Compare commits
4 commits
main
...
feature/売上
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cfbe9dcb7 | ||
|
|
a2f7013984 | ||
|
|
c98dd3cc72 | ||
|
|
632a95bd0e |
98 changed files with 14904 additions and 796 deletions
125
README.md
125
README.md
|
|
@ -22,6 +22,16 @@
|
|||
- Phone ブック取り込みを共通化した `ContactPickerSheet`
|
||||
- 税率・税表示・印影の追加設定
|
||||
- 90 日寿命チェック(`BuildExpiryInfo`)と期限切れ画面
|
||||
- モジュール指向ダッシュボード
|
||||
- `FeatureModule` / `ModuleRegistry` により各機能を独立カードとして登録
|
||||
- A2(伝票履歴)/A1(伝票入力)モジュールと売上管理モジュールを実装済み
|
||||
- 伝票ロックバーやカード表示は AppConfig の feature flag で制御
|
||||
- ダッシュボードと売上モジュールの最新強化
|
||||
- `AppConfig.enabledRoutes` にダッシュボード・売上伝票(U1/U2)経路を含め、S1 設定画面で登録したカードが D1 に確実に現れるよう調整
|
||||
- ダッシュボードカード登録時に有効モジュールを自動注入し、売上伝票入力(`sales_entries`)カードを SalesManagementModule から直接表示
|
||||
- A2(AppBar) の左上ボタンがホームモード設定に追従し、戻る/メニューボタンを正しく出し分け
|
||||
- U2:売上伝票編集では保存アイコン・テキストボタンを AppBar に追加し、明細フォームのキーボード余白を整理して「せり上がり」を軽減
|
||||
- U2 の顧客選択モーダルを `Scaffold + AppBar` 構成に刷新し、タイトル/閉じる操作を統一
|
||||
- ビルド用スクリプト `scripts/build_with_expiry.sh`
|
||||
- `--dart-define=APP_BUILD_TIMESTAMP` を自動付与し APK を生成
|
||||
- analyze 実行~APK ビルドのワンステップ化
|
||||
|
|
@ -37,7 +47,7 @@
|
|||
- Google Drive への自動バックアップ、容量推定
|
||||
2. **販売アシスト1号の拡張モジュール化**
|
||||
- 売上(POS)、仕入、在庫、チャット、通知をモジュールとして追加
|
||||
- ダッシュボードにモジュールカードを組み込む方式へ刷新
|
||||
- ダッシュボードにモジュールカードを組み込む方式へ刷新(初期実装済)
|
||||
3. **チャット&サポート**
|
||||
- 「順次対応である」旨を明記した問い合わせチャットをローカル実装
|
||||
- 母艦側で受信・返信・履歴管理ができる仕組みを構築
|
||||
|
|
@ -74,15 +84,118 @@
|
|||
```bash
|
||||
flutter pub get
|
||||
```
|
||||
2. 90 日寿命 APK の生成
|
||||
2. 90 日寿命 APK の生成(`scripts/build_with_expiry.sh [mode] [flavor]`)
|
||||
```bash
|
||||
chmod +x scripts/build_with_expiry.sh
|
||||
./scripts/build_with_expiry.sh [debug|profile|release]
|
||||
# 例: debug×client フレーバー(販売アシスト1号)
|
||||
./scripts/build_with_expiry.sh debug client
|
||||
|
||||
# 例: release×mothership フレーバー(お局様)
|
||||
./scripts/build_with_expiry.sh release mothership
|
||||
```
|
||||
- スクリプト内で `APP_BUILD_TIMESTAMP` を UTC で自動付与
|
||||
- `flutter analyze` → `flutter build apk` を連続実行
|
||||
- `APP_BUILD_TIMESTAMP` を UTC で自動付与し、`ENABLE_DEBUG_FEATURES=true` で全機能を有効化
|
||||
- `flutter analyze` → `flutter build apk --flavor ... --dart-define=...` を連続実行
|
||||
3. 実機/エミュレータで起動すると、寿命切れ時には `ExpiredApp` が自動表示されます。
|
||||
|
||||
### 機能フラグ(モジュール)
|
||||
|
||||
アプリは `AppConfig` の dart-define を通じてモジュール単位で有効化できます。
|
||||
|
||||
| Flag | 既定値 | 説明 |
|
||||
| --- | --- | --- |
|
||||
| `ENABLE_DEBUG_FEATURES` | `false` | **マスター・スイッチ**。`true` にすると以下の各機能フラグ/Debug Webhook がすべて強制的に ON になる |
|
||||
| `ENABLE_BILLING_DOCS` | `true` | 伝票作成/履歴モジュール(A1/A2)の表示を制御 |
|
||||
| `ENABLE_SALES_MANAGEMENT` | `false` | 売上管理モジュール(年間カード・トップ顧客・月次サマリー)を有効化 |
|
||||
| `ENABLE_SALES_OPERATIONS` | `false` | 受注/出荷/在庫など販売オペレーションモジュールを有効化 |
|
||||
| `ENABLE_PURCHASE_MANAGEMENT` | `false` | 仕入伝票・支払管理(P1〜P4)モジュールを有効化 |
|
||||
| `ENABLE_DEBUG_WEBHOOK` | `false` | MatterMost Webhook へノード情報/日時の debug log を送信 |
|
||||
| `DEBUG_WEBHOOK_URL` | `https://mm.ka.sugeee.com/hooks/x6nxx8q35jdkuetbmh89ogt5ze` | debug 送信先を上書きしたい場合に指定 |
|
||||
|
||||
例1: 売上管理と debug ログ送信を同時に試す場合
|
||||
|
||||
```bash
|
||||
flutter run \
|
||||
--dart-define=ENABLE_SALES_MANAGEMENT=true \
|
||||
--dart-define=ENABLE_DEBUG_WEBHOOK=true \
|
||||
--dart-define=DEBUG_WEBHOOK_URL=https://mm.ka.sugeee.com/hooks/x6nxx8q35jdkuetbmh89ogt5ze
|
||||
```
|
||||
|
||||
`ENABLE_DEBUG_WEBHOOK=false`(既定値)に戻すと MatterMost への送信は行われません。フラグが有効なモジュールは `ModuleRegistry` 経由でダッシュボードカードに自動注入され、debug フラグはアプリ起動時の ping 送信のみを制御します。
|
||||
|
||||
例2: すべての機能を一括で有効化したい場合(`ENABLE_DEBUG_FEATURES=true` を指定するだけで OK)
|
||||
|
||||
```bash
|
||||
flutter run \
|
||||
--dart-define=ENABLE_DEBUG_FEATURES=true
|
||||
```
|
||||
|
||||
個別フラグを false のまま渡しても、このマスター・スイッチが ON の間は `AppConfig` 内で強制的に true として扱われます。
|
||||
|
||||
### Android ビルドフレーバー(Play 想定の二本立て)
|
||||
|
||||
`android/app/build.gradle.kts` に `client` / `mothership` の 2 フレーバーを定義しました。これにより将来 Google Play で「販売アシスト1号」と「お局様」を別 APK として配布しやすくなります。
|
||||
|
||||
| Flavor | applicationId | `appName` (manifest placeholder) | 用途 |
|
||||
| --- | --- | --- | --- |
|
||||
| `client` | `com.example.assist1` | `販売アシスト1号` | 現場端末向けクライアントアプリ(既存機能) |
|
||||
| `mothership` | `com.example.mothership` | `お局様` | 将来の母艦/監視用アプリ(まだ UI 未実装) |
|
||||
|
||||
起動コマンド例:
|
||||
|
||||
```bash
|
||||
# 販売アシスト1号(既定)
|
||||
flutter run --flavor client
|
||||
|
||||
# お局様フレーバーを実行(まだ UI は同じだがパッケージ名とラベルが分離)
|
||||
flutter run --flavor mothership
|
||||
```
|
||||
|
||||
> メモ: 実機配布前に `applicationId` を本番用ドメインへ変更し、Play Console の keystore / サイン設定に合わせて `release` ビルドタイプの signingConfig を更新してください。
|
||||
|
||||
この構成により、販売アシスト1号・お局様それぞれを別ストアリスティングで公開、あるいはお局様だけ別配布チャネルで提供するといった運用が可能になります。
|
||||
|
||||
### 画面IDとナビゲーション指針
|
||||
|
||||
最新の UI アップデートにより、画面遷移ルールと画面タイトルの表記を統一しました。
|
||||
|
||||
- **すべての AppBar タイトルは 2 文字の画面ID + コロン + タイトル** で表示します(例: `S1:設定`, `U4:入金編集`)。
|
||||
- **ホーム以外の画面は明示的な戻るボタンを左上に表示** します。`Scaffold` の `leading` で `BackButton` を指定し、ユーザーが階層を把握しやすいようにします。
|
||||
- **伝票一覧(A2:履歴リスト)がホームモードの場合のみ三本線のメニューボタン** を表示し、ドロワーから各種マスターや設定へ遷移できます。ホームモードでなければ通常通り戻るボタンを表示します。
|
||||
- 新規に追加する画面もこの規約に従って ID を採番し、Dashboard 側のカードやメニュー表示名もあわせて更新してください。
|
||||
- 売上伝票・入金管理(U1〜U4)など財務関連の新画面にもすでに適用済みです。各機能の AppBar を流用する際は ID だけ差し替えられるようコンポーネント化を検討しています。
|
||||
|
||||
---
|
||||
|
||||
## 粗利計算と卸値管理
|
||||
|
||||
- 商品マスタ(P1)に **仕入値(wholesale_price)** を保持するフィールドを追加しました。新規/既存商品の編集ダイアログで販売単価と合わせて卸値を登録できます。
|
||||
- U2/A1 での売上明細は商品選択時に `ProductPickerModal` から卸値を受け取り、明細内部にコストを保持します。仕入値が未登録の明細は **暫定粗利=0** として扱い、仕入確定後に再計算する前提です。
|
||||
- S1:設定 に「粗利表示 / 暫定粗利」セクションを追加し、次のスイッチで運用を制御できます。
|
||||
1. **U2/A1で粗利を表示** … 単価−仕入値を計算して行ごとに表示。
|
||||
2. **営業端末に粗利表示スイッチを表示** … 現場ユーザーが粗利の表示/非表示を切り替えられるようにする。
|
||||
3. **暫定粗利(仕入未確定)を合計に含める** … 未入荷・未知商品の粗利=0 を合計に含めるかどうかを制御。
|
||||
- これらの設定値は `app_settings` テーブルに保存され、端末再起動後も保持されます。アプリの将来バージョンではロット別の仕入れ管理と合わせて粗利再計算ジョブを提供する予定です。
|
||||
|
||||
---
|
||||
|
||||
## Googleカレンダー連携
|
||||
|
||||
営業オペレーションの ToDo を Google カレンダーへ自動配信します。設定画面(S1:設定 → 「Googleカレンダー連携」セクション)から次の手順で利用できます。
|
||||
|
||||
1. 「Googleカレンダーと連携する」を ON に切り替え、Google アカウントへサインインします。
|
||||
2. 「カレンダー一覧を取得」で同期可能なカレンダーを読み込み、プライマリまたは任意の書き込み権限付きカレンダーを選択します。
|
||||
3. 以降、出荷 (`ShipmentService`) と債権 (`ReceivableService`) のイベントが `BusinessCalendarMapper` を経由して自動同期され、Google 側では `shipment-<id>` / `receivable-<invoiceId>` という extendedProperties 付きで登録されます。
|
||||
4. 必要に応じて「今すぐカレンダー同期を実行」ボタンを押すと、全件再同期+結果サマリ(件数・エラー詳細)が表示されます。手動同期は `CalendarSyncDiagnostics` により実装されています。
|
||||
|
||||
### 同期対象イベント
|
||||
|
||||
| 区分 | 連携トリガー | 内容 |
|
||||
| --- | --- | --- |
|
||||
| 出荷 | 新規作成・更新・ステータス遷移 | 出荷予定/実績日を 9:00〜2h イベントとして登録。顧客名、受注番号、追跡情報などを本文に記載。 |
|
||||
| 債権 | サマリ取得・入金追加/削除 | 期日を 10:00〜1h イベントとして登録。請求額や残高を本文に記載。 |
|
||||
|
||||
Google 側でカレンダーを変更したい場合は、再度一覧取得→選択を行ってください。サインイン状態が切れた場合は「Googleを再認証」でリフレッシュできます。
|
||||
|
||||
---
|
||||
|
||||
## 母艦「お局様」LAN サーバの起動
|
||||
|
|
@ -117,7 +230,7 @@
|
|||
|
||||
- README は **機能追加・アーキテクチャ変更・モジュール構成の見直し時に必ず更新** します。
|
||||
- 変更履歴とファイルツリーは必要に応じて追記し、最新状態を反映させます。
|
||||
- 設計検討中の内容(母艦 Web UI、チャット、モジュール化など)は本 README の「将来像」節で随時アップデートします。
|
||||
- 設計検討中の内容(母艦 Web UI、チャット、モジュール化など)は本 README の「将来像」節で随時アップデートします。現在は売上モジュールが最初の実装例です。
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -20,8 +20,6 @@ android {
|
|||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.example.h_1"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
|
|
@ -30,6 +28,20 @@ android {
|
|||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
flavorDimensions += listOf("distribution")
|
||||
productFlavors {
|
||||
create("client") {
|
||||
dimension = "distribution"
|
||||
applicationId = "com.example.assist1"
|
||||
manifestPlaceholders["appName"] = "販売アシスト1号"
|
||||
}
|
||||
create("mothership") {
|
||||
dimension = "distribution"
|
||||
applicationId = "com.example.mothership"
|
||||
manifestPlaceholders["appName"] = "お局様"
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@
|
|||
</queries>
|
||||
|
||||
<application
|
||||
android:label="h-1"
|
||||
android:label="${appName}"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
>
|
||||
|
|
|
|||
4
android/app/src/main/res/values/strings.xml
Normal file
4
android/app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">販売アシスト1号</string>
|
||||
</resources>
|
||||
BIN
assets/icon/Gemini_Generated_Image_zemfu5zemfu5zemf.ico
Normal file
BIN
assets/icon/Gemini_Generated_Image_zemfu5zemfu5zemf.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 361 KiB |
BIN
assets/icon/app_icon.png
Normal file
BIN
assets/icon/app_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
|
|
@ -24,6 +24,12 @@
|
|||
@import geolocator_apple;
|
||||
#endif
|
||||
|
||||
#if __has_include(<google_sign_in_ios/FLTGoogleSignInPlugin.h>)
|
||||
#import <google_sign_in_ios/FLTGoogleSignInPlugin.h>
|
||||
#else
|
||||
@import google_sign_in_ios;
|
||||
#endif
|
||||
|
||||
#if __has_include(<image_picker_ios/FLTImagePickerPlugin.h>)
|
||||
#import <image_picker_ios/FLTImagePickerPlugin.h>
|
||||
#else
|
||||
|
|
@ -90,6 +96,7 @@
|
|||
[FlutterContactsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterContactsPlugin"]];
|
||||
[FlutterEmailSenderPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterEmailSenderPlugin"]];
|
||||
[GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]];
|
||||
[FLTGoogleSignInPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTGoogleSignInPlugin"]];
|
||||
[FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]];
|
||||
[MobileScannerPlugin registerWithRegistrar:[registry registrarForPlugin:@"MobileScannerPlugin"]];
|
||||
[OpenFilePlugin registerWithRegistrar:[registry registrarForPlugin:@"OpenFilePlugin"]];
|
||||
|
|
|
|||
|
|
@ -6,8 +6,22 @@ class AppConfig {
|
|||
static const String version = String.fromEnvironment('APP_VERSION', defaultValue: '1.0.0');
|
||||
|
||||
/// 機能フラグ(ビルド時に --dart-define で上書き可能)。
|
||||
static const bool enableBillingDocs = bool.fromEnvironment('ENABLE_BILLING_DOCS', defaultValue: true);
|
||||
static const bool enableSalesManagement = bool.fromEnvironment('ENABLE_SALES_MANAGEMENT', defaultValue: false);
|
||||
static const bool _enableDebugFeatures = bool.fromEnvironment('ENABLE_DEBUG_FEATURES', defaultValue: false);
|
||||
static const bool _enableBillingDocsFlag = bool.fromEnvironment('ENABLE_BILLING_DOCS', defaultValue: true);
|
||||
static const bool _enableSalesManagementFlag = bool.fromEnvironment('ENABLE_SALES_MANAGEMENT', defaultValue: false);
|
||||
static const bool _enableSalesOperationsFlag = bool.fromEnvironment('ENABLE_SALES_OPERATIONS', defaultValue: false);
|
||||
static const bool _enablePurchaseManagementFlag = bool.fromEnvironment('ENABLE_PURCHASE_MANAGEMENT', defaultValue: false);
|
||||
static const bool _enableDebugWebhookLoggingFlag = bool.fromEnvironment('ENABLE_DEBUG_WEBHOOK', defaultValue: false);
|
||||
static const String debugWebhookUrl = String.fromEnvironment(
|
||||
'DEBUG_WEBHOOK_URL',
|
||||
defaultValue: 'https://mm.ka.sugeee.com/hooks/x6nxx8q35jdkuetbmh89ogt5ze',
|
||||
);
|
||||
|
||||
static bool get enableBillingDocs => _enableDebugFeatures || _enableBillingDocsFlag;
|
||||
static bool get enableSalesManagement => _enableDebugFeatures || _enableSalesManagementFlag;
|
||||
static bool get enableSalesOperations => _enableDebugFeatures || _enableSalesOperationsFlag;
|
||||
static bool get enablePurchaseManagement => _enableDebugFeatures || _enablePurchaseManagementFlag;
|
||||
static bool get enableDebugWebhookLogging => _enableDebugFeatures || _enableDebugWebhookLoggingFlag;
|
||||
|
||||
/// APIエンドポイント(必要に応じて dart-define で注入)。
|
||||
static const String apiEndpoint = String.fromEnvironment('API_ENDPOINT', defaultValue: '');
|
||||
|
|
@ -16,6 +30,9 @@ class AppConfig {
|
|||
static Map<String, bool> get features => {
|
||||
'enableBillingDocs': enableBillingDocs,
|
||||
'enableSalesManagement': enableSalesManagement,
|
||||
'enableSalesOperations': enableSalesOperations,
|
||||
'enablePurchaseManagement': enablePurchaseManagement,
|
||||
'enableDebugWebhookLogging': enableDebugWebhookLogging,
|
||||
};
|
||||
|
||||
/// 機能キーで有効/無効を判定するヘルパー。
|
||||
|
|
@ -23,12 +40,18 @@ class AppConfig {
|
|||
|
||||
/// 有効なダッシュボードルート一覧(動的に増える場合はここで管理)。
|
||||
static Set<String> get enabledRoutes {
|
||||
final routes = <String>{'settings'};
|
||||
final routes = <String>{'settings', 'dashboard'};
|
||||
if (enableBillingDocs) {
|
||||
routes.addAll({'invoice_history', 'invoice_input', 'master_hub', 'customer_master', 'product_master'});
|
||||
}
|
||||
if (enableSalesManagement) {
|
||||
routes.add('sales_management');
|
||||
routes.addAll({'sales_management', 'sales_entries'});
|
||||
}
|
||||
if (enableSalesOperations) {
|
||||
routes.addAll({'sales_orders', 'shipments', 'inventory', 'receivables'});
|
||||
}
|
||||
if (enablePurchaseManagement) {
|
||||
routes.addAll({'purchase_entries', 'purchase_receipts'});
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import 'services/app_settings_repository.dart';
|
|||
import 'services/chat_sync_scheduler.dart';
|
||||
import 'services/mothership_client.dart';
|
||||
import 'services/theme_controller.dart';
|
||||
import 'services/debug_webhook_logger.dart';
|
||||
import 'utils/build_expiry_info.dart';
|
||||
|
||||
void main() async {
|
||||
|
|
@ -44,11 +45,13 @@ class _MyAppState extends State<MyApp> {
|
|||
int _activePointers = 0;
|
||||
final MothershipClient _mothershipClient = MothershipClient();
|
||||
final ChatSyncScheduler _chatSyncScheduler = ChatSyncScheduler();
|
||||
final DebugWebhookLogger _debugLogger = const DebugWebhookLogger();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_sendHeartbeat();
|
||||
_sendDebugPing();
|
||||
_chatSyncScheduler.start();
|
||||
}
|
||||
|
||||
|
|
@ -62,6 +65,10 @@ class _MyAppState extends State<MyApp> {
|
|||
Future.microtask(() => _mothershipClient.sendHeartbeat(widget.expiryInfo));
|
||||
}
|
||||
|
||||
void _sendDebugPing() {
|
||||
Future.microtask(() => _debugLogger.sendNodePing(note: 'App boot completed'));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<ThemeMode>(
|
||||
|
|
|
|||
60
lib/models/department_model.dart
Normal file
60
lib/models/department_model.dart
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
|
||||
@immutable
|
||||
class Department {
|
||||
const Department({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.code,
|
||||
this.description,
|
||||
this.isActive = true,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final String? code;
|
||||
final String? description;
|
||||
final bool isActive;
|
||||
final DateTime updatedAt;
|
||||
|
||||
Department copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
String? code,
|
||||
String? description,
|
||||
bool? isActive,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
return Department(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
code: code ?? this.code,
|
||||
description: description ?? this.description,
|
||||
isActive: isActive ?? this.isActive,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
factory Department.fromMap(Map<String, Object?> map) {
|
||||
return Department(
|
||||
id: map['id'] as String,
|
||||
name: map['name'] as String? ?? '-',
|
||||
code: map['code'] as String?,
|
||||
description: map['description'] as String?,
|
||||
isActive: (map['is_active'] as int? ?? 1) == 1,
|
||||
updatedAt: DateTime.parse(map['updated_at'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> toMap() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'code': code,
|
||||
'description': description,
|
||||
'is_active': isActive ? 1 : 0,
|
||||
'updated_at': updatedAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
||||
33
lib/models/hash_chain_models.dart
Normal file
33
lib/models/hash_chain_models.dart
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
class HashChainBreak {
|
||||
const HashChainBreak({
|
||||
required this.invoiceId,
|
||||
this.invoiceNumber,
|
||||
required this.issue,
|
||||
this.expectedHash,
|
||||
this.actualHash,
|
||||
this.expectedPreviousHash,
|
||||
this.actualPreviousHash,
|
||||
});
|
||||
|
||||
final String invoiceId;
|
||||
final String? invoiceNumber;
|
||||
final String issue;
|
||||
final String? expectedHash;
|
||||
final String? actualHash;
|
||||
final String? expectedPreviousHash;
|
||||
final String? actualPreviousHash;
|
||||
}
|
||||
|
||||
class HashChainVerificationResult {
|
||||
const HashChainVerificationResult({
|
||||
required this.isHealthy,
|
||||
required this.checkedCount,
|
||||
required this.verifiedAt,
|
||||
required this.breaks,
|
||||
});
|
||||
|
||||
final bool isHealthy;
|
||||
final int checkedCount;
|
||||
final DateTime verifiedAt;
|
||||
final List<HashChainBreak> breaks;
|
||||
}
|
||||
124
lib/models/inventory_models.dart
Normal file
124
lib/models/inventory_models.dart
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import 'package:meta/meta.dart';
|
||||
|
||||
/// 入出庫区分。
|
||||
enum InventoryMovementType {
|
||||
receipt,
|
||||
issue,
|
||||
adjustment,
|
||||
}
|
||||
|
||||
extension InventoryMovementTypeX on InventoryMovementType {
|
||||
String get displayName {
|
||||
switch (this) {
|
||||
case InventoryMovementType.receipt:
|
||||
return '入庫';
|
||||
case InventoryMovementType.issue:
|
||||
return '出庫';
|
||||
case InventoryMovementType.adjustment:
|
||||
return '棚卸/調整';
|
||||
}
|
||||
}
|
||||
|
||||
/// 在庫増減に与える係数。
|
||||
int get deltaSign {
|
||||
switch (this) {
|
||||
case InventoryMovementType.receipt:
|
||||
return 1;
|
||||
case InventoryMovementType.issue:
|
||||
return -1;
|
||||
case InventoryMovementType.adjustment:
|
||||
return 1; // 調整は quantityDelta をそのまま使うため符号 1 とする
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class InventoryMovement {
|
||||
const InventoryMovement({
|
||||
required this.id,
|
||||
required this.productId,
|
||||
required this.productNameSnapshot,
|
||||
required this.type,
|
||||
required this.quantity,
|
||||
required this.quantityDelta,
|
||||
required this.createdAt,
|
||||
this.reference,
|
||||
this.notes,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String productId;
|
||||
final String productNameSnapshot;
|
||||
final InventoryMovementType type;
|
||||
final int quantity;
|
||||
final int quantityDelta;
|
||||
final DateTime createdAt;
|
||||
final String? reference;
|
||||
final String? notes;
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'id': id,
|
||||
'product_id': productId,
|
||||
'product_name_snapshot': productNameSnapshot,
|
||||
'movement_type': type.name,
|
||||
'quantity': quantity,
|
||||
'quantity_delta': quantityDelta,
|
||||
'reference': reference,
|
||||
'notes': notes,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
};
|
||||
|
||||
factory InventoryMovement.fromMap(Map<String, dynamic> map) {
|
||||
InventoryMovementType parseType(String? value) {
|
||||
return InventoryMovementType.values.firstWhere(
|
||||
(type) => type.name == value,
|
||||
orElse: () => InventoryMovementType.adjustment,
|
||||
);
|
||||
}
|
||||
|
||||
return InventoryMovement(
|
||||
id: map['id'] as String,
|
||||
productId: map['product_id'] as String,
|
||||
productNameSnapshot: map['product_name_snapshot'] as String? ?? '-',
|
||||
type: parseType(map['movement_type'] as String?),
|
||||
quantity: map['quantity'] as int? ?? 0,
|
||||
quantityDelta: map['quantity_delta'] as int? ?? 0,
|
||||
reference: map['reference'] as String?,
|
||||
notes: map['notes'] as String?,
|
||||
createdAt: DateTime.parse(map['created_at'] as String),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class InventorySummary {
|
||||
const InventorySummary({
|
||||
required this.productId,
|
||||
required this.productName,
|
||||
required this.stockQuantity,
|
||||
this.category,
|
||||
this.defaultUnitPrice,
|
||||
this.lastMovementAt,
|
||||
});
|
||||
|
||||
final String productId;
|
||||
final String productName;
|
||||
final int stockQuantity;
|
||||
final String? category;
|
||||
final int? defaultUnitPrice;
|
||||
final DateTime? lastMovementAt;
|
||||
|
||||
InventorySummary copyWith({
|
||||
int? stockQuantity,
|
||||
DateTime? lastMovementAt,
|
||||
}) {
|
||||
return InventorySummary(
|
||||
productId: productId,
|
||||
productName: productName,
|
||||
stockQuantity: stockQuantity ?? this.stockQuantity,
|
||||
category: category,
|
||||
defaultUnitPrice: defaultUnitPrice,
|
||||
lastMovementAt: lastMovementAt ?? this.lastMovementAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -65,6 +65,21 @@ enum DocumentType {
|
|||
receipt, // 領収
|
||||
}
|
||||
|
||||
extension DocumentTypeDisplay on DocumentType {
|
||||
String get displayName {
|
||||
switch (this) {
|
||||
case DocumentType.estimation:
|
||||
return '見積書';
|
||||
case DocumentType.delivery:
|
||||
return '納品書';
|
||||
case DocumentType.invoice:
|
||||
return '請求書';
|
||||
case DocumentType.receipt:
|
||||
return '領収書';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Invoice {
|
||||
static const String lockStatement =
|
||||
'正式発行ボタン押下時にこの伝票はロックされ、以後の編集・削除はできません。ロック状態はハッシュチェーンで保護されます。';
|
||||
|
|
@ -96,6 +111,9 @@ class Invoice {
|
|||
final String? companySealHash; // 追加: 角印画像ハッシュ
|
||||
final String? metaJson;
|
||||
final String? metaHash;
|
||||
final String? previousChainHash;
|
||||
final String? chainHash;
|
||||
final String chainStatus;
|
||||
|
||||
Invoice({
|
||||
String? id,
|
||||
|
|
@ -124,6 +142,9 @@ class Invoice {
|
|||
this.companySealHash,
|
||||
this.metaJson,
|
||||
this.metaHash,
|
||||
this.previousChainHash,
|
||||
this.chainHash,
|
||||
this.chainStatus = 'pending',
|
||||
}) : id = id ?? DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
terminalId = terminalId ?? "T1", // デフォルト端末ID
|
||||
updatedAt = updatedAt ?? DateTime.now();
|
||||
|
|
@ -259,6 +280,9 @@ class Invoice {
|
|||
'company_seal_hash': companySealHash,
|
||||
'meta_json': metaJsonValue,
|
||||
'meta_hash': metaHashValue,
|
||||
'previous_chain_hash': previousChainHash,
|
||||
'chain_hash': chainHash,
|
||||
'chain_status': chainStatus,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -289,6 +313,9 @@ class Invoice {
|
|||
String? companySealHash,
|
||||
String? metaJson,
|
||||
String? metaHash,
|
||||
String? previousChainHash,
|
||||
String? chainHash,
|
||||
String? chainStatus,
|
||||
}) {
|
||||
return Invoice(
|
||||
id: id ?? this.id,
|
||||
|
|
@ -317,6 +344,9 @@ class Invoice {
|
|||
companySealHash: companySealHash ?? this.companySealHash,
|
||||
metaJson: metaJson ?? this.metaJson,
|
||||
metaHash: metaHash ?? this.metaHash,
|
||||
previousChainHash: previousChainHash ?? this.previousChainHash,
|
||||
chainHash: chainHash ?? this.chainHash,
|
||||
chainStatus: chainStatus ?? this.chainStatus,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
211
lib/models/order_models.dart
Normal file
211
lib/models/order_models.dart
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import 'package:collection/collection.dart';
|
||||
|
||||
/// 受注ステータスの定義。
|
||||
enum SalesOrderStatus {
|
||||
draft,
|
||||
confirmed,
|
||||
picking,
|
||||
shipped,
|
||||
closed,
|
||||
cancelled,
|
||||
}
|
||||
|
||||
extension SalesOrderStatusX on SalesOrderStatus {
|
||||
String get displayName {
|
||||
switch (this) {
|
||||
case SalesOrderStatus.draft:
|
||||
return '下書き';
|
||||
case SalesOrderStatus.confirmed:
|
||||
return '確定';
|
||||
case SalesOrderStatus.picking:
|
||||
return '出荷準備中';
|
||||
case SalesOrderStatus.shipped:
|
||||
return '出荷済み';
|
||||
case SalesOrderStatus.closed:
|
||||
return '完了';
|
||||
case SalesOrderStatus.cancelled:
|
||||
return 'キャンセル';
|
||||
}
|
||||
}
|
||||
|
||||
static SalesOrderStatus fromDbValue(String? value) {
|
||||
return SalesOrderStatus.values.firstWhere(
|
||||
(status) => status.name == value,
|
||||
orElse: () => SalesOrderStatus.draft,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SalesOrderItem {
|
||||
SalesOrderItem({
|
||||
required this.id,
|
||||
required this.orderId,
|
||||
required this.description,
|
||||
required this.quantity,
|
||||
required this.unitPrice,
|
||||
this.productId,
|
||||
this.taxRate = 0,
|
||||
this.sortIndex = 0,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String orderId;
|
||||
final String? productId;
|
||||
final String description;
|
||||
final int quantity;
|
||||
final int unitPrice;
|
||||
final double taxRate;
|
||||
final int sortIndex;
|
||||
|
||||
int get lineTotal => quantity * unitPrice;
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'id': id,
|
||||
'order_id': orderId,
|
||||
'product_id': productId,
|
||||
'description': description,
|
||||
'quantity': quantity,
|
||||
'unit_price': unitPrice,
|
||||
'tax_rate': taxRate,
|
||||
'sort_index': sortIndex,
|
||||
};
|
||||
|
||||
factory SalesOrderItem.fromMap(Map<String, dynamic> map) => SalesOrderItem(
|
||||
id: map['id'] as String,
|
||||
orderId: map['order_id'] as String,
|
||||
productId: map['product_id'] as String?,
|
||||
description: map['description'] as String,
|
||||
quantity: map['quantity'] as int,
|
||||
unitPrice: map['unit_price'] as int,
|
||||
taxRate: (map['tax_rate'] as num?)?.toDouble() ?? 0,
|
||||
sortIndex: map['sort_index'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
class SalesOrder {
|
||||
SalesOrder({
|
||||
required this.id,
|
||||
required this.customerId,
|
||||
required this.orderDate,
|
||||
required this.status,
|
||||
required this.subtotal,
|
||||
required this.taxAmount,
|
||||
required this.totalAmount,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
this.orderNumber,
|
||||
this.customerNameSnapshot,
|
||||
this.requestedShipDate,
|
||||
this.notes,
|
||||
this.assignedTo,
|
||||
this.workflowStage,
|
||||
this.items = const [],
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String customerId;
|
||||
final String? customerNameSnapshot;
|
||||
final DateTime orderDate;
|
||||
final DateTime? requestedShipDate;
|
||||
final SalesOrderStatus status;
|
||||
final int subtotal;
|
||||
final int taxAmount;
|
||||
final int totalAmount;
|
||||
final String? orderNumber;
|
||||
final String? notes;
|
||||
final String? assignedTo;
|
||||
final String? workflowStage;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
final List<SalesOrderItem> items;
|
||||
|
||||
SalesOrder copyWith({
|
||||
String? id,
|
||||
String? customerId,
|
||||
String? customerNameSnapshot,
|
||||
DateTime? orderDate,
|
||||
DateTime? requestedShipDate,
|
||||
SalesOrderStatus? status,
|
||||
int? subtotal,
|
||||
int? taxAmount,
|
||||
int? totalAmount,
|
||||
String? orderNumber,
|
||||
String? notes,
|
||||
String? assignedTo,
|
||||
String? workflowStage,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
List<SalesOrderItem>? items,
|
||||
}) {
|
||||
return SalesOrder(
|
||||
id: id ?? this.id,
|
||||
customerId: customerId ?? this.customerId,
|
||||
customerNameSnapshot: customerNameSnapshot ?? this.customerNameSnapshot,
|
||||
orderDate: orderDate ?? this.orderDate,
|
||||
requestedShipDate: requestedShipDate ?? this.requestedShipDate,
|
||||
status: status ?? this.status,
|
||||
subtotal: subtotal ?? this.subtotal,
|
||||
taxAmount: taxAmount ?? this.taxAmount,
|
||||
totalAmount: totalAmount ?? this.totalAmount,
|
||||
orderNumber: orderNumber ?? this.orderNumber,
|
||||
notes: notes ?? this.notes,
|
||||
assignedTo: assignedTo ?? this.assignedTo,
|
||||
workflowStage: workflowStage ?? this.workflowStage,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
items: items ?? this.items,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'id': id,
|
||||
'order_number': orderNumber,
|
||||
'customer_id': customerId,
|
||||
'customer_name_snapshot': customerNameSnapshot,
|
||||
'order_date': orderDate.toIso8601String(),
|
||||
'requested_ship_date': requestedShipDate?.toIso8601String(),
|
||||
'status': status.name,
|
||||
'subtotal': subtotal,
|
||||
'tax_amount': taxAmount,
|
||||
'total_amount': totalAmount,
|
||||
'notes': notes,
|
||||
'assigned_to': assignedTo,
|
||||
'workflow_stage': workflowStage,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'updated_at': updatedAt.toIso8601String(),
|
||||
};
|
||||
|
||||
factory SalesOrder.fromMap(Map<String, dynamic> map, {List<SalesOrderItem> items = const []}) {
|
||||
return SalesOrder(
|
||||
id: map['id'] as String,
|
||||
orderNumber: map['order_number'] as String?,
|
||||
customerId: map['customer_id'] as String,
|
||||
customerNameSnapshot: map['customer_name_snapshot'] as String?,
|
||||
orderDate: DateTime.parse(map['order_date'] as String),
|
||||
requestedShipDate: (map['requested_ship_date'] as String?) != null
|
||||
? DateTime.parse(map['requested_ship_date'] as String)
|
||||
: null,
|
||||
status: SalesOrderStatusX.fromDbValue(map['status'] as String?),
|
||||
subtotal: map['subtotal'] as int? ?? 0,
|
||||
taxAmount: map['tax_amount'] as int? ?? 0,
|
||||
totalAmount: map['total_amount'] as int? ?? 0,
|
||||
notes: map['notes'] as String?,
|
||||
assignedTo: map['assigned_to'] as String?,
|
||||
workflowStage: map['workflow_stage'] as String?,
|
||||
createdAt: DateTime.parse(map['created_at'] as String),
|
||||
updatedAt: DateTime.parse(map['updated_at'] as String),
|
||||
items: items,
|
||||
);
|
||||
}
|
||||
|
||||
SalesOrder recalculateTotals({double? defaultTaxRate}) {
|
||||
final newSubtotal = items.fold<int>(0, (sum, item) => sum + item.lineTotal);
|
||||
final taxRate = defaultTaxRate ?? items.map((e) => e.taxRate).firstWhereOrNull((rate) => rate > 0) ?? 0;
|
||||
final newTaxAmount = (newSubtotal * taxRate).round();
|
||||
return copyWith(
|
||||
subtotal: newSubtotal,
|
||||
taxAmount: newTaxAmount,
|
||||
totalAmount: newSubtotal + newTaxAmount,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ class Product {
|
|||
final String id;
|
||||
final String name;
|
||||
final int defaultUnitPrice;
|
||||
final int wholesalePrice;
|
||||
final String? barcode;
|
||||
final String? category;
|
||||
final int stockQuantity; // 追加
|
||||
|
|
@ -13,6 +14,7 @@ class Product {
|
|||
required this.id,
|
||||
required this.name,
|
||||
this.defaultUnitPrice = 0,
|
||||
this.wholesalePrice = 0,
|
||||
this.barcode,
|
||||
this.category,
|
||||
this.stockQuantity = 0, // 追加
|
||||
|
|
@ -26,6 +28,7 @@ class Product {
|
|||
'id': id,
|
||||
'name': name,
|
||||
'default_unit_price': defaultUnitPrice,
|
||||
'wholesale_price': wholesalePrice,
|
||||
'barcode': barcode,
|
||||
'category': category,
|
||||
'stock_quantity': stockQuantity, // 追加
|
||||
|
|
@ -40,6 +43,7 @@ class Product {
|
|||
id: map['id'],
|
||||
name: map['name'],
|
||||
defaultUnitPrice: map['default_unit_price'] ?? 0,
|
||||
wholesalePrice: map['wholesale_price'] ?? 0,
|
||||
barcode: map['barcode'],
|
||||
category: map['category'],
|
||||
stockQuantity: map['stock_quantity'] ?? 0, // 追加
|
||||
|
|
@ -53,6 +57,7 @@ class Product {
|
|||
String? id,
|
||||
String? name,
|
||||
int? defaultUnitPrice,
|
||||
int? wholesalePrice,
|
||||
String? barcode,
|
||||
String? category,
|
||||
int? stockQuantity,
|
||||
|
|
@ -64,6 +69,7 @@ class Product {
|
|||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
defaultUnitPrice: defaultUnitPrice ?? this.defaultUnitPrice,
|
||||
wholesalePrice: wholesalePrice ?? this.wholesalePrice,
|
||||
barcode: barcode ?? this.barcode,
|
||||
category: category ?? this.category,
|
||||
stockQuantity: stockQuantity ?? this.stockQuantity,
|
||||
|
|
|
|||
288
lib/models/purchase_entry_models.dart
Normal file
288
lib/models/purchase_entry_models.dart
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
|
||||
enum PurchaseEntryStatus { draft, confirmed, settled }
|
||||
|
||||
extension PurchaseEntryStatusDisplay on PurchaseEntryStatus {
|
||||
String get displayName {
|
||||
switch (this) {
|
||||
case PurchaseEntryStatus.draft:
|
||||
return '下書き';
|
||||
case PurchaseEntryStatus.confirmed:
|
||||
return '確定';
|
||||
case PurchaseEntryStatus.settled:
|
||||
return '支払済み';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class PurchaseLineItem {
|
||||
const PurchaseLineItem({
|
||||
required this.id,
|
||||
required this.purchaseEntryId,
|
||||
required this.description,
|
||||
required this.quantity,
|
||||
required this.unitPrice,
|
||||
required this.lineTotal,
|
||||
this.productId,
|
||||
this.taxRate = 0,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String purchaseEntryId;
|
||||
final String description;
|
||||
final int quantity;
|
||||
final int unitPrice;
|
||||
final int lineTotal;
|
||||
final String? productId;
|
||||
final double taxRate;
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'id': id,
|
||||
'purchase_entry_id': purchaseEntryId,
|
||||
'product_id': productId,
|
||||
'description': description,
|
||||
'quantity': quantity,
|
||||
'unit_price': unitPrice,
|
||||
'tax_rate': taxRate,
|
||||
'line_total': lineTotal,
|
||||
};
|
||||
|
||||
factory PurchaseLineItem.fromMap(Map<String, dynamic> map) => PurchaseLineItem(
|
||||
id: map['id'] as String,
|
||||
purchaseEntryId: map['purchase_entry_id'] as String,
|
||||
productId: map['product_id'] as String?,
|
||||
description: map['description'] as String,
|
||||
quantity: map['quantity'] as int? ?? 0,
|
||||
unitPrice: map['unit_price'] as int? ?? 0,
|
||||
taxRate: (map['tax_rate'] as num?)?.toDouble() ?? 0,
|
||||
lineTotal: map['line_total'] as int? ?? 0,
|
||||
);
|
||||
|
||||
PurchaseLineItem copyWith({
|
||||
String? id,
|
||||
String? purchaseEntryId,
|
||||
String? description,
|
||||
int? quantity,
|
||||
int? unitPrice,
|
||||
int? lineTotal,
|
||||
String? productId,
|
||||
double? taxRate,
|
||||
}) {
|
||||
return PurchaseLineItem(
|
||||
id: id ?? this.id,
|
||||
purchaseEntryId: purchaseEntryId ?? this.purchaseEntryId,
|
||||
description: description ?? this.description,
|
||||
quantity: quantity ?? this.quantity,
|
||||
unitPrice: unitPrice ?? this.unitPrice,
|
||||
lineTotal: lineTotal ?? this.lineTotal,
|
||||
productId: productId ?? this.productId,
|
||||
taxRate: taxRate ?? this.taxRate,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class PurchaseEntry {
|
||||
const PurchaseEntry({
|
||||
required this.id,
|
||||
required this.issueDate,
|
||||
required this.status,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
this.supplierId,
|
||||
this.supplierNameSnapshot,
|
||||
this.subject,
|
||||
this.amountTaxExcl = 0,
|
||||
this.taxAmount = 0,
|
||||
this.amountTaxIncl = 0,
|
||||
this.notes,
|
||||
this.items = const [],
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String? supplierId;
|
||||
final String? supplierNameSnapshot;
|
||||
final String? subject;
|
||||
final DateTime issueDate;
|
||||
final PurchaseEntryStatus status;
|
||||
final int amountTaxExcl;
|
||||
final int taxAmount;
|
||||
final int amountTaxIncl;
|
||||
final String? notes;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
final List<PurchaseLineItem> items;
|
||||
|
||||
PurchaseEntry copyWith({
|
||||
String? id,
|
||||
String? supplierId,
|
||||
String? supplierNameSnapshot,
|
||||
String? subject,
|
||||
DateTime? issueDate,
|
||||
PurchaseEntryStatus? status,
|
||||
int? amountTaxExcl,
|
||||
int? taxAmount,
|
||||
int? amountTaxIncl,
|
||||
String? notes,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
List<PurchaseLineItem>? items,
|
||||
}) {
|
||||
return PurchaseEntry(
|
||||
id: id ?? this.id,
|
||||
supplierId: supplierId ?? this.supplierId,
|
||||
supplierNameSnapshot: supplierNameSnapshot ?? this.supplierNameSnapshot,
|
||||
subject: subject ?? this.subject,
|
||||
issueDate: issueDate ?? this.issueDate,
|
||||
status: status ?? this.status,
|
||||
amountTaxExcl: amountTaxExcl ?? this.amountTaxExcl,
|
||||
taxAmount: taxAmount ?? this.taxAmount,
|
||||
amountTaxIncl: amountTaxIncl ?? this.amountTaxIncl,
|
||||
notes: notes ?? this.notes,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
items: items ?? this.items,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'id': id,
|
||||
'supplier_id': supplierId,
|
||||
'supplier_name_snapshot': supplierNameSnapshot,
|
||||
'subject': subject,
|
||||
'issue_date': issueDate.toIso8601String(),
|
||||
'status': status.name,
|
||||
'amount_tax_excl': amountTaxExcl,
|
||||
'tax_amount': taxAmount,
|
||||
'amount_tax_incl': amountTaxIncl,
|
||||
'notes': notes,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'updated_at': updatedAt.toIso8601String(),
|
||||
};
|
||||
|
||||
factory PurchaseEntry.fromMap(Map<String, dynamic> map, {List<PurchaseLineItem> items = const []}) => PurchaseEntry(
|
||||
id: map['id'] as String,
|
||||
supplierId: map['supplier_id'] as String?,
|
||||
supplierNameSnapshot: map['supplier_name_snapshot'] as String?,
|
||||
subject: map['subject'] as String?,
|
||||
issueDate: DateTime.parse(map['issue_date'] as String),
|
||||
status: PurchaseEntryStatus.values.firstWhere(
|
||||
(s) => s.name == map['status'],
|
||||
orElse: () => PurchaseEntryStatus.draft,
|
||||
),
|
||||
amountTaxExcl: map['amount_tax_excl'] as int? ?? 0,
|
||||
taxAmount: map['tax_amount'] as int? ?? 0,
|
||||
amountTaxIncl: map['amount_tax_incl'] as int? ?? 0,
|
||||
notes: map['notes'] as String?,
|
||||
createdAt: DateTime.parse(map['created_at'] as String),
|
||||
updatedAt: DateTime.parse(map['updated_at'] as String),
|
||||
items: items,
|
||||
);
|
||||
|
||||
PurchaseEntry recalcTotals() {
|
||||
final subtotal = items.fold<int>(0, (sum, item) => sum + item.lineTotal);
|
||||
final tax = items.fold<double>(0, (sum, item) => sum + item.lineTotal * item.taxRate).round();
|
||||
return copyWith(
|
||||
amountTaxExcl: subtotal,
|
||||
taxAmount: tax,
|
||||
amountTaxIncl: subtotal + tax,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class PurchaseReceiptAllocationInput {
|
||||
const PurchaseReceiptAllocationInput({required this.purchaseEntryId, required this.amount});
|
||||
|
||||
final String purchaseEntryId;
|
||||
final int amount;
|
||||
}
|
||||
|
||||
@immutable
|
||||
class PurchaseReceipt {
|
||||
const PurchaseReceipt({
|
||||
required this.id,
|
||||
required this.paymentDate,
|
||||
required this.amount,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
this.supplierId,
|
||||
this.method,
|
||||
this.notes,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String? supplierId;
|
||||
final DateTime paymentDate;
|
||||
final String? method;
|
||||
final int amount;
|
||||
final String? notes;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'id': id,
|
||||
'supplier_id': supplierId,
|
||||
'payment_date': paymentDate.toIso8601String(),
|
||||
'method': method,
|
||||
'amount': amount,
|
||||
'notes': notes,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'updated_at': updatedAt.toIso8601String(),
|
||||
};
|
||||
|
||||
factory PurchaseReceipt.fromMap(Map<String, dynamic> map) => PurchaseReceipt(
|
||||
id: map['id'] as String,
|
||||
supplierId: map['supplier_id'] as String?,
|
||||
paymentDate: DateTime.parse(map['payment_date'] as String),
|
||||
method: map['method'] as String?,
|
||||
amount: map['amount'] as int? ?? 0,
|
||||
notes: map['notes'] as String?,
|
||||
createdAt: DateTime.parse(map['created_at'] as String),
|
||||
updatedAt: DateTime.parse(map['updated_at'] as String),
|
||||
);
|
||||
|
||||
PurchaseReceipt copyWith({
|
||||
String? id,
|
||||
String? supplierId,
|
||||
DateTime? paymentDate,
|
||||
String? method,
|
||||
int? amount,
|
||||
String? notes,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
return PurchaseReceipt(
|
||||
id: id ?? this.id,
|
||||
supplierId: supplierId ?? this.supplierId,
|
||||
paymentDate: paymentDate ?? this.paymentDate,
|
||||
method: method ?? this.method,
|
||||
amount: amount ?? this.amount,
|
||||
notes: notes ?? this.notes,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class PurchaseReceiptLink {
|
||||
const PurchaseReceiptLink({required this.receiptId, required this.purchaseEntryId, required this.allocatedAmount});
|
||||
|
||||
final String receiptId;
|
||||
final String purchaseEntryId;
|
||||
final int allocatedAmount;
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'receipt_id': receiptId,
|
||||
'purchase_entry_id': purchaseEntryId,
|
||||
'allocated_amount': allocatedAmount,
|
||||
};
|
||||
|
||||
factory PurchaseReceiptLink.fromMap(Map<String, dynamic> map) => PurchaseReceiptLink(
|
||||
receiptId: map['receipt_id'] as String,
|
||||
purchaseEntryId: map['purchase_entry_id'] as String,
|
||||
allocatedAmount: map['allocated_amount'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
102
lib/models/receivable_models.dart
Normal file
102
lib/models/receivable_models.dart
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import 'package:meta/meta.dart';
|
||||
|
||||
enum PaymentMethod {
|
||||
bankTransfer,
|
||||
cash,
|
||||
creditCard,
|
||||
cheque,
|
||||
other,
|
||||
}
|
||||
|
||||
extension PaymentMethodX on PaymentMethod {
|
||||
String get displayName {
|
||||
switch (this) {
|
||||
case PaymentMethod.bankTransfer:
|
||||
return '銀行振込';
|
||||
case PaymentMethod.cash:
|
||||
return '現金';
|
||||
case PaymentMethod.creditCard:
|
||||
return 'クレジット';
|
||||
case PaymentMethod.cheque:
|
||||
return '小切手';
|
||||
case PaymentMethod.other:
|
||||
return 'その他';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class ReceivableInvoiceSummary {
|
||||
const ReceivableInvoiceSummary({
|
||||
required this.invoiceId,
|
||||
required this.invoiceNumber,
|
||||
required this.customerName,
|
||||
required this.invoiceDate,
|
||||
required this.totalAmount,
|
||||
required this.paidAmount,
|
||||
required this.dueDate,
|
||||
});
|
||||
|
||||
final String invoiceId;
|
||||
final String invoiceNumber;
|
||||
final String customerName;
|
||||
final DateTime invoiceDate;
|
||||
final DateTime dueDate;
|
||||
final int totalAmount;
|
||||
final int paidAmount;
|
||||
|
||||
int get outstandingAmount => totalAmount - paidAmount;
|
||||
bool get isSettled => outstandingAmount <= 0;
|
||||
bool get isOverdue => !isSettled && DateTime.now().isAfter(dueDate);
|
||||
double get collectionProgress => totalAmount == 0 ? 1.0 : paidAmount.clamp(0, totalAmount) / totalAmount;
|
||||
}
|
||||
|
||||
@immutable
|
||||
class ReceivablePayment {
|
||||
const ReceivablePayment({
|
||||
required this.id,
|
||||
required this.invoiceId,
|
||||
required this.amount,
|
||||
required this.paymentDate,
|
||||
required this.method,
|
||||
required this.createdAt,
|
||||
this.notes,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String invoiceId;
|
||||
final int amount;
|
||||
final DateTime paymentDate;
|
||||
final PaymentMethod method;
|
||||
final String? notes;
|
||||
final DateTime createdAt;
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'id': id,
|
||||
'invoice_id': invoiceId,
|
||||
'amount': amount,
|
||||
'payment_date': paymentDate.toIso8601String(),
|
||||
'method': method.name,
|
||||
'notes': notes,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
};
|
||||
|
||||
factory ReceivablePayment.fromMap(Map<String, dynamic> map) {
|
||||
PaymentMethod parseMethod(String? value) {
|
||||
return PaymentMethod.values.firstWhere(
|
||||
(method) => method.name == value,
|
||||
orElse: () => PaymentMethod.other,
|
||||
);
|
||||
}
|
||||
|
||||
return ReceivablePayment(
|
||||
id: map['id'] as String,
|
||||
invoiceId: map['invoice_id'] as String,
|
||||
amount: map['amount'] as int? ?? 0,
|
||||
paymentDate: DateTime.parse(map['payment_date'] as String),
|
||||
method: parseMethod(map['method'] as String?),
|
||||
notes: map['notes'] as String?,
|
||||
createdAt: DateTime.parse(map['created_at'] as String),
|
||||
);
|
||||
}
|
||||
}
|
||||
476
lib/models/sales_entry_models.dart
Normal file
476
lib/models/sales_entry_models.dart
Normal file
|
|
@ -0,0 +1,476 @@
|
|||
import 'package:meta/meta.dart';
|
||||
|
||||
import '../models/invoice_models.dart';
|
||||
|
||||
const _unset = Object();
|
||||
|
||||
enum SalesEntryStatus { draft, confirmed, settled }
|
||||
|
||||
enum SettlementMethod { cash, bankTransfer, card, accountsReceivable, other }
|
||||
|
||||
extension SettlementMethodX on SettlementMethod {
|
||||
String get displayName {
|
||||
switch (this) {
|
||||
case SettlementMethod.cash:
|
||||
return '現金';
|
||||
case SettlementMethod.bankTransfer:
|
||||
return '銀行振込';
|
||||
case SettlementMethod.card:
|
||||
return 'カード';
|
||||
case SettlementMethod.accountsReceivable:
|
||||
return '売掛';
|
||||
case SettlementMethod.other:
|
||||
return 'その他';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettlementMethod? settlementMethodFromName(String? value) {
|
||||
if (value == null) return null;
|
||||
for (final method in SettlementMethod.values) {
|
||||
if (method.name == value) {
|
||||
return method;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@immutable
|
||||
class SalesInvoiceImportData {
|
||||
const SalesInvoiceImportData({
|
||||
required this.invoiceId,
|
||||
required this.documentType,
|
||||
required this.issueDate,
|
||||
required this.taxRate,
|
||||
required this.totalAmount,
|
||||
required this.items,
|
||||
required this.isLocked,
|
||||
required this.chainStatus,
|
||||
required this.contentHash,
|
||||
this.customerId,
|
||||
this.customerFormalName,
|
||||
this.subject,
|
||||
});
|
||||
|
||||
final String invoiceId;
|
||||
final DocumentType documentType;
|
||||
final DateTime issueDate;
|
||||
final double taxRate;
|
||||
final int totalAmount;
|
||||
final List<InvoiceItem> items;
|
||||
final bool isLocked;
|
||||
final String chainStatus;
|
||||
final String contentHash;
|
||||
final String? customerId;
|
||||
final String? customerFormalName;
|
||||
final String? subject;
|
||||
}
|
||||
|
||||
extension SalesEntryStatusDisplay on SalesEntryStatus {
|
||||
String get displayName {
|
||||
switch (this) {
|
||||
case SalesEntryStatus.draft:
|
||||
return '下書き';
|
||||
case SalesEntryStatus.confirmed:
|
||||
return '確定';
|
||||
case SalesEntryStatus.settled:
|
||||
return '入金済み';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SalesReceiptAllocationInput {
|
||||
const SalesReceiptAllocationInput({required this.salesEntryId, required this.amount});
|
||||
|
||||
final String salesEntryId;
|
||||
final int amount;
|
||||
}
|
||||
|
||||
@immutable
|
||||
class SalesLineItem {
|
||||
const SalesLineItem({
|
||||
required this.id,
|
||||
required this.salesEntryId,
|
||||
required this.description,
|
||||
required this.quantity,
|
||||
required this.unitPrice,
|
||||
required this.lineTotal,
|
||||
this.productId,
|
||||
this.taxRate = 0,
|
||||
this.sourceInvoiceId,
|
||||
this.sourceInvoiceItemId,
|
||||
this.costAmount = 0,
|
||||
this.costIsProvisional = false,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String salesEntryId;
|
||||
final String description;
|
||||
final int quantity;
|
||||
final int unitPrice;
|
||||
final int lineTotal;
|
||||
final String? productId;
|
||||
final double taxRate;
|
||||
final String? sourceInvoiceId;
|
||||
final String? sourceInvoiceItemId;
|
||||
final int costAmount;
|
||||
final bool costIsProvisional;
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'id': id,
|
||||
'sales_entry_id': salesEntryId,
|
||||
'product_id': productId,
|
||||
'description': description,
|
||||
'quantity': quantity,
|
||||
'unit_price': unitPrice,
|
||||
'tax_rate': taxRate,
|
||||
'line_total': lineTotal,
|
||||
'cost_amount': costAmount,
|
||||
'cost_is_provisional': costIsProvisional ? 1 : 0,
|
||||
'source_invoice_id': sourceInvoiceId,
|
||||
'source_invoice_item_id': sourceInvoiceItemId,
|
||||
};
|
||||
|
||||
factory SalesLineItem.fromMap(Map<String, dynamic> map) => SalesLineItem(
|
||||
id: map['id'] as String,
|
||||
salesEntryId: map['sales_entry_id'] as String,
|
||||
productId: map['product_id'] as String?,
|
||||
description: map['description'] as String,
|
||||
quantity: map['quantity'] as int? ?? 0,
|
||||
unitPrice: map['unit_price'] as int? ?? 0,
|
||||
taxRate: (map['tax_rate'] as num?)?.toDouble() ?? 0,
|
||||
lineTotal: map['line_total'] as int? ?? 0,
|
||||
costAmount: map['cost_amount'] as int? ?? 0,
|
||||
costIsProvisional: (map['cost_is_provisional'] as int? ?? 0) == 1,
|
||||
sourceInvoiceId: map['source_invoice_id'] as String?,
|
||||
sourceInvoiceItemId: map['source_invoice_item_id'] as String?,
|
||||
);
|
||||
SalesLineItem copyWith({
|
||||
String? id,
|
||||
String? salesEntryId,
|
||||
String? description,
|
||||
int? quantity,
|
||||
int? unitPrice,
|
||||
int? lineTotal,
|
||||
String? productId,
|
||||
double? taxRate,
|
||||
String? sourceInvoiceId,
|
||||
String? sourceInvoiceItemId,
|
||||
int? costAmount,
|
||||
bool? costIsProvisional,
|
||||
}) {
|
||||
return SalesLineItem(
|
||||
id: id ?? this.id,
|
||||
salesEntryId: salesEntryId ?? this.salesEntryId,
|
||||
description: description ?? this.description,
|
||||
quantity: quantity ?? this.quantity,
|
||||
unitPrice: unitPrice ?? this.unitPrice,
|
||||
lineTotal: lineTotal ?? this.lineTotal,
|
||||
productId: productId ?? this.productId,
|
||||
taxRate: taxRate ?? this.taxRate,
|
||||
sourceInvoiceId: sourceInvoiceId ?? this.sourceInvoiceId,
|
||||
sourceInvoiceItemId: sourceInvoiceItemId ?? this.sourceInvoiceItemId,
|
||||
costAmount: costAmount ?? this.costAmount,
|
||||
costIsProvisional: costIsProvisional ?? this.costIsProvisional,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class SalesEntry {
|
||||
const SalesEntry({
|
||||
required this.id,
|
||||
required this.issueDate,
|
||||
required this.status,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
this.customerId,
|
||||
this.customerNameSnapshot,
|
||||
this.subject,
|
||||
this.amountTaxExcl = 0,
|
||||
this.taxAmount = 0,
|
||||
this.amountTaxIncl = 0,
|
||||
this.notes,
|
||||
this.settlementMethod,
|
||||
this.settlementCardCompany,
|
||||
this.settlementDueDate,
|
||||
this.items = const [],
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String? customerId;
|
||||
final String? customerNameSnapshot;
|
||||
final String? subject;
|
||||
final DateTime issueDate;
|
||||
final SalesEntryStatus status;
|
||||
final int amountTaxExcl;
|
||||
final int taxAmount;
|
||||
final int amountTaxIncl;
|
||||
final String? notes;
|
||||
final SettlementMethod? settlementMethod;
|
||||
final String? settlementCardCompany;
|
||||
final DateTime? settlementDueDate;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
final List<SalesLineItem> items;
|
||||
|
||||
SalesEntry copyWith({
|
||||
String? id,
|
||||
String? customerId,
|
||||
String? customerNameSnapshot,
|
||||
String? subject,
|
||||
DateTime? issueDate,
|
||||
SalesEntryStatus? status,
|
||||
int? amountTaxExcl,
|
||||
int? taxAmount,
|
||||
int? amountTaxIncl,
|
||||
String? notes,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
List<SalesLineItem>? items,
|
||||
Object? settlementMethod = _unset,
|
||||
Object? settlementCardCompany = _unset,
|
||||
Object? settlementDueDate = _unset,
|
||||
}) {
|
||||
return SalesEntry(
|
||||
id: id ?? this.id,
|
||||
customerId: customerId ?? this.customerId,
|
||||
customerNameSnapshot: customerNameSnapshot ?? this.customerNameSnapshot,
|
||||
subject: subject ?? this.subject,
|
||||
issueDate: issueDate ?? this.issueDate,
|
||||
status: status ?? this.status,
|
||||
amountTaxExcl: amountTaxExcl ?? this.amountTaxExcl,
|
||||
taxAmount: taxAmount ?? this.taxAmount,
|
||||
amountTaxIncl: amountTaxIncl ?? this.amountTaxIncl,
|
||||
notes: notes ?? this.notes,
|
||||
settlementMethod: settlementMethod == _unset ? this.settlementMethod : settlementMethod as SettlementMethod?,
|
||||
settlementCardCompany: settlementCardCompany == _unset ? this.settlementCardCompany : settlementCardCompany as String?,
|
||||
settlementDueDate: settlementDueDate == _unset ? this.settlementDueDate : settlementDueDate as DateTime?,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
items: items ?? this.items,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'id': id,
|
||||
'customer_id': customerId,
|
||||
'customer_name_snapshot': customerNameSnapshot,
|
||||
'subject': subject,
|
||||
'issue_date': issueDate.toIso8601String(),
|
||||
'status': status.name,
|
||||
'amount_tax_excl': amountTaxExcl,
|
||||
'tax_amount': taxAmount,
|
||||
'amount_tax_incl': amountTaxIncl,
|
||||
'notes': notes,
|
||||
'settlement_method': settlementMethod?.name,
|
||||
'settlement_card_company': settlementCardCompany,
|
||||
'settlement_due_date': settlementDueDate?.toIso8601String(),
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'updated_at': updatedAt.toIso8601String(),
|
||||
};
|
||||
|
||||
factory SalesEntry.fromMap(Map<String, dynamic> map, {List<SalesLineItem> items = const []}) => SalesEntry(
|
||||
id: map['id'] as String,
|
||||
customerId: map['customer_id'] as String?,
|
||||
customerNameSnapshot: map['customer_name_snapshot'] as String?,
|
||||
subject: map['subject'] as String?,
|
||||
issueDate: DateTime.parse(map['issue_date'] as String),
|
||||
status: SalesEntryStatus.values.firstWhere(
|
||||
(s) => s.name == map['status'],
|
||||
orElse: () => SalesEntryStatus.draft,
|
||||
),
|
||||
amountTaxExcl: map['amount_tax_excl'] as int? ?? 0,
|
||||
taxAmount: map['tax_amount'] as int? ?? 0,
|
||||
amountTaxIncl: map['amount_tax_incl'] as int? ?? 0,
|
||||
notes: map['notes'] as String?,
|
||||
settlementMethod: settlementMethodFromName(map['settlement_method'] as String?),
|
||||
settlementCardCompany: map['settlement_card_company'] as String?,
|
||||
settlementDueDate: map['settlement_due_date'] != null && (map['settlement_due_date'] as String).isNotEmpty
|
||||
? DateTime.parse(map['settlement_due_date'] as String)
|
||||
: null,
|
||||
createdAt: DateTime.parse(map['created_at'] as String),
|
||||
updatedAt: DateTime.parse(map['updated_at'] as String),
|
||||
items: items,
|
||||
);
|
||||
|
||||
SalesEntry recalcTotals() {
|
||||
final subtotal = items.fold<int>(0, (sum, item) => sum + item.lineTotal);
|
||||
final tax = items.fold<double>(0, (sum, item) => sum + item.lineTotal * item.taxRate).round();
|
||||
return copyWith(
|
||||
amountTaxExcl: subtotal,
|
||||
taxAmount: tax,
|
||||
amountTaxIncl: subtotal + tax,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class SalesReceipt {
|
||||
const SalesReceipt({
|
||||
required this.id,
|
||||
required this.paymentDate,
|
||||
required this.amount,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
this.customerId,
|
||||
this.method,
|
||||
this.notes,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String? customerId;
|
||||
final DateTime paymentDate;
|
||||
final String? method;
|
||||
final int amount;
|
||||
final String? notes;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'id': id,
|
||||
'customer_id': customerId,
|
||||
'payment_date': paymentDate.toIso8601String(),
|
||||
'method': method,
|
||||
'amount': amount,
|
||||
'notes': notes,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'updated_at': updatedAt.toIso8601String(),
|
||||
};
|
||||
|
||||
factory SalesReceipt.fromMap(Map<String, dynamic> map) => SalesReceipt(
|
||||
id: map['id'] as String,
|
||||
customerId: map['customer_id'] as String?,
|
||||
paymentDate: DateTime.parse(map['payment_date'] as String),
|
||||
method: map['method'] as String?,
|
||||
amount: map['amount'] as int? ?? 0,
|
||||
notes: map['notes'] as String?,
|
||||
createdAt: DateTime.parse(map['created_at'] as String),
|
||||
updatedAt: DateTime.parse(map['updated_at'] as String),
|
||||
);
|
||||
|
||||
SalesReceipt copyWith({
|
||||
String? id,
|
||||
String? customerId,
|
||||
DateTime? paymentDate,
|
||||
String? method,
|
||||
int? amount,
|
||||
String? notes,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
return SalesReceipt(
|
||||
id: id ?? this.id,
|
||||
customerId: customerId ?? this.customerId,
|
||||
paymentDate: paymentDate ?? this.paymentDate,
|
||||
method: method ?? this.method,
|
||||
amount: amount ?? this.amount,
|
||||
notes: notes ?? this.notes,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class SalesReceiptLink {
|
||||
const SalesReceiptLink({required this.receiptId, required this.salesEntryId, required this.allocatedAmount});
|
||||
|
||||
final String receiptId;
|
||||
final String salesEntryId;
|
||||
final int allocatedAmount;
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'receipt_id': receiptId,
|
||||
'sales_entry_id': salesEntryId,
|
||||
'allocated_amount': allocatedAmount,
|
||||
};
|
||||
|
||||
factory SalesReceiptLink.fromMap(Map<String, dynamic> map) => SalesReceiptLink(
|
||||
receiptId: map['receipt_id'] as String,
|
||||
salesEntryId: map['sales_entry_id'] as String,
|
||||
allocatedAmount: map['allocated_amount'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
@immutable
|
||||
class SalesEntrySource {
|
||||
const SalesEntrySource({
|
||||
required this.id,
|
||||
required this.salesEntryId,
|
||||
required this.invoiceId,
|
||||
required this.importedAt,
|
||||
this.invoiceHashSnapshot,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String salesEntryId;
|
||||
final String invoiceId;
|
||||
final DateTime importedAt;
|
||||
final String? invoiceHashSnapshot;
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'id': id,
|
||||
'sales_entry_id': salesEntryId,
|
||||
'invoice_id': invoiceId,
|
||||
'imported_at': importedAt.toIso8601String(),
|
||||
'invoice_hash_snapshot': invoiceHashSnapshot,
|
||||
};
|
||||
|
||||
factory SalesEntrySource.fromMap(Map<String, dynamic> map) => SalesEntrySource(
|
||||
id: map['id'] as String,
|
||||
salesEntryId: map['sales_entry_id'] as String,
|
||||
invoiceId: map['invoice_id'] as String,
|
||||
importedAt: DateTime.parse(map['imported_at'] as String),
|
||||
invoiceHashSnapshot: map['invoice_hash_snapshot'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@immutable
|
||||
class SalesImportCandidate {
|
||||
const SalesImportCandidate({
|
||||
required this.invoiceId,
|
||||
required this.invoiceNumber,
|
||||
required this.documentType,
|
||||
required this.invoiceDate,
|
||||
required this.customerName,
|
||||
required this.totalAmount,
|
||||
required this.isLocked,
|
||||
required this.chainStatus,
|
||||
required this.contentHash,
|
||||
this.subject,
|
||||
});
|
||||
|
||||
final String invoiceId;
|
||||
final String invoiceNumber;
|
||||
final DocumentType documentType;
|
||||
final DateTime invoiceDate;
|
||||
final String customerName;
|
||||
final int totalAmount;
|
||||
final bool isLocked;
|
||||
final String chainStatus;
|
||||
final String contentHash;
|
||||
final String? subject;
|
||||
|
||||
String get documentTypeName => documentType.displayName;
|
||||
|
||||
factory SalesImportCandidate.fromMap(Map<String, Object?> row) {
|
||||
final docTypeName = row['document_type'] as String? ?? DocumentType.invoice.name;
|
||||
final documentType = DocumentType.values.firstWhere(
|
||||
(type) => type.name == docTypeName,
|
||||
orElse: () => DocumentType.invoice,
|
||||
);
|
||||
return SalesImportCandidate(
|
||||
invoiceId: row['id'] as String,
|
||||
invoiceNumber: row['invoice_number'] as String,
|
||||
documentType: documentType,
|
||||
invoiceDate: DateTime.parse(row['date'] as String),
|
||||
customerName: row['customer_name'] as String? ?? '-',
|
||||
totalAmount: (row['total_amount'] as num?)?.toInt() ?? 0,
|
||||
isLocked: (row['is_locked'] as int?) == 1,
|
||||
chainStatus: row['chain_status'] as String? ?? 'pending',
|
||||
contentHash: row['content_hash'] as String? ?? '',
|
||||
subject: row['subject'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
32
lib/models/sales_summary.dart
Normal file
32
lib/models/sales_summary.dart
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import '../models/invoice_models.dart';
|
||||
|
||||
class SalesCustomerStat {
|
||||
SalesCustomerStat({required this.customerName, required this.totalAmount});
|
||||
|
||||
final String customerName;
|
||||
final int totalAmount;
|
||||
}
|
||||
|
||||
class SalesSummary {
|
||||
SalesSummary({
|
||||
required this.year,
|
||||
required this.monthlyTotals,
|
||||
required this.yearlyTotal,
|
||||
required this.customerStats,
|
||||
this.documentType,
|
||||
});
|
||||
|
||||
final int year;
|
||||
final Map<int, int> monthlyTotals;
|
||||
final int yearlyTotal;
|
||||
final List<SalesCustomerStat> customerStats;
|
||||
final DocumentType? documentType;
|
||||
|
||||
int get bestMonth =>
|
||||
monthlyTotals.entries.isEmpty ? 0 : monthlyTotals.entries.reduce((a, b) => a.value >= b.value ? a : b).key;
|
||||
|
||||
int get bestMonthTotal =>
|
||||
monthlyTotals.entries.isEmpty ? 0 : monthlyTotals.entries.reduce((a, b) => a.value >= b.value ? a : b).value;
|
||||
|
||||
double get averageMonthly => yearlyTotal / (monthlyTotals.isEmpty ? 1 : 12);
|
||||
}
|
||||
199
lib/models/shipment_models.dart
Normal file
199
lib/models/shipment_models.dart
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
/// 出荷ステータス定義。
|
||||
enum ShipmentStatus {
|
||||
pending,
|
||||
picking,
|
||||
ready,
|
||||
shipped,
|
||||
delivered,
|
||||
cancelled,
|
||||
}
|
||||
|
||||
extension ShipmentStatusX on ShipmentStatus {
|
||||
String get displayName {
|
||||
switch (this) {
|
||||
case ShipmentStatus.pending:
|
||||
return '未手配';
|
||||
case ShipmentStatus.picking:
|
||||
return 'ピッキング中';
|
||||
case ShipmentStatus.ready:
|
||||
return '出荷待ち';
|
||||
case ShipmentStatus.shipped:
|
||||
return '出荷済み';
|
||||
case ShipmentStatus.delivered:
|
||||
return '納品済み';
|
||||
case ShipmentStatus.cancelled:
|
||||
return 'キャンセル';
|
||||
}
|
||||
}
|
||||
|
||||
static ShipmentStatus fromDbValue(String? value) {
|
||||
return ShipmentStatus.values.firstWhere(
|
||||
(status) => status.name == value,
|
||||
orElse: () => ShipmentStatus.pending,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ShipmentItem {
|
||||
ShipmentItem({
|
||||
required this.id,
|
||||
required this.shipmentId,
|
||||
required this.description,
|
||||
required this.quantity,
|
||||
this.orderItemId,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String shipmentId;
|
||||
final String? orderItemId;
|
||||
final String description;
|
||||
final int quantity;
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'id': id,
|
||||
'shipment_id': shipmentId,
|
||||
'order_item_id': orderItemId,
|
||||
'description': description,
|
||||
'quantity': quantity,
|
||||
};
|
||||
|
||||
factory ShipmentItem.fromMap(Map<String, dynamic> map) => ShipmentItem(
|
||||
id: map['id'] as String,
|
||||
shipmentId: map['shipment_id'] as String,
|
||||
orderItemId: map['order_item_id'] as String?,
|
||||
description: map['description'] as String,
|
||||
quantity: map['quantity'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
class Shipment {
|
||||
Shipment({
|
||||
required this.id,
|
||||
required this.status,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
this.orderId,
|
||||
this.orderNumberSnapshot,
|
||||
this.customerNameSnapshot,
|
||||
this.scheduledShipDate,
|
||||
this.actualShipDate,
|
||||
this.carrierName,
|
||||
this.trackingNumber,
|
||||
this.trackingUrl,
|
||||
this.labelPdfPath,
|
||||
this.notes,
|
||||
this.pickingCompletedAt,
|
||||
this.items = const [],
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String? orderId;
|
||||
final String? orderNumberSnapshot;
|
||||
final String? customerNameSnapshot;
|
||||
final DateTime? scheduledShipDate;
|
||||
final DateTime? actualShipDate;
|
||||
final ShipmentStatus status;
|
||||
final String? carrierName;
|
||||
final String? trackingNumber;
|
||||
final String? trackingUrl;
|
||||
final String? labelPdfPath;
|
||||
final String? notes;
|
||||
final DateTime? pickingCompletedAt;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
final List<ShipmentItem> items;
|
||||
|
||||
Shipment copyWith({
|
||||
String? id,
|
||||
String? orderId,
|
||||
String? orderNumberSnapshot,
|
||||
String? customerNameSnapshot,
|
||||
DateTime? scheduledShipDate,
|
||||
DateTime? actualShipDate,
|
||||
ShipmentStatus? status,
|
||||
String? carrierName,
|
||||
String? trackingNumber,
|
||||
String? trackingUrl,
|
||||
String? labelPdfPath,
|
||||
String? notes,
|
||||
DateTime? pickingCompletedAt,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
List<ShipmentItem>? items,
|
||||
}) {
|
||||
return Shipment(
|
||||
id: id ?? this.id,
|
||||
orderId: orderId ?? this.orderId,
|
||||
orderNumberSnapshot: orderNumberSnapshot ?? this.orderNumberSnapshot,
|
||||
customerNameSnapshot: customerNameSnapshot ?? this.customerNameSnapshot,
|
||||
scheduledShipDate: scheduledShipDate ?? this.scheduledShipDate,
|
||||
actualShipDate: actualShipDate ?? this.actualShipDate,
|
||||
status: status ?? this.status,
|
||||
carrierName: carrierName ?? this.carrierName,
|
||||
trackingNumber: trackingNumber ?? this.trackingNumber,
|
||||
trackingUrl: trackingUrl ?? this.trackingUrl,
|
||||
labelPdfPath: labelPdfPath ?? this.labelPdfPath,
|
||||
notes: notes ?? this.notes,
|
||||
pickingCompletedAt: pickingCompletedAt ?? this.pickingCompletedAt,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
items: items ?? this.items,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'id': id,
|
||||
'order_id': orderId,
|
||||
'order_number_snapshot': orderNumberSnapshot,
|
||||
'customer_name_snapshot': customerNameSnapshot,
|
||||
'scheduled_ship_date': scheduledShipDate?.toIso8601String(),
|
||||
'actual_ship_date': actualShipDate?.toIso8601String(),
|
||||
'status': status.name,
|
||||
'carrier_name': carrierName,
|
||||
'tracking_number': trackingNumber,
|
||||
'tracking_url': trackingUrl,
|
||||
'label_pdf_path': labelPdfPath,
|
||||
'notes': notes,
|
||||
'picking_completed_at': pickingCompletedAt?.toIso8601String(),
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'updated_at': updatedAt.toIso8601String(),
|
||||
};
|
||||
|
||||
factory Shipment.fromMap(Map<String, dynamic> map, {List<ShipmentItem> items = const []}) {
|
||||
DateTime? parseNullable(String? value) => value == null ? null : DateTime.parse(value);
|
||||
return Shipment(
|
||||
id: map['id'] as String,
|
||||
orderId: map['order_id'] as String?,
|
||||
orderNumberSnapshot: map['order_number_snapshot'] as String?,
|
||||
customerNameSnapshot: map['customer_name_snapshot'] as String?,
|
||||
scheduledShipDate: parseNullable(map['scheduled_ship_date'] as String?),
|
||||
actualShipDate: parseNullable(map['actual_ship_date'] as String?),
|
||||
status: ShipmentStatusX.fromDbValue(map['status'] as String?),
|
||||
carrierName: map['carrier_name'] as String?,
|
||||
trackingNumber: map['tracking_number'] as String?,
|
||||
trackingUrl: map['tracking_url'] as String?,
|
||||
labelPdfPath: map['label_pdf_path'] as String?,
|
||||
notes: map['notes'] as String?,
|
||||
pickingCompletedAt: parseNullable(map['picking_completed_at'] as String?),
|
||||
createdAt: DateTime.parse(map['created_at'] as String),
|
||||
updatedAt: DateTime.parse(map['updated_at'] as String),
|
||||
items: items,
|
||||
);
|
||||
}
|
||||
|
||||
ShipmentStatus nextDefaultStatus() {
|
||||
switch (status) {
|
||||
case ShipmentStatus.pending:
|
||||
return ShipmentStatus.picking;
|
||||
case ShipmentStatus.picking:
|
||||
return ShipmentStatus.ready;
|
||||
case ShipmentStatus.ready:
|
||||
return ShipmentStatus.shipped;
|
||||
case ShipmentStatus.shipped:
|
||||
return ShipmentStatus.delivered;
|
||||
case ShipmentStatus.delivered:
|
||||
case ShipmentStatus.cancelled:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
}
|
||||
78
lib/models/staff_model.dart
Normal file
78
lib/models/staff_model.dart
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
|
||||
@immutable
|
||||
class StaffMember {
|
||||
const StaffMember({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.email,
|
||||
this.tel,
|
||||
this.role,
|
||||
this.departmentId,
|
||||
this.permissionLevel,
|
||||
this.isActive = true,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final String? email;
|
||||
final String? tel;
|
||||
final String? role;
|
||||
final String? departmentId;
|
||||
final String? permissionLevel;
|
||||
final bool isActive;
|
||||
final DateTime updatedAt;
|
||||
|
||||
StaffMember copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
String? email,
|
||||
String? tel,
|
||||
String? role,
|
||||
String? departmentId,
|
||||
String? permissionLevel,
|
||||
bool? isActive,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
return StaffMember(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
email: email ?? this.email,
|
||||
tel: tel ?? this.tel,
|
||||
role: role ?? this.role,
|
||||
departmentId: departmentId ?? this.departmentId,
|
||||
permissionLevel: permissionLevel ?? this.permissionLevel,
|
||||
isActive: isActive ?? this.isActive,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
factory StaffMember.fromMap(Map<String, Object?> map) {
|
||||
return StaffMember(
|
||||
id: map['id'] as String,
|
||||
name: map['name'] as String? ?? '-',
|
||||
email: map['email'] as String?,
|
||||
tel: map['tel'] as String?,
|
||||
role: map['role'] as String?,
|
||||
departmentId: map['department_id'] as String?,
|
||||
permissionLevel: map['permission_level'] as String?,
|
||||
isActive: (map['is_active'] as int? ?? 1) == 1,
|
||||
updatedAt: DateTime.parse(map['updated_at'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> toMap() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'email': email,
|
||||
'tel': tel,
|
||||
'role': role,
|
||||
'department_id': departmentId,
|
||||
'permission_level': permissionLevel,
|
||||
'is_active': isActive ? 1 : 0,
|
||||
'updated_at': updatedAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
||||
90
lib/models/supplier_model.dart
Normal file
90
lib/models/supplier_model.dart
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
|
||||
@immutable
|
||||
class Supplier {
|
||||
const Supplier({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.contactPerson,
|
||||
this.email,
|
||||
this.tel,
|
||||
this.address,
|
||||
this.closingDay,
|
||||
this.paymentSiteDays = 30,
|
||||
this.notes,
|
||||
this.isHidden = false,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final String? contactPerson;
|
||||
final String? email;
|
||||
final String? tel;
|
||||
final String? address;
|
||||
final int? closingDay;
|
||||
final int paymentSiteDays;
|
||||
final String? notes;
|
||||
final bool isHidden;
|
||||
final DateTime updatedAt;
|
||||
|
||||
Supplier copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
String? contactPerson,
|
||||
String? email,
|
||||
String? tel,
|
||||
String? address,
|
||||
int? closingDay,
|
||||
int? paymentSiteDays,
|
||||
String? notes,
|
||||
bool? isHidden,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
return Supplier(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
contactPerson: contactPerson ?? this.contactPerson,
|
||||
email: email ?? this.email,
|
||||
tel: tel ?? this.tel,
|
||||
address: address ?? this.address,
|
||||
closingDay: closingDay ?? this.closingDay,
|
||||
paymentSiteDays: paymentSiteDays ?? this.paymentSiteDays,
|
||||
notes: notes ?? this.notes,
|
||||
isHidden: isHidden ?? this.isHidden,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
factory Supplier.fromMap(Map<String, Object?> map) {
|
||||
return Supplier(
|
||||
id: map['id'] as String,
|
||||
name: map['name'] as String? ?? '-',
|
||||
contactPerson: map['contact_person'] as String?,
|
||||
email: map['email'] as String?,
|
||||
tel: map['tel'] as String?,
|
||||
address: map['address'] as String?,
|
||||
closingDay: map['closing_day'] as int?,
|
||||
paymentSiteDays: map['payment_site_days'] as int? ?? 30,
|
||||
notes: map['notes'] as String?,
|
||||
isHidden: (map['is_hidden'] as int? ?? 0) == 1,
|
||||
updatedAt: DateTime.parse(map['updated_at'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> toMap() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'contact_person': contactPerson,
|
||||
'email': email,
|
||||
'tel': tel,
|
||||
'address': address,
|
||||
'closing_day': closingDay,
|
||||
'payment_site_days': paymentSiteDays,
|
||||
'notes': notes,
|
||||
'is_hidden': isHidden ? 1 : 0,
|
||||
'updated_at': updatedAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
||||
48
lib/models/tax_setting_model.dart
Normal file
48
lib/models/tax_setting_model.dart
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
|
||||
@immutable
|
||||
class TaxSetting {
|
||||
const TaxSetting({
|
||||
required this.id,
|
||||
required this.rate,
|
||||
required this.roundingMode,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final double rate;
|
||||
final String roundingMode;
|
||||
final DateTime updatedAt;
|
||||
|
||||
TaxSetting copyWith({
|
||||
String? id,
|
||||
double? rate,
|
||||
String? roundingMode,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
return TaxSetting(
|
||||
id: id ?? this.id,
|
||||
rate: rate ?? this.rate,
|
||||
roundingMode: roundingMode ?? this.roundingMode,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
factory TaxSetting.fromMap(Map<String, Object?> map) {
|
||||
return TaxSetting(
|
||||
id: map['id'] as String,
|
||||
rate: (map['rate'] as num).toDouble(),
|
||||
roundingMode: map['rounding_mode'] as String? ?? 'round',
|
||||
updatedAt: DateTime.parse(map['updated_at'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> toMap() {
|
||||
return {
|
||||
'id': id,
|
||||
'rate': rate,
|
||||
'rounding_mode': roundingMode,
|
||||
'updated_at': updatedAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
||||
69
lib/modules/billing_docs_module.dart
Normal file
69
lib/modules/billing_docs_module.dart
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../config/app_config.dart';
|
||||
import '../models/invoice_models.dart';
|
||||
import '../modules/feature_module.dart';
|
||||
import '../screens/invoice_detail_page.dart';
|
||||
import '../screens/invoice_history_screen.dart';
|
||||
import '../screens/invoice_input_screen.dart';
|
||||
import '../services/customer_repository.dart';
|
||||
import '../services/location_service.dart';
|
||||
|
||||
class BillingDocsModule extends FeatureModule {
|
||||
BillingDocsModule();
|
||||
|
||||
final LocationService _locationService = LocationService();
|
||||
final CustomerRepository _customerRepository = CustomerRepository();
|
||||
|
||||
@override
|
||||
String get key => 'billing_docs';
|
||||
|
||||
@override
|
||||
bool get isEnabled => AppConfig.enableBillingDocs;
|
||||
|
||||
@override
|
||||
List<ModuleDashboardCard> get dashboardCards => [
|
||||
ModuleDashboardCard(
|
||||
id: 'billing_docs_history',
|
||||
route: 'invoice_history',
|
||||
title: '伝票一覧',
|
||||
description: 'A2: 履歴リストとロック管理',
|
||||
iconName: 'history',
|
||||
requiresUnlock: true,
|
||||
onTap: (context) async {
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const InvoiceHistoryScreen(initialUnlocked: true)),
|
||||
);
|
||||
},
|
||||
),
|
||||
ModuleDashboardCard(
|
||||
id: 'billing_docs_input',
|
||||
route: 'invoice_input',
|
||||
title: '伝票新規作成',
|
||||
description: 'A1: 新しい伝票を登録',
|
||||
iconName: 'edit_note',
|
||||
onTap: (context) async {
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => InvoiceInputForm(
|
||||
initialDocumentType: DocumentType.invoice,
|
||||
onInvoiceGenerated: (invoice, path) async {
|
||||
final pos = await _locationService.getCurrentLocation();
|
||||
if (pos != null) {
|
||||
await _customerRepository.addGpsHistory(invoice.customer.id, pos.latitude, pos.longitude);
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => InvoiceDetailPage(invoice: invoice)),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
37
lib/modules/feature_module.dart
Normal file
37
lib/modules/feature_module.dart
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
typedef ModuleCardAction = Future<void> Function(BuildContext context);
|
||||
|
||||
class ModuleDashboardCard {
|
||||
const ModuleDashboardCard({
|
||||
required this.id,
|
||||
required this.route,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.iconName,
|
||||
required this.onTap,
|
||||
this.requiresUnlock = false,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String route;
|
||||
final String title;
|
||||
final String description;
|
||||
final String iconName;
|
||||
final ModuleCardAction onTap;
|
||||
final bool requiresUnlock;
|
||||
}
|
||||
|
||||
abstract class FeatureModule {
|
||||
String get key;
|
||||
bool get isEnabled;
|
||||
List<ModuleDashboardCard> get dashboardCards;
|
||||
|
||||
ModuleDashboardCard? cardByRoute(String route) {
|
||||
try {
|
||||
return dashboardCards.firstWhere((card) => card.route == route);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
35
lib/modules/module_registry.dart
Normal file
35
lib/modules/module_registry.dart
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import 'billing_docs_module.dart';
|
||||
import 'feature_module.dart';
|
||||
import 'purchase_management_module.dart';
|
||||
import 'sales_management_module.dart';
|
||||
import 'sales_operations_module.dart';
|
||||
|
||||
class ModuleRegistry {
|
||||
ModuleRegistry._();
|
||||
|
||||
static final ModuleRegistry instance = ModuleRegistry._();
|
||||
|
||||
final List<FeatureModule> _modules = [
|
||||
BillingDocsModule(),
|
||||
SalesManagementModule(),
|
||||
SalesOperationsModule(),
|
||||
PurchaseManagementModule(),
|
||||
];
|
||||
|
||||
Iterable<FeatureModule> get modules => _modules;
|
||||
|
||||
List<ModuleDashboardCard> get enabledCards => _modules.where((m) => m.isEnabled).expand((m) => m.dashboardCards).toList();
|
||||
|
||||
bool supportsRoute(String route) => enabledCards.any((card) => card.route == route);
|
||||
|
||||
ModuleDashboardCard? cardForRoute(String route) {
|
||||
for (final module in _modules) {
|
||||
if (!module.isEnabled) continue;
|
||||
final card = module.cardByRoute(route);
|
||||
if (card != null) {
|
||||
return card;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
40
lib/modules/purchase_management_module.dart
Normal file
40
lib/modules/purchase_management_module.dart
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../config/app_config.dart';
|
||||
import '../modules/feature_module.dart';
|
||||
import '../screens/purchase_entries_screen.dart';
|
||||
import '../screens/purchase_receipts_screen.dart';
|
||||
|
||||
class PurchaseManagementModule extends FeatureModule {
|
||||
PurchaseManagementModule();
|
||||
|
||||
@override
|
||||
String get key => 'purchase_management';
|
||||
|
||||
@override
|
||||
bool get isEnabled => AppConfig.enablePurchaseManagement;
|
||||
|
||||
@override
|
||||
List<ModuleDashboardCard> get dashboardCards => [
|
||||
ModuleDashboardCard(
|
||||
id: 'purchase_entries',
|
||||
route: 'purchase_entries',
|
||||
title: '仕入伝票',
|
||||
description: 'P1/P2:仕入伝票の一覧と編集',
|
||||
iconName: 'shopping_cart',
|
||||
onTap: (context) async {
|
||||
await Navigator.push(context, MaterialPageRoute(builder: (_) => const PurchaseEntriesScreen()));
|
||||
},
|
||||
),
|
||||
ModuleDashboardCard(
|
||||
id: 'purchase_receipts',
|
||||
route: 'purchase_receipts',
|
||||
title: '支払管理',
|
||||
description: 'P3/P4:支払登録と割当',
|
||||
iconName: 'payments',
|
||||
onTap: (context) async {
|
||||
await Navigator.push(context, MaterialPageRoute(builder: (_) => const PurchaseReceiptsScreen()));
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
46
lib/modules/sales_management_module.dart
Normal file
46
lib/modules/sales_management_module.dart
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../config/app_config.dart';
|
||||
import '../modules/feature_module.dart';
|
||||
import '../screens/sales_dashboard_screen.dart';
|
||||
import '../screens/sales_entries_screen.dart';
|
||||
|
||||
class SalesManagementModule extends FeatureModule {
|
||||
SalesManagementModule();
|
||||
|
||||
@override
|
||||
String get key => 'sales_management';
|
||||
|
||||
@override
|
||||
bool get isEnabled => AppConfig.enableSalesManagement;
|
||||
|
||||
@override
|
||||
List<ModuleDashboardCard> get dashboardCards => [
|
||||
ModuleDashboardCard(
|
||||
id: 'sales_management_report',
|
||||
route: 'sales_management',
|
||||
title: '売上管理',
|
||||
description: '売上ダッシュボード(ランチャー)',
|
||||
iconName: 'analytics',
|
||||
onTap: (context) async {
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const SalesDashboardScreen()),
|
||||
);
|
||||
},
|
||||
),
|
||||
ModuleDashboardCard(
|
||||
id: 'sales_entries',
|
||||
route: 'sales_entries',
|
||||
title: '売上伝票入力',
|
||||
description: 'U1:売上伝票の入力・編集',
|
||||
iconName: 'receipt_long',
|
||||
onTap: (context) async {
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => SalesEntriesScreen()),
|
||||
);
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
59
lib/modules/sales_operations_module.dart
Normal file
59
lib/modules/sales_operations_module.dart
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../config/app_config.dart';
|
||||
import '../modules/feature_module.dart';
|
||||
import '../screens/sales_orders_screen.dart';
|
||||
|
||||
class SalesOperationsModule extends FeatureModule {
|
||||
SalesOperationsModule();
|
||||
|
||||
@override
|
||||
String get key => 'sales_operations';
|
||||
|
||||
@override
|
||||
bool get isEnabled => AppConfig.enableSalesOperations;
|
||||
|
||||
@override
|
||||
List<ModuleDashboardCard> get dashboardCards => [
|
||||
ModuleDashboardCard(
|
||||
id: 'sales_orders',
|
||||
route: 'sales_orders',
|
||||
title: '受注管理',
|
||||
description: '受注入力と進捗管理',
|
||||
iconName: 'assignment',
|
||||
onTap: (context) async {
|
||||
await Navigator.push(context, MaterialPageRoute(builder: (_) => const SalesOrdersScreen()));
|
||||
},
|
||||
),
|
||||
ModuleDashboardCard(
|
||||
id: 'shipments',
|
||||
route: 'shipments',
|
||||
title: '出荷管理',
|
||||
description: 'ピッキング・配送番号登録',
|
||||
iconName: 'local_shipping',
|
||||
onTap: (context) async {
|
||||
await Navigator.push(context, MaterialPageRoute(builder: (_) => const SalesShipmentsScreen()));
|
||||
},
|
||||
),
|
||||
ModuleDashboardCard(
|
||||
id: 'inventory',
|
||||
route: 'inventory',
|
||||
title: '在庫管理',
|
||||
description: '残高/入出庫履歴を確認',
|
||||
iconName: 'inventory_2',
|
||||
onTap: (context) async {
|
||||
await Navigator.push(context, MaterialPageRoute(builder: (_) => const SalesInventoryScreen()));
|
||||
},
|
||||
),
|
||||
ModuleDashboardCard(
|
||||
id: 'receivables',
|
||||
route: 'receivables',
|
||||
title: '回収・入金',
|
||||
description: '売掛残高と入金状況',
|
||||
iconName: 'account_balance',
|
||||
onTap: (context) async {
|
||||
await Navigator.push(context, MaterialPageRoute(builder: (_) => const SalesReceivablesScreen()));
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:intl/intl.dart';
|
||||
import '../models/activity_log_model.dart';
|
||||
import '../services/activity_log_repository.dart';
|
||||
import '../widgets/screen_id_title.dart';
|
||||
|
||||
class ActivityLogScreen extends StatefulWidget {
|
||||
const ActivityLogScreen({super.key});
|
||||
|
|
@ -36,7 +37,8 @@ class _ActivityLogScreenState extends State<ActivityLogScreen> {
|
|||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("アクティビティ履歴 (Gitログ風)"),
|
||||
leading: const BackButton(),
|
||||
title: const ScreenAppBarTitle(screenId: 'A1', title: 'アクティビティ履歴'),
|
||||
backgroundColor: Colors.blueGrey.shade800,
|
||||
actions: [
|
||||
IconButton(icon: const Icon(Icons.refresh), onPressed: _loadLogs),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
|
||||
import '../widgets/screen_id_title.dart';
|
||||
|
||||
class BarcodeScannerScreen extends StatefulWidget {
|
||||
const BarcodeScannerScreen({super.key});
|
||||
|
||||
|
|
@ -13,7 +15,8 @@ class _BarcodeScannerScreenState extends State<BarcodeScannerScreen> {
|
|||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("バーコードスキャン"),
|
||||
leading: const BackButton(),
|
||||
title: const ScreenAppBarTitle(screenId: 'B1', title: 'バーコードスキャン'),
|
||||
backgroundColor: Colors.black,
|
||||
),
|
||||
body: MobileScanner(
|
||||
|
|
|
|||
|
|
@ -218,6 +218,7 @@ class _BusinessProfileScreenState extends State<BusinessProfileScreen> {
|
|||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: const BackButton(),
|
||||
title: const Text('F2:自社情報'),
|
||||
backgroundColor: Colors.indigo,
|
||||
actions: [
|
||||
|
|
|
|||
|
|
@ -84,7 +84,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
final theme = Theme.of(context);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('母艦チャット'),
|
||||
leading: const BackButton(),
|
||||
title: const Text('C1:母艦チャット'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: '再同期',
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ class _CompanyInfoScreenState extends State<CompanyInfoScreen> {
|
|||
return Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
appBar: AppBar(
|
||||
leading: const BackButton(),
|
||||
title: const Text("F1:自社情報"),
|
||||
backgroundColor: Colors.indigo,
|
||||
actions: [
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import '../models/customer_model.dart';
|
|||
import '../services/customer_repository.dart';
|
||||
import '../widgets/keyboard_inset_wrapper.dart';
|
||||
import '../widgets/contact_picker_sheet.dart';
|
||||
import '../widgets/screen_id_title.dart';
|
||||
|
||||
/// 顧客マスターからの選択、登録、編集、削除を行うモーダル
|
||||
class CustomerPickerModal extends StatefulWidget {
|
||||
|
|
@ -222,96 +223,105 @@ class _CustomerPickerModalState extends State<CustomerPickerModal> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
child: KeyboardInsetWrapper(
|
||||
basePadding: const EdgeInsets.fromLTRB(0, 0, 0, 16),
|
||||
extraBottom: 32,
|
||||
child: CustomScrollView(
|
||||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text("顧客マスター管理", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)),
|
||||
],
|
||||
final body = KeyboardInsetWrapper(
|
||||
safeAreaTop: false,
|
||||
basePadding: const EdgeInsets.fromLTRB(0, 0, 0, 16),
|
||||
extraBottom: 32,
|
||||
child: CustomScrollView(
|
||||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
decoration: InputDecoration(
|
||||
hintText: "登録済み顧客を検索...",
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
decoration: InputDecoration(
|
||||
hintText: "登録済み顧客を検索...",
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
onChanged: _onSearch,
|
||||
onChanged: _onSearch,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _isImportingFromContacts ? null : _importFromPhoneContacts,
|
||||
icon: _isImportingFromContacts
|
||||
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.contact_phone),
|
||||
label: const Text("電話帳から新規取り込み"),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.blueGrey.shade700, foregroundColor: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _isImportingFromContacts ? null : _importFromPhoneContacts,
|
||||
icon: _isImportingFromContacts
|
||||
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.contact_phone),
|
||||
label: const Text("電話帳から新規取り込み"),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.blueGrey.shade700, foregroundColor: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(child: Divider(height: 1)),
|
||||
if (_isLoading)
|
||||
const SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (_filteredCustomers.isEmpty)
|
||||
const SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(child: Text("該当する顧客がいません")),
|
||||
)
|
||||
else
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.only(bottom: 120),
|
||||
sliver: SliverList.builder(
|
||||
itemCount: _filteredCustomers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final customer = _filteredCustomers[index];
|
||||
return ListTile(
|
||||
leading: const CircleAvatar(child: Icon(Icons.business)),
|
||||
title: Text(customer.formalName),
|
||||
subtitle: Text(customer.department?.isNotEmpty == true ? customer.department! : "部署未設定"),
|
||||
onTap: () => widget.onCustomerSelected(customer),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit, color: Colors.blueGrey, size: 20),
|
||||
onPressed: () => _showCustomerEditDialog(
|
||||
displayName: customer.displayName,
|
||||
initialFormalName: customer.formalName,
|
||||
existingCustomer: customer,
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(child: Divider(height: 1)),
|
||||
if (_isLoading)
|
||||
const SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (_filteredCustomers.isEmpty)
|
||||
const SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(child: Text("該当する顧客がいません")),
|
||||
)
|
||||
else
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.only(bottom: 120),
|
||||
sliver: SliverList.builder(
|
||||
itemCount: _filteredCustomers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final customer = _filteredCustomers[index];
|
||||
return ListTile(
|
||||
leading: const CircleAvatar(child: Icon(Icons.business)),
|
||||
title: Text(customer.formalName),
|
||||
subtitle: Text(customer.department?.isNotEmpty == true ? customer.department! : "部署未設定"),
|
||||
onTap: () => widget.onCustomerSelected(customer),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit, color: Colors.blueGrey, size: 20),
|
||||
onPressed: () => _showCustomerEditDialog(
|
||||
displayName: customer.displayName,
|
||||
initialFormalName: customer.formalName,
|
||||
existingCustomer: customer,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline, color: Colors.redAccent, size: 20),
|
||||
onPressed: () => _confirmDelete(customer),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline, color: Colors.redAccent, size: 20),
|
||||
onPressed: () => _confirmDelete(customer),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return SafeArea(
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: const ScreenAppBarTitle(screenId: 'UC', title: '顧客選択'),
|
||||
),
|
||||
body: body,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,18 +1,21 @@
|
|||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../config/app_config.dart';
|
||||
import '../models/invoice_models.dart';
|
||||
import '../modules/feature_module.dart';
|
||||
import '../modules/module_registry.dart';
|
||||
import '../services/app_settings_repository.dart';
|
||||
import '../services/customer_repository.dart';
|
||||
import '../services/location_service.dart';
|
||||
import '../widgets/slide_to_unlock.dart';
|
||||
import 'customer_master_screen.dart';
|
||||
import 'invoice_detail_page.dart';
|
||||
import 'invoice_history_screen.dart';
|
||||
import 'invoice_input_screen.dart';
|
||||
import 'invoice_detail_page.dart';
|
||||
import 'customer_master_screen.dart';
|
||||
import 'master_hub_page.dart';
|
||||
import 'sales_orders_screen.dart';
|
||||
import 'product_master_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
import 'master_hub_page.dart';
|
||||
import '../models/invoice_models.dart';
|
||||
import '../services/location_service.dart';
|
||||
import '../services/customer_repository.dart';
|
||||
import '../widgets/slide_to_unlock.dart';
|
||||
import '../config/app_config.dart';
|
||||
|
||||
class DashboardScreen extends StatefulWidget {
|
||||
const DashboardScreen({super.key});
|
||||
|
|
@ -23,11 +26,13 @@ class DashboardScreen extends StatefulWidget {
|
|||
|
||||
class _DashboardScreenState extends State<DashboardScreen> {
|
||||
final _repo = AppSettingsRepository();
|
||||
final _moduleRegistry = ModuleRegistry.instance;
|
||||
bool _loading = true;
|
||||
bool _statusEnabled = true;
|
||||
String _statusText = '工事中';
|
||||
List<DashboardMenuItem> _menu = [];
|
||||
bool _historyUnlocked = false;
|
||||
List<ModuleDashboardCard> _moduleCards = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -39,8 +44,9 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||
final statusEnabled = await _repo.getDashboardStatusEnabled();
|
||||
final statusText = await _repo.getDashboardStatusText();
|
||||
final rawMenu = await _repo.getDashboardMenu();
|
||||
final enabledRoutes = AppConfig.enabledRoutes;
|
||||
final menu = rawMenu.where((m) => enabledRoutes.contains(m.route)).toList();
|
||||
final moduleCards = _moduleRegistry.enabledCards.toList();
|
||||
final menu = rawMenu.toList();
|
||||
_ensureModuleCardsInjected(menu, moduleCards);
|
||||
final unlocked = await _repo.getDashboardHistoryUnlocked();
|
||||
setState(() {
|
||||
_statusEnabled = statusEnabled;
|
||||
|
|
@ -48,16 +54,53 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||
_menu = menu;
|
||||
_loading = false;
|
||||
_historyUnlocked = unlocked;
|
||||
_moduleCards = moduleCards;
|
||||
});
|
||||
}
|
||||
|
||||
Set<String> _enabledRouteSet() {
|
||||
final routes = {...AppConfig.enabledRoutes};
|
||||
for (final card in _moduleRegistry.enabledCards) {
|
||||
routes.add(card.route);
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
|
||||
void _ensureModuleCardsInjected(List<DashboardMenuItem> menu, List<ModuleDashboardCard> cards) {
|
||||
final existingRoutes = menu.map((m) => m.route).toSet();
|
||||
for (final card in cards) {
|
||||
if (!existingRoutes.contains(card.route)) {
|
||||
menu.add(DashboardMenuItem(id: card.id, title: card.title, route: card.route, iconName: card.iconName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ModuleDashboardCard? _moduleCardForRoute(String route) {
|
||||
for (final card in _moduleCards) {
|
||||
if (card.route == route) {
|
||||
return card;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _navigate(DashboardMenuItem item) async {
|
||||
Widget? target;
|
||||
final enabledRoutes = AppConfig.enabledRoutes;
|
||||
final enabledRoutes = _enabledRouteSet();
|
||||
if (!enabledRoutes.contains(item.route)) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('この機能は現在ご利用いただけません')));
|
||||
return;
|
||||
}
|
||||
final moduleCard = _moduleCardForRoute(item.route);
|
||||
if (moduleCard != null) {
|
||||
if (moduleCard.requiresUnlock && !_historyUnlocked) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('ロックを解除してください')));
|
||||
return;
|
||||
}
|
||||
await moduleCard.onTap(context);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (item.route) {
|
||||
case 'invoice_history':
|
||||
if (!_historyUnlocked) {
|
||||
|
|
@ -96,6 +139,9 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||
case 'settings':
|
||||
target = const SettingsScreen();
|
||||
break;
|
||||
case 'sales_operations':
|
||||
target = const SalesOrdersScreen();
|
||||
break;
|
||||
default:
|
||||
target = const InvoiceHistoryScreen();
|
||||
break;
|
||||
|
|
@ -157,6 +203,10 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||
}
|
||||
|
||||
String _routeLabel(String route) {
|
||||
final moduleCard = _moduleCardForRoute(route);
|
||||
if (moduleCard != null) {
|
||||
return moduleCard.description;
|
||||
}
|
||||
switch (route) {
|
||||
case 'invoice_history':
|
||||
return 'A2:伝票一覧';
|
||||
|
|
@ -170,6 +220,8 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||
return 'M1:マスター管理';
|
||||
case 'settings':
|
||||
return 'S1:設定';
|
||||
case 'sales_operations':
|
||||
return 'B1:販売オペレーション';
|
||||
default:
|
||||
return route;
|
||||
}
|
||||
|
|
|
|||
225
lib/screens/department_master_screen.dart
Normal file
225
lib/screens/department_master_screen.dart
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/department_model.dart';
|
||||
import '../services/department_repository.dart';
|
||||
|
||||
class DepartmentMasterScreen extends StatefulWidget {
|
||||
const DepartmentMasterScreen({super.key});
|
||||
|
||||
@override
|
||||
State<DepartmentMasterScreen> createState() => _DepartmentMasterScreenState();
|
||||
}
|
||||
|
||||
class _DepartmentMasterScreenState extends State<DepartmentMasterScreen> {
|
||||
final DepartmentRepository _repository = DepartmentRepository();
|
||||
final Uuid _uuid = const Uuid();
|
||||
|
||||
bool _isLoading = true;
|
||||
bool _includeInactive = true;
|
||||
List<Department> _departments = const [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadDepartments();
|
||||
}
|
||||
|
||||
Future<void> _loadDepartments() async {
|
||||
setState(() => _isLoading = true);
|
||||
final list = await _repository.fetchDepartments(includeInactive: _includeInactive);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_departments = list;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _openForm({Department? department}) async {
|
||||
final result = await showDialog<Department>(
|
||||
context: context,
|
||||
builder: (ctx) => _DepartmentFormDialog(
|
||||
department: department,
|
||||
onSubmit: (data) => Navigator.of(ctx).pop(data),
|
||||
),
|
||||
);
|
||||
if (result == null) return;
|
||||
final saving = result.copyWith(id: result.id.isEmpty ? _uuid.v4() : result.id, updatedAt: DateTime.now());
|
||||
await _repository.saveDepartment(saving);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('部門を保存しました')));
|
||||
_loadDepartments();
|
||||
}
|
||||
|
||||
Future<void> _deleteDepartment(Department department) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('部門を削除'),
|
||||
content: Text('${department.name} を削除しますか?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('キャンセル')),
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('削除', style: TextStyle(color: Colors.red))),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
await _repository.deleteDepartment(department.id);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('部門を削除しました')));
|
||||
_loadDepartments();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: const BackButton(),
|
||||
title: const Text('M4:部門マスター'),
|
||||
actions: [
|
||||
SwitchListTile.adaptive(
|
||||
value: _includeInactive,
|
||||
onChanged: (value) {
|
||||
setState(() => _includeInactive = value);
|
||||
_loadDepartments();
|
||||
},
|
||||
contentPadding: const EdgeInsets.only(right: 12),
|
||||
title: const Text('無効を表示'),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => _openForm(),
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _departments.isEmpty
|
||||
? const _EmptyState(message: '部門が登録されていません')
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _departments.length,
|
||||
separatorBuilder: (context, _) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final department = _departments[index];
|
||||
return Card(
|
||||
child: ListTile(
|
||||
title: Text(department.name, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
subtitle: Text([
|
||||
if (department.code?.isNotEmpty == true) 'コード: ${department.code}',
|
||||
department.description ?? '',
|
||||
department.isActive ? '稼働中' : '無効',
|
||||
].where((v) => v.isNotEmpty).join('\n')),
|
||||
trailing: PopupMenuButton<String>(
|
||||
onSelected: (value) {
|
||||
switch (value) {
|
||||
case 'edit':
|
||||
_openForm(department: department);
|
||||
break;
|
||||
case 'delete':
|
||||
_deleteDepartment(department);
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => const [
|
||||
PopupMenuItem(value: 'edit', child: Text('編集')),
|
||||
PopupMenuItem(value: 'delete', child: Text('削除')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DepartmentFormDialog extends StatefulWidget {
|
||||
const _DepartmentFormDialog({required this.onSubmit, this.department});
|
||||
|
||||
final Department? department;
|
||||
final ValueChanged<Department> onSubmit;
|
||||
|
||||
@override
|
||||
State<_DepartmentFormDialog> createState() => _DepartmentFormDialogState();
|
||||
}
|
||||
|
||||
class _DepartmentFormDialogState extends State<_DepartmentFormDialog> {
|
||||
late final TextEditingController _nameController;
|
||||
late final TextEditingController _codeController;
|
||||
late final TextEditingController _descriptionController;
|
||||
bool _isActive = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final department = widget.department;
|
||||
_nameController = TextEditingController(text: department?.name ?? '');
|
||||
_codeController = TextEditingController(text: department?.code ?? '');
|
||||
_descriptionController = TextEditingController(text: department?.description ?? '');
|
||||
_isActive = department?.isActive ?? true;
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
if (_nameController.text.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('部門名は必須です')));
|
||||
return;
|
||||
}
|
||||
widget.onSubmit(
|
||||
Department(
|
||||
id: widget.department?.id ?? '',
|
||||
name: _nameController.text.trim(),
|
||||
code: _codeController.text.trim().isEmpty ? null : _codeController.text.trim(),
|
||||
description: _descriptionController.text.trim().isEmpty ? null : _descriptionController.text.trim(),
|
||||
isActive: _isActive,
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(widget.department == null ? '部門を追加' : '部門を編集'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(controller: _nameController, decoration: const InputDecoration(labelText: '部門名 *')),
|
||||
TextField(controller: _codeController, decoration: const InputDecoration(labelText: '部門コード')),
|
||||
TextField(controller: _descriptionController, decoration: const InputDecoration(labelText: '説明'), maxLines: 2),
|
||||
SwitchListTile(
|
||||
title: const Text('稼働中'),
|
||||
value: _isActive,
|
||||
onChanged: (value) => setState(() => _isActive = value),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')),
|
||||
FilledButton(onPressed: _submit, child: const Text('保存')),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyState extends StatelessWidget {
|
||||
const _EmptyState({required this.message});
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.view_list, size: 64, color: Colors.grey),
|
||||
const SizedBox(height: 16),
|
||||
Text(message),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -325,6 +325,7 @@ class _EmailSettingsScreenState extends State<EmailSettingsScreen> {
|
|||
final listBottomPadding = 24 + bottomInset;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: const BackButton(),
|
||||
title: const Text('SM:メール設定'),
|
||||
backgroundColor: Colors.indigo,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -33,7 +33,8 @@ class _GpsHistoryScreenState extends State<GpsHistoryScreen> {
|
|||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("GPS位置情報履歴"),
|
||||
leading: const BackButton(),
|
||||
title: const Text("G1:GPS位置履歴"),
|
||||
backgroundColor: Colors.blueGrey,
|
||||
actions: [
|
||||
IconButton(onPressed: _loadHistory, icon: const Icon(Icons.refresh)),
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import 'customer_master_screen.dart';
|
|||
import 'invoice_input_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
import 'company_info_screen.dart';
|
||||
import 'dashboard_screen.dart';
|
||||
import '../services/app_settings_repository.dart';
|
||||
import '../widgets/slide_to_unlock.dart';
|
||||
// InvoiceFlowScreen import removed; using inline type picker
|
||||
|
|
@ -150,6 +149,23 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget? _buildLeading(BuildContext context) {
|
||||
final canPop = Navigator.canPop(context);
|
||||
final hasDrawer = !_useDashboardHome && _isUnlocked;
|
||||
if (!canPop && hasDrawer) {
|
||||
return Builder(
|
||||
builder: (ctx) => IconButton(
|
||||
icon: const Icon(Icons.menu),
|
||||
onPressed: () => Scaffold.of(ctx).openDrawer(),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (canPop) {
|
||||
return const BackButton();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool _requireUnlock() {
|
||||
if (_isUnlocked) return true;
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("スライドでロック解除してください")));
|
||||
|
|
@ -214,9 +230,10 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
|
|||
Widget build(BuildContext context) {
|
||||
final amountFormatter = NumberFormat("#,###");
|
||||
final dateFormatter = DateFormat('yyyy/MM/dd');
|
||||
final hasDrawer = !_useDashboardHome && _isUnlocked;
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
drawer: (_useDashboardHome || !_isUnlocked)
|
||||
drawer: (!hasDrawer)
|
||||
? null
|
||||
: Drawer(
|
||||
child: SafeArea(
|
||||
|
|
@ -289,24 +306,7 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
|
|||
),
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
leading: _useDashboardHome
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () {
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const DashboardScreen()),
|
||||
);
|
||||
},
|
||||
)
|
||||
: (_isUnlocked
|
||||
? Builder(
|
||||
builder: (ctx) => IconButton(
|
||||
icon: const Icon(Icons.menu),
|
||||
onPressed: () => Scaffold.of(ctx).openDrawer(),
|
||||
),
|
||||
)
|
||||
: null),
|
||||
leading: _buildLeading(context),
|
||||
title: GestureDetector(
|
||||
onLongPress: () {
|
||||
Navigator.push(
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
List<EditLogEntry> _editLogs = [];
|
||||
final FocusNode _subjectFocusNode = FocusNode();
|
||||
String _lastLoggedSubject = "";
|
||||
bool _hasUnsavedChanges = false;
|
||||
|
||||
String _documentTypeLabel(DocumentType type) {
|
||||
switch (type) {
|
||||
|
|
@ -113,14 +114,73 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
return _currentId!;
|
||||
}
|
||||
|
||||
void _copyAsNew() {
|
||||
Future<bool> _confirmDiscardChanges() async {
|
||||
if (!_hasUnsavedChanges || _isSaving) {
|
||||
return true;
|
||||
}
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('保存されていない変更があります'),
|
||||
content: const Text('編集した内容を破棄してもよろしいですか?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('編集を続ける'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('破棄する'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
Future<void> _handleBackPressed() async {
|
||||
final allow = await _confirmDiscardChanges();
|
||||
if (!mounted || !allow) return;
|
||||
Navigator.of(context).maybePop();
|
||||
}
|
||||
|
||||
Future<DocumentType?> _pickCopyDocumentType() {
|
||||
return showDialog<DocumentType>(
|
||||
context: context,
|
||||
builder: (context) => SimpleDialog(
|
||||
title: const Text('コピー後の伝票種別を選択'),
|
||||
children: DocumentType.values.map((type) {
|
||||
final isCurrent = type == _documentType;
|
||||
return SimpleDialogOption(
|
||||
onPressed: () => Navigator.of(context).pop(type),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
isCurrent ? Icons.check_circle : Icons.circle_outlined,
|
||||
color: _documentTypeColor(type),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(_documentTypeLabel(type)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _copyAsNew() async {
|
||||
if (widget.existingInvoice == null && _currentId == null) return;
|
||||
final selectedType = await _pickCopyDocumentType();
|
||||
if (selectedType == null) return;
|
||||
final clonedItems = _cloneItems(_items, resetIds: true);
|
||||
setState(() {
|
||||
_currentId = DateTime.now().millisecondsSinceEpoch.toString();
|
||||
_isDraft = true;
|
||||
_isLocked = false;
|
||||
_selectedDate = DateTime.now();
|
||||
_documentType = selectedType;
|
||||
_items
|
||||
..clear()
|
||||
..addAll(clonedItems);
|
||||
|
|
@ -159,6 +219,7 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
final savedSummary = await _settingsRepo.getSummaryTheme();
|
||||
_summaryIsBlue = savedSummary == 'blue';
|
||||
|
||||
_isApplyingSnapshot = true;
|
||||
setState(() {
|
||||
// 既存伝票がある場合は初期値を上書き
|
||||
if (widget.existingInvoice != null) {
|
||||
|
|
@ -182,10 +243,14 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
_isLocked = false;
|
||||
}
|
||||
});
|
||||
_isApplyingSnapshot = false;
|
||||
_isViewMode = widget.startViewMode; // 指定に従う
|
||||
_showNewBadge = widget.showNewBadge;
|
||||
_showCopyBadge = widget.showCopyBadge;
|
||||
_pushHistory(clearRedo: true);
|
||||
_pushHistory(clearRedo: true, markDirty: false);
|
||||
if (_hasUnsavedChanges) {
|
||||
setState(() => _hasUnsavedChanges = false);
|
||||
}
|
||||
_lastLoggedSubject = _subjectController.text;
|
||||
if (_currentId != null) {
|
||||
_loadEditLogs();
|
||||
|
|
@ -287,7 +352,12 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
}
|
||||
await _editLogRepo.addLog(_currentId!, "伝票を保存しました");
|
||||
await _loadEditLogs();
|
||||
if (mounted) setState(() => _isViewMode = true);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isViewMode = true;
|
||||
_hasUnsavedChanges = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('保存に失敗しました: $e')));
|
||||
|
|
@ -346,7 +416,7 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
);
|
||||
}
|
||||
|
||||
void _pushHistory({bool clearRedo = false}) {
|
||||
void _pushHistory({bool clearRedo = false, bool markDirty = true}) {
|
||||
setState(() {
|
||||
if (_undoStack.length >= 30) _undoStack.removeAt(0);
|
||||
_undoStack.add(_InvoiceSnapshot(
|
||||
|
|
@ -360,6 +430,9 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
subject: _subjectController.text,
|
||||
));
|
||||
if (clearRedo) _redoStack.clear();
|
||||
if (markDirty) {
|
||||
_hasUnsavedChanges = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -432,100 +505,113 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
|
||||
final docColor = _documentTypeColor(_documentType);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: themeColor,
|
||||
resizeToAvoidBottomInset: false,
|
||||
appBar: AppBar(
|
||||
backgroundColor: docColor,
|
||||
leading: const BackButton(),
|
||||
title: Text("A1:${_documentTypeLabel(_documentType)}"),
|
||||
actions: [
|
||||
if (_isDraft)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
||||
child: _DraftBadge(),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy),
|
||||
tooltip: "コピーして新規",
|
||||
onPressed: _copyAsNew,
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, result) async {
|
||||
if (didPop) return;
|
||||
final allow = await _confirmDiscardChanges();
|
||||
if (allow && context.mounted) {
|
||||
Navigator.of(context).pop(result);
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: themeColor,
|
||||
resizeToAvoidBottomInset: false,
|
||||
appBar: AppBar(
|
||||
backgroundColor: docColor,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => _handleBackPressed(),
|
||||
),
|
||||
if (_isLocked)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Icon(Icons.lock, color: Colors.white),
|
||||
)
|
||||
else if (_isViewMode)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
tooltip: "編集モードにする",
|
||||
onPressed: () => setState(() => _isViewMode = false),
|
||||
)
|
||||
else ...[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.undo),
|
||||
onPressed: _canUndo ? _undo : null,
|
||||
tooltip: "元に戻す",
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.redo),
|
||||
onPressed: _canRedo ? _redo : null,
|
||||
tooltip: "やり直す",
|
||||
),
|
||||
if (!_isLocked)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.save),
|
||||
tooltip: "保存",
|
||||
onPressed: _isSaving ? null : () => _saveInvoice(generatePdf: false),
|
||||
title: Text("A1:${_documentTypeLabel(_documentType)}"),
|
||||
actions: [
|
||||
if (_isDraft)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
||||
child: _DraftBadge(),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy),
|
||||
tooltip: "コピーして新規",
|
||||
onPressed: () => _copyAsNew(),
|
||||
),
|
||||
if (_isLocked)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Icon(Icons.lock, color: Colors.white),
|
||||
)
|
||||
else if (_isViewMode)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
tooltip: "編集モードにする",
|
||||
onPressed: () => setState(() => _isViewMode = false),
|
||||
)
|
||||
else ...[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.undo),
|
||||
onPressed: _canUndo ? _undo : null,
|
||||
tooltip: "元に戻す",
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.redo),
|
||||
onPressed: _canRedo ? _redo : null,
|
||||
tooltip: "やり直す",
|
||||
),
|
||||
if (!_isLocked)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.save),
|
||||
tooltip: "保存",
|
||||
onPressed: _isSaving ? null : () => _saveInvoice(generatePdf: false),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, MediaQuery.of(context).viewInsets.bottom + 140),
|
||||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, MediaQuery.of(context).viewInsets.bottom + 140),
|
||||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildDateSection(),
|
||||
const SizedBox(height: 16),
|
||||
_buildCustomerSection(),
|
||||
const SizedBox(height: 16),
|
||||
_buildSubjectSection(textColor),
|
||||
const SizedBox(height: 20),
|
||||
_buildItemsSection(fmt),
|
||||
const SizedBox(height: 20),
|
||||
_buildSummarySection(fmt),
|
||||
const SizedBox(height: 12),
|
||||
_buildEditLogsSection(),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildBottomActionBar(),
|
||||
],
|
||||
),
|
||||
if (_isSaving)
|
||||
Container(
|
||||
color: Colors.black54,
|
||||
child: const Center(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildDateSection(),
|
||||
const SizedBox(height: 16),
|
||||
_buildCustomerSection(),
|
||||
const SizedBox(height: 16),
|
||||
_buildSubjectSection(textColor),
|
||||
const SizedBox(height: 20),
|
||||
_buildItemsSection(fmt),
|
||||
const SizedBox(height: 20),
|
||||
_buildSummarySection(fmt),
|
||||
const SizedBox(height: 12),
|
||||
_buildEditLogsSection(),
|
||||
const SizedBox(height: 20),
|
||||
CircularProgressIndicator(color: Colors.white),
|
||||
SizedBox(height: 16),
|
||||
Text("保存中...", style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildBottomActionBar(),
|
||||
],
|
||||
),
|
||||
if (_isSaving)
|
||||
Container(
|
||||
color: Colors.black54,
|
||||
child: const Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(color: Colors.white),
|
||||
SizedBox(height: 16),
|
||||
Text("保存中...", style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import '../services/invoice_repository.dart';
|
||||
import '../services/customer_repository.dart';
|
||||
import '../services/database_maintenance_service.dart';
|
||||
import 'product_master_screen.dart';
|
||||
import 'customer_master_screen.dart';
|
||||
import 'activity_log_screen.dart';
|
||||
|
|
@ -19,12 +17,13 @@ class ManagementScreen extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _ManagementScreenState extends State<ManagementScreen> {
|
||||
final _dbMaintenance = const DatabaseMaintenanceService();
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: const BackButton(),
|
||||
title: const Text("マスター管理・同期"),
|
||||
title: const Text("M2:マスター管理・同期"),
|
||||
backgroundColor: Colors.blueGrey,
|
||||
),
|
||||
body: ListView(
|
||||
|
|
@ -157,13 +156,12 @@ class _ManagementScreenState extends State<ManagementScreen> {
|
|||
}
|
||||
|
||||
Future<void> _backupDatabase(BuildContext context) async {
|
||||
final dbPath = p.join(await getDatabasesPath(), 'gemi_invoice.db');
|
||||
final file = File(dbPath);
|
||||
if (await file.exists()) {
|
||||
final file = await _dbMaintenance.getDatabaseFile();
|
||||
if (file != null) {
|
||||
await SharePlus.instance.share(
|
||||
ShareParams(
|
||||
text: '販売アシスト1号_DBバックアップ',
|
||||
files: [XFile(dbPath)],
|
||||
files: [XFile(file.path)],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'customer_master_screen.dart';
|
||||
import 'department_master_screen.dart';
|
||||
import 'product_master_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
import 'staff_master_screen.dart';
|
||||
import 'supplier_master_screen.dart';
|
||||
import 'tax_setting_screen.dart';
|
||||
|
||||
class MasterHubPage extends StatelessWidget {
|
||||
const MasterHubPage({super.key});
|
||||
|
|
@ -15,12 +19,36 @@ class MasterHubPage extends StatelessWidget {
|
|||
icon: Icons.people,
|
||||
builder: (_) => const CustomerMasterScreen(),
|
||||
),
|
||||
MasterEntry(
|
||||
title: '仕入先マスター',
|
||||
description: '仕入先・支払条件の管理',
|
||||
icon: Icons.factory,
|
||||
builder: (_) => const SupplierMasterScreen(),
|
||||
),
|
||||
MasterEntry(
|
||||
title: '商品マスター',
|
||||
description: '商品情報の管理・編集',
|
||||
icon: Icons.inventory_2,
|
||||
builder: (_) => const ProductMasterScreen(),
|
||||
),
|
||||
MasterEntry(
|
||||
title: '部門マスター',
|
||||
description: '部門・部署構成を管理',
|
||||
icon: Icons.apartment,
|
||||
builder: (_) => const DepartmentMasterScreen(),
|
||||
),
|
||||
MasterEntry(
|
||||
title: '担当者マスター',
|
||||
description: '社内スタッフと権限を管理',
|
||||
icon: Icons.badge,
|
||||
builder: (_) => const StaffMasterScreen(),
|
||||
),
|
||||
MasterEntry(
|
||||
title: '消費税・端数設定',
|
||||
description: '税率と端数処理ルールを設定',
|
||||
icon: Icons.calculate,
|
||||
builder: (_) => const TaxSettingScreen(),
|
||||
),
|
||||
MasterEntry(
|
||||
title: '設定',
|
||||
description: 'アプリ設定・メニュー管理',
|
||||
|
|
@ -31,11 +59,12 @@ class MasterHubPage extends StatelessWidget {
|
|||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: const BackButton(),
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: const [
|
||||
Text('マスター管理'),
|
||||
Text('ScreenID: 03', style: TextStyle(fontSize: 11, color: Colors.white70)),
|
||||
Text('M3:マスター管理'),
|
||||
Text('ScreenID: M3', style: TextStyle(fontSize: 11, color: Colors.white70)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
|
|||
final nameController = TextEditingController(text: product?.name ?? "");
|
||||
final priceController = TextEditingController(text: (product?.defaultUnitPrice ?? 0).toString());
|
||||
final barcodeController = TextEditingController(text: product?.barcode ?? "");
|
||||
final wholesaleController = TextEditingController(text: (product?.wholesalePrice ?? 0).toString());
|
||||
final categoryController = TextEditingController(text: product?.category ?? "");
|
||||
final stockController = TextEditingController(text: (product?.stockQuantity ?? 0).toString());
|
||||
|
||||
|
|
@ -84,6 +85,7 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
|
|||
TextField(controller: nameController, decoration: const InputDecoration(labelText: "商品名")),
|
||||
TextField(controller: categoryController, decoration: const InputDecoration(labelText: "カテゴリ")),
|
||||
TextField(controller: priceController, decoration: const InputDecoration(labelText: "初期単価"), keyboardType: TextInputType.number),
|
||||
TextField(controller: wholesaleController, decoration: const InputDecoration(labelText: "仕入値(卸値)"), keyboardType: TextInputType.number),
|
||||
TextField(controller: stockController, decoration: const InputDecoration(labelText: "在庫数"), keyboardType: TextInputType.number),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
|
|
@ -121,6 +123,7 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
|
|||
id: newId,
|
||||
name: nameController.text.trim(),
|
||||
defaultUnitPrice: int.tryParse(priceController.text) ?? 0,
|
||||
wholesalePrice: int.tryParse(wholesaleController.text) ?? 0,
|
||||
barcode: barcodeController.text.isEmpty ? null : barcodeController.text.trim(),
|
||||
category: categoryController.text.isEmpty ? null : categoryController.text.trim(),
|
||||
stockQuantity: int.tryParse(stockController.text) ?? 0,
|
||||
|
|
@ -207,7 +210,7 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
|
|||
: (p.isLocked ? Colors.grey : Colors.black87),
|
||||
),
|
||||
),
|
||||
subtitle: Text("${p.category ?? '未分類'} - ¥${p.defaultUnitPrice} (在庫: ${p.stockQuantity})"),
|
||||
subtitle: Text("${p.category ?? '未分類'} - 販売¥${p.defaultUnitPrice} / 仕入¥${p.wholesalePrice} (在庫: ${p.stockQuantity})"),
|
||||
onTap: () {
|
||||
if (widget.selectionMode) {
|
||||
if (p.isHidden) return; // safety: do not return hidden in selection
|
||||
|
|
@ -312,7 +315,8 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
|
|||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text("単価: ¥${p.defaultUnitPrice}"),
|
||||
Text("販売単価: ¥${p.defaultUnitPrice}"),
|
||||
Text("仕入値: ¥${p.wholesalePrice}"),
|
||||
Text("在庫: ${p.stockQuantity}"),
|
||||
if (p.barcode != null && p.barcode!.isNotEmpty) Text("バーコード: ${p.barcode}"),
|
||||
const SizedBox(height: 12),
|
||||
|
|
|
|||
|
|
@ -2,13 +2,16 @@ import 'package:flutter/material.dart';
|
|||
import '../models/invoice_models.dart';
|
||||
import '../models/product_model.dart';
|
||||
import '../services/product_repository.dart';
|
||||
import '../widgets/keyboard_inset_wrapper.dart';
|
||||
import '../widgets/screen_id_title.dart';
|
||||
import 'product_master_screen.dart';
|
||||
|
||||
/// 商品マスターから項目を選択するためのモーダル(スタブ実装)
|
||||
class ProductPickerModal extends StatefulWidget {
|
||||
final Function(InvoiceItem) onItemSelected;
|
||||
final ValueChanged<Product>? onProductSelected;
|
||||
|
||||
const ProductPickerModal({super.key, required this.onItemSelected});
|
||||
const ProductPickerModal({super.key, required this.onItemSelected, this.onProductSelected});
|
||||
|
||||
@override
|
||||
State<ProductPickerModal> createState() => _ProductPickerModalState();
|
||||
|
|
@ -37,135 +40,137 @@ class _ProductPickerModalState extends State<ProductPickerModal> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 16, 8),
|
||||
child: Row(
|
||||
return SafeArea(
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: const ScreenAppBarTitle(screenId: 'P6', title: '商品・サービス選択'),
|
||||
),
|
||||
body: KeyboardInsetWrapper(
|
||||
safeAreaTop: false,
|
||||
basePadding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
extraBottom: 24,
|
||||
child: Column(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
TextField(
|
||||
controller: _searchController,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: "商品名・カテゴリ・バーコードで検索",
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
_onSearch("");
|
||||
},
|
||||
),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 0),
|
||||
),
|
||||
onChanged: _onSearch,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _products.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text("商品が見つかりません"),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await Navigator.push(context, MaterialPageRoute(builder: (context) => const ProductMasterScreen()));
|
||||
_onSearch(_searchController.text);
|
||||
},
|
||||
child: const Text("マスターに追加する"),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: _products.length,
|
||||
itemBuilder: (context, index) {
|
||||
final product = _products[index];
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.inventory_2_outlined),
|
||||
title: Text(product.name),
|
||||
subtitle: Text("¥${product.defaultUnitPrice} (在庫: ${product.stockQuantity})"),
|
||||
onTap: () {
|
||||
widget.onProductSelected?.call(product);
|
||||
widget.onItemSelected(
|
||||
InvoiceItem(
|
||||
productId: product.id,
|
||||
description: product.name,
|
||||
quantity: 1,
|
||||
unitPrice: product.defaultUnitPrice,
|
||||
),
|
||||
);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
onLongPress: () async {
|
||||
await showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.edit),
|
||||
title: const Text("編集"),
|
||||
onTap: () async {
|
||||
Navigator.pop(ctx);
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const ProductMasterScreen()),
|
||||
);
|
||||
_onSearch(_searchController.text);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete_outline, color: Colors.redAccent),
|
||||
title: const Text("削除", style: TextStyle(color: Colors.redAccent)),
|
||||
onTap: () async {
|
||||
Navigator.pop(ctx);
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
title: const Text("削除の確認"),
|
||||
content: Text("${product.name} を削除しますか?"),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("キャンセル")),
|
||||
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text("削除", style: TextStyle(color: Colors.red))),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true) {
|
||||
await _productRepo.deleteProduct(product.id);
|
||||
if (mounted) _onSearch(_searchController.text);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Text("商品・サービス選択", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: "商品名・カテゴリ・バーコードで検索",
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () { _searchController.clear(); _onSearch(""); },
|
||||
),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 0),
|
||||
),
|
||||
onChanged: _onSearch,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Divider(),
|
||||
Expanded(
|
||||
child: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _products.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text("商品が見つかりません"),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await Navigator.push(context, MaterialPageRoute(builder: (context) => const ProductMasterScreen()));
|
||||
_onSearch(_searchController.text);
|
||||
},
|
||||
child: const Text("マスターに追加する"),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: _products.length,
|
||||
itemBuilder: (context, index) {
|
||||
final product = _products[index];
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.inventory_2_outlined),
|
||||
title: Text(product.name),
|
||||
subtitle: Text("¥${product.defaultUnitPrice} (在庫: ${product.stockQuantity})"),
|
||||
onTap: () {
|
||||
widget.onItemSelected(
|
||||
InvoiceItem(
|
||||
productId: product.id,
|
||||
description: product.name,
|
||||
quantity: 1,
|
||||
unitPrice: product.defaultUnitPrice,
|
||||
),
|
||||
);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
onLongPress: () async {
|
||||
await showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.edit),
|
||||
title: const Text("編集"),
|
||||
onTap: () async {
|
||||
Navigator.pop(ctx);
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const ProductMasterScreen()),
|
||||
);
|
||||
_onSearch(_searchController.text);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete_outline, color: Colors.redAccent),
|
||||
title: const Text("削除", style: TextStyle(color: Colors.redAccent)),
|
||||
onTap: () async {
|
||||
Navigator.pop(ctx);
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
title: const Text("削除の確認"),
|
||||
content: Text("${product.name} を削除しますか?"),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("キャンセル")),
|
||||
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text("削除", style: TextStyle(color: Colors.red))),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true) {
|
||||
await _productRepo.deleteProduct(product.id);
|
||||
if (mounted) _onSearch(_searchController.text);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
441
lib/screens/purchase_entries_screen.dart
Normal file
441
lib/screens/purchase_entries_screen.dart
Normal file
|
|
@ -0,0 +1,441 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/purchase_entry_models.dart';
|
||||
import '../models/supplier_model.dart';
|
||||
import '../services/purchase_entry_service.dart';
|
||||
import '../widgets/keyboard_inset_wrapper.dart';
|
||||
import '../widgets/line_item_editor.dart';
|
||||
import '../widgets/modal_utils.dart';
|
||||
import '../widgets/screen_id_title.dart';
|
||||
import 'product_picker_modal.dart';
|
||||
import 'supplier_picker_modal.dart';
|
||||
|
||||
class PurchaseEntriesScreen extends StatefulWidget {
|
||||
const PurchaseEntriesScreen({super.key});
|
||||
|
||||
@override
|
||||
State<PurchaseEntriesScreen> createState() => _PurchaseEntriesScreenState();
|
||||
}
|
||||
|
||||
class _PurchaseEntriesScreenState extends State<PurchaseEntriesScreen> {
|
||||
final PurchaseEntryService _service = PurchaseEntryService();
|
||||
final NumberFormat _currencyFormat = NumberFormat.currency(locale: 'ja_JP', symbol: '¥');
|
||||
|
||||
bool _isLoading = true;
|
||||
bool _isRefreshing = false;
|
||||
PurchaseEntryStatus? _filterStatus;
|
||||
List<PurchaseEntry> _entries = const [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadEntries();
|
||||
}
|
||||
|
||||
Future<void> _loadEntries() async {
|
||||
if (!_isRefreshing) {
|
||||
setState(() => _isLoading = true);
|
||||
}
|
||||
try {
|
||||
final entries = await _service.fetchEntries(status: _filterStatus);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_entries = entries;
|
||||
_isLoading = false;
|
||||
_isRefreshing = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_isRefreshing = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('仕入伝票の取得に失敗しました: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
void _setFilter(PurchaseEntryStatus? status) {
|
||||
setState(() => _filterStatus = status);
|
||||
_loadEntries();
|
||||
}
|
||||
|
||||
Future<void> _handleRefresh() async {
|
||||
setState(() => _isRefreshing = true);
|
||||
await _loadEntries();
|
||||
}
|
||||
|
||||
Future<void> _openEditor({PurchaseEntry? entry}) async {
|
||||
final saved = await Navigator.push<PurchaseEntry>(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => PurchaseEntryEditorPage(entry: entry)),
|
||||
);
|
||||
if (saved != null) {
|
||||
await _loadEntries();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('仕入伝票を保存しました')));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteEntry(PurchaseEntry entry) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
title: const Text('仕入伝票を削除'),
|
||||
content: Text('${entry.subject ?? '無題'} を削除しますか?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('キャンセル')),
|
||||
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('削除', style: TextStyle(color: Colors.red))),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
await _service.deleteEntry(entry.id);
|
||||
if (!mounted) return;
|
||||
await _loadEntries();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('仕入伝票を削除しました')));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final body = _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: RefreshIndicator(
|
||||
onRefresh: _handleRefresh,
|
||||
child: _entries.isEmpty
|
||||
? ListView(
|
||||
children: const [
|
||||
SizedBox(height: 140),
|
||||
Icon(Icons.receipt_long, size: 64, color: Colors.grey),
|
||||
SizedBox(height: 12),
|
||||
Center(child: Text('仕入伝票がありません。右下のボタンから登録してください。')),
|
||||
],
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 120),
|
||||
itemCount: _entries.length,
|
||||
itemBuilder: (context, index) => _buildEntryCard(_entries[index]),
|
||||
),
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.grey.shade200,
|
||||
appBar: AppBar(
|
||||
leading: const BackButton(),
|
||||
title: const ScreenAppBarTitle(screenId: 'P1', title: '仕入伝票一覧'),
|
||||
actions: [
|
||||
PopupMenuButton<PurchaseEntryStatus?>(
|
||||
icon: const Icon(Icons.filter_alt),
|
||||
onSelected: _setFilter,
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(value: null, child: Text('すべて')),
|
||||
...PurchaseEntryStatus.values.map((status) => PopupMenuItem(
|
||||
value: status,
|
||||
child: Text(status.displayName),
|
||||
)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
body: body,
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => _openEditor(),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('仕入伝票を登録'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEntryCard(PurchaseEntry entry) {
|
||||
return Card(
|
||||
color: Colors.white,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: ListTile(
|
||||
onTap: () => _openEditor(entry: entry),
|
||||
onLongPress: () => _deleteEntry(entry),
|
||||
title: Text(entry.subject?.isNotEmpty == true ? entry.subject! : '無題の仕入伝票',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(entry.supplierNameSnapshot ?? '仕入先未設定'),
|
||||
const SizedBox(height: 4),
|
||||
Text('計上日: ${DateFormat('yyyy/MM/dd').format(entry.issueDate)}'),
|
||||
],
|
||||
),
|
||||
),
|
||||
trailing: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(entry.status.displayName, style: const TextStyle(fontSize: 12, color: Colors.black54)),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_currencyFormat.format(entry.amountTaxIncl),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PurchaseEntryEditorPage extends StatefulWidget {
|
||||
const PurchaseEntryEditorPage({super.key, this.entry});
|
||||
|
||||
final PurchaseEntry? entry;
|
||||
|
||||
@override
|
||||
State<PurchaseEntryEditorPage> createState() => _PurchaseEntryEditorPageState();
|
||||
}
|
||||
|
||||
class _PurchaseEntryEditorPageState extends State<PurchaseEntryEditorPage> {
|
||||
final PurchaseEntryService _service = PurchaseEntryService();
|
||||
final TextEditingController _subjectController = TextEditingController();
|
||||
final TextEditingController _notesController = TextEditingController();
|
||||
final uuid = const Uuid();
|
||||
|
||||
Supplier? _supplier;
|
||||
String? _supplierSnapshot;
|
||||
DateTime _issueDate = DateTime.now();
|
||||
bool _isSaving = false;
|
||||
final List<LineItemFormData> _lines = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final entry = widget.entry;
|
||||
if (entry != null) {
|
||||
_subjectController.text = entry.subject ?? '';
|
||||
_notesController.text = entry.notes ?? '';
|
||||
_issueDate = entry.issueDate;
|
||||
_supplierSnapshot = entry.supplierNameSnapshot;
|
||||
_lines.addAll(entry.items
|
||||
.map((item) => LineItemFormData(
|
||||
id: item.id,
|
||||
productId: item.productId,
|
||||
productName: item.description,
|
||||
quantity: item.quantity,
|
||||
unitPrice: item.unitPrice,
|
||||
taxRate: item.taxRate,
|
||||
))
|
||||
.toList());
|
||||
}
|
||||
if (_lines.isEmpty) {
|
||||
_lines.add(LineItemFormData(quantity: 1, unitPrice: 0));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_subjectController.dispose();
|
||||
_notesController.dispose();
|
||||
for (final line in _lines) {
|
||||
line.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _pickSupplier() async {
|
||||
final selected = await showFeatureModalBottomSheet<Supplier>(
|
||||
context: context,
|
||||
builder: (ctx) => SupplierPickerModal(
|
||||
onSupplierSelected: (supplier) {
|
||||
Navigator.pop(ctx, supplier);
|
||||
},
|
||||
),
|
||||
);
|
||||
if (selected == null) return;
|
||||
setState(() {
|
||||
_supplier = selected;
|
||||
_supplierSnapshot = selected.name;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pickIssueDate() async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _issueDate,
|
||||
firstDate: DateTime(2010),
|
||||
lastDate: DateTime(2100),
|
||||
);
|
||||
if (picked == null) return;
|
||||
setState(() => _issueDate = picked);
|
||||
}
|
||||
|
||||
void _addLine() {
|
||||
setState(() => _lines.add(LineItemFormData(quantity: 1, unitPrice: 0)));
|
||||
}
|
||||
|
||||
void _removeLine(int index) {
|
||||
setState(() {
|
||||
final removed = _lines.removeAt(index);
|
||||
removed.dispose();
|
||||
if (_lines.isEmpty) {
|
||||
_lines.add(LineItemFormData(quantity: 1, unitPrice: 0));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pickProduct(int index) async {
|
||||
await showFeatureModalBottomSheet<void>(
|
||||
context: context,
|
||||
builder: (_) => ProductPickerModal(
|
||||
onItemSelected: (_) {},
|
||||
onProductSelected: (product) {
|
||||
setState(() => _lines[index].applyProduct(product));
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (_isSaving) return;
|
||||
if (_lines.every((line) => line.descriptionController.text.trim().isEmpty)) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('少なくとも1件の明細を入力してください')));
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isSaving = true);
|
||||
try {
|
||||
final now = DateTime.now();
|
||||
final entryId = widget.entry?.id ?? uuid.v4();
|
||||
final items = _lines.map((line) {
|
||||
final quantity = line.quantityValue;
|
||||
final unitPrice = line.unitPriceValue;
|
||||
return PurchaseLineItem(
|
||||
id: line.id ?? uuid.v4(),
|
||||
purchaseEntryId: entryId,
|
||||
description: line.description.isEmpty ? '商品' : line.description,
|
||||
quantity: quantity,
|
||||
unitPrice: unitPrice,
|
||||
lineTotal: quantity * unitPrice,
|
||||
productId: line.productId,
|
||||
taxRate: line.taxRate ?? 0,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
final entry = PurchaseEntry(
|
||||
id: entryId,
|
||||
supplierId: _supplier?.id ?? widget.entry?.supplierId,
|
||||
supplierNameSnapshot: _supplierSnapshot,
|
||||
subject: _subjectController.text.trim().isEmpty ? '仕入伝票' : _subjectController.text.trim(),
|
||||
issueDate: _issueDate,
|
||||
status: widget.entry?.status ?? PurchaseEntryStatus.draft,
|
||||
notes: _notesController.text.trim().isEmpty ? null : _notesController.text.trim(),
|
||||
createdAt: widget.entry?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
items: items,
|
||||
);
|
||||
|
||||
final saved = await _service.saveEntry(entry);
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context, saved);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _isSaving = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('保存に失敗しました: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.grey.shade200,
|
||||
resizeToAvoidBottomInset: false,
|
||||
appBar: AppBar(
|
||||
leading: const BackButton(),
|
||||
title: ScreenAppBarTitle(
|
||||
screenId: 'P2',
|
||||
title: widget.entry == null ? '仕入伝票作成' : '仕入伝票編集',
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: _isSaving ? null : _save, child: const Text('保存')),
|
||||
],
|
||||
),
|
||||
body: KeyboardInsetWrapper(
|
||||
safeAreaTop: false,
|
||||
basePadding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
|
||||
extraBottom: 32,
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.zero,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Card(
|
||||
color: Colors.white,
|
||||
child: ListTile(
|
||||
title: Text(_supplierSnapshot ?? '仕入先を選択'),
|
||||
subtitle: const Text('タップして仕入先を選択'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: _pickSupplier,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Card(
|
||||
color: Colors.white,
|
||||
child: ListTile(
|
||||
title: const Text('計上日'),
|
||||
subtitle: Text(DateFormat('yyyy/MM/dd').format(_issueDate)),
|
||||
trailing: TextButton(onPressed: _pickIssueDate, child: const Text('変更')),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Card(
|
||||
color: Colors.white,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: TextField(
|
||||
controller: _subjectController,
|
||||
decoration: const InputDecoration(labelText: '件名'),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text('明細', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
..._lines.asMap().entries.map(
|
||||
(entry) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: LineItemCard(
|
||||
data: entry.value,
|
||||
onPickProduct: () => _pickProduct(entry.key),
|
||||
onRemove: () => _removeLine(entry.key),
|
||||
),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(onPressed: _addLine, icon: const Icon(Icons.add), label: const Text('明細を追加')),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Card(
|
||||
color: Colors.white,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: KeyboardInsetWrapper(
|
||||
basePadding: EdgeInsets.zero,
|
||||
safeAreaTop: false,
|
||||
safeAreaBottom: false,
|
||||
child: TextField(
|
||||
controller: _notesController,
|
||||
decoration: const InputDecoration(labelText: 'メモ'),
|
||||
minLines: 2,
|
||||
maxLines: 4,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
745
lib/screens/purchase_receipts_screen.dart
Normal file
745
lib/screens/purchase_receipts_screen.dart
Normal file
|
|
@ -0,0 +1,745 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../models/purchase_entry_models.dart';
|
||||
import '../models/supplier_model.dart';
|
||||
import '../services/purchase_entry_service.dart';
|
||||
import '../services/purchase_receipt_service.dart';
|
||||
import '../services/supplier_repository.dart';
|
||||
import '../widgets/keyboard_inset_wrapper.dart';
|
||||
import '../widgets/screen_id_title.dart';
|
||||
import 'supplier_picker_modal.dart';
|
||||
|
||||
class PurchaseReceiptsScreen extends StatefulWidget {
|
||||
const PurchaseReceiptsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<PurchaseReceiptsScreen> createState() => _PurchaseReceiptsScreenState();
|
||||
}
|
||||
|
||||
class _PurchaseReceiptsScreenState extends State<PurchaseReceiptsScreen> {
|
||||
final PurchaseReceiptService _receiptService = PurchaseReceiptService();
|
||||
final SupplierRepository _supplierRepository = SupplierRepository();
|
||||
final NumberFormat _currencyFormat = NumberFormat.currency(locale: 'ja_JP', symbol: '¥');
|
||||
final DateFormat _dateFormat = DateFormat('yyyy/MM/dd');
|
||||
|
||||
bool _isLoading = true;
|
||||
bool _isRefreshing = false;
|
||||
List<PurchaseReceipt> _receipts = const [];
|
||||
Map<String, int> _receiptAllocations = const {};
|
||||
Map<String, String> _supplierNames = const {};
|
||||
DateTime? _startDate;
|
||||
DateTime? _endDate;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadReceipts();
|
||||
}
|
||||
|
||||
Future<void> _loadReceipts() async {
|
||||
if (!_isRefreshing) {
|
||||
setState(() => _isLoading = true);
|
||||
}
|
||||
try {
|
||||
final receipts = await _receiptService.fetchReceipts(startDate: _startDate, endDate: _endDate);
|
||||
final allocationMap = <String, int>{};
|
||||
for (final receipt in receipts) {
|
||||
final links = await _receiptService.fetchLinks(receipt.id);
|
||||
allocationMap[receipt.id] = links.fold<int>(0, (sum, link) => sum + link.allocatedAmount);
|
||||
}
|
||||
final supplierIds = receipts.map((r) => r.supplierId).whereType<String>().toSet();
|
||||
final supplierNames = Map<String, String>.from(_supplierNames);
|
||||
for (final id in supplierIds) {
|
||||
if (supplierNames.containsKey(id)) continue;
|
||||
final supplier = await _supplierRepository.fetchSuppliers(includeHidden: true).then(
|
||||
(list) => list.firstWhere(
|
||||
(s) => s.id == id,
|
||||
orElse: () => Supplier(id: id, name: '仕入先不明', updatedAt: DateTime.now()),
|
||||
),
|
||||
);
|
||||
supplierNames[id] = supplier.name;
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_receipts = receipts;
|
||||
_receiptAllocations = allocationMap;
|
||||
_supplierNames = supplierNames;
|
||||
_isLoading = false;
|
||||
_isRefreshing = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_isRefreshing = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('支払データの取得に失敗しました: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleRefresh() async {
|
||||
setState(() => _isRefreshing = true);
|
||||
await _loadReceipts();
|
||||
}
|
||||
|
||||
Future<void> _pickDate({required bool isStart}) async {
|
||||
final initial = isStart ? (_startDate ?? DateTime.now().subtract(const Duration(days: 30))) : (_endDate ?? DateTime.now());
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: initial,
|
||||
firstDate: DateTime(2015),
|
||||
lastDate: DateTime(2100),
|
||||
);
|
||||
if (picked == null) return;
|
||||
setState(() {
|
||||
if (isStart) {
|
||||
_startDate = picked;
|
||||
} else {
|
||||
_endDate = picked;
|
||||
}
|
||||
});
|
||||
_loadReceipts();
|
||||
}
|
||||
|
||||
void _clearFilters() {
|
||||
setState(() {
|
||||
_startDate = null;
|
||||
_endDate = null;
|
||||
});
|
||||
_loadReceipts();
|
||||
}
|
||||
|
||||
Future<void> _openEditor({PurchaseReceipt? receipt}) async {
|
||||
final updated = await Navigator.of(context).push<PurchaseReceipt>(
|
||||
MaterialPageRoute(builder: (_) => PurchaseReceiptEditorPage(receipt: receipt)),
|
||||
);
|
||||
if (updated != null) {
|
||||
await _loadReceipts();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('支払データを保存しました')));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(PurchaseReceipt receipt) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('支払を削除'),
|
||||
content: Text('${_dateFormat.format(receipt.paymentDate)}の${_currencyFormat.format(receipt.amount)}を削除しますか?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('キャンセル')),
|
||||
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('削除')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
try {
|
||||
await _receiptService.deleteReceipt(receipt.id);
|
||||
if (!mounted) return;
|
||||
await _loadReceipts();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('支払を削除しました')));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('削除に失敗しました: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
String _supplierLabel(PurchaseReceipt receipt) {
|
||||
if (receipt.supplierId == null) {
|
||||
return '仕入先未設定';
|
||||
}
|
||||
return _supplierNames[receipt.supplierId] ?? '仕入先読込中';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final filterLabel = [
|
||||
if (_startDate != null) '開始: ${_dateFormat.format(_startDate!)}',
|
||||
if (_endDate != null) '終了: ${_dateFormat.format(_endDate!)}',
|
||||
].join(' / ');
|
||||
|
||||
final body = _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: RefreshIndicator(
|
||||
onRefresh: _handleRefresh,
|
||||
child: _receipts.isEmpty
|
||||
? ListView(
|
||||
children: const [
|
||||
SizedBox(height: 140),
|
||||
Icon(Icons.account_balance_wallet_outlined, size: 64, color: Colors.grey),
|
||||
SizedBox(height: 12),
|
||||
Center(child: Text('支払データがありません。右下のボタンから登録してください。')),
|
||||
],
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 120),
|
||||
itemCount: _receipts.length,
|
||||
itemBuilder: (context, index) => _buildReceiptCard(_receipts[index]),
|
||||
),
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: const BackButton(),
|
||||
title: const ScreenAppBarTitle(screenId: 'P3', title: '支払管理'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: '開始日を選択',
|
||||
icon: const Icon(Icons.calendar_today),
|
||||
onPressed: () => _pickDate(isStart: true),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '終了日を選択',
|
||||
icon: const Icon(Icons.event),
|
||||
onPressed: () => _pickDate(isStart: false),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'フィルターをクリア',
|
||||
icon: const Icon(Icons.filter_alt_off),
|
||||
onPressed: (_startDate == null && _endDate == null) ? null : _clearFilters,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
bottom: filterLabel.isEmpty
|
||||
? null
|
||||
: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(32),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(filterLabel, style: const TextStyle(color: Colors.white70)),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: body,
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => _openEditor(),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('支払を登録'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReceiptCard(PurchaseReceipt receipt) {
|
||||
final allocated = _receiptAllocations[receipt.id] ?? 0;
|
||||
final allocationRatio = receipt.amount == 0 ? 0.0 : allocated / receipt.amount;
|
||||
final statusColor = allocationRatio >= 0.999
|
||||
? Colors.green
|
||||
: allocationRatio <= 0
|
||||
? Colors.orange
|
||||
: Colors.blue;
|
||||
final supplier = _supplierLabel(receipt);
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: ListTile(
|
||||
onTap: () => _openEditor(receipt: receipt),
|
||||
title: Text(
|
||||
_currencyFormat.format(receipt.amount),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
|
||||
),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(supplier),
|
||||
const SizedBox(height: 4),
|
||||
Text('割当: ${_currencyFormat.format(allocated)} / ${_currencyFormat.format(receipt.amount)}'),
|
||||
if (receipt.notes?.isNotEmpty == true) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(receipt.notes!, style: const TextStyle(fontSize: 12, color: Colors.black87)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
trailing: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(_dateFormat.format(receipt.paymentDate)),
|
||||
const SizedBox(height: 4),
|
||||
Text(receipt.method ?? '未設定', style: const TextStyle(fontSize: 12, color: Colors.black54)),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
decoration: BoxDecoration(color: statusColor.withAlpha(32), borderRadius: BorderRadius.circular(12)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
child: Text(
|
||||
allocationRatio >= 0.999
|
||||
? '全額割当済'
|
||||
: allocationRatio <= 0
|
||||
? '未割当'
|
||||
: '一部割当',
|
||||
style: TextStyle(color: statusColor, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
isThreeLine: true,
|
||||
contentPadding: const EdgeInsets.all(16),
|
||||
tileColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
onLongPress: () => _confirmDelete(receipt),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PurchaseReceiptEditorPage extends StatefulWidget {
|
||||
const PurchaseReceiptEditorPage({super.key, this.receipt});
|
||||
|
||||
final PurchaseReceipt? receipt;
|
||||
|
||||
@override
|
||||
State<PurchaseReceiptEditorPage> createState() => _PurchaseReceiptEditorPageState();
|
||||
}
|
||||
|
||||
class _PurchaseReceiptEditorPageState extends State<PurchaseReceiptEditorPage> {
|
||||
final PurchaseReceiptService _receiptService = PurchaseReceiptService();
|
||||
final PurchaseEntryService _entryService = PurchaseEntryService();
|
||||
final SupplierRepository _supplierRepository = SupplierRepository();
|
||||
final TextEditingController _amountController = TextEditingController();
|
||||
final TextEditingController _notesController = TextEditingController();
|
||||
final DateFormat _dateFormat = DateFormat('yyyy/MM/dd');
|
||||
final NumberFormat _currencyFormat = NumberFormat.currency(locale: 'ja_JP', symbol: '¥');
|
||||
|
||||
DateTime _paymentDate = DateTime.now();
|
||||
String? _supplierId;
|
||||
String? _supplierName;
|
||||
String? _method = '銀行振込';
|
||||
bool _isSaving = false;
|
||||
bool _isInitializing = true;
|
||||
|
||||
List<_AllocationRow> _allocations = [];
|
||||
List<PurchaseEntry> _entries = [];
|
||||
Map<String, int> _baseAllocated = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final receipt = widget.receipt;
|
||||
if (receipt != null) {
|
||||
_paymentDate = receipt.paymentDate;
|
||||
_amountController.text = receipt.amount.toString();
|
||||
_notesController.text = receipt.notes ?? '';
|
||||
_method = receipt.method ?? '銀行振込';
|
||||
_supplierId = receipt.supplierId;
|
||||
if (_supplierId != null) {
|
||||
_loadSupplierName(_supplierId!);
|
||||
}
|
||||
} else {
|
||||
_amountController.text = '';
|
||||
}
|
||||
_amountController.addListener(() => setState(() {}));
|
||||
_loadData();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_amountController.dispose();
|
||||
_notesController.dispose();
|
||||
for (final row in _allocations) {
|
||||
row.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadSupplierName(String supplierId) async {
|
||||
final suppliers = await _supplierRepository.fetchSuppliers(includeHidden: true);
|
||||
final supplier = suppliers.firstWhere(
|
||||
(s) => s.id == supplierId,
|
||||
orElse: () => Supplier(id: supplierId, name: '仕入先不明', updatedAt: DateTime.now()),
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _supplierName = supplier.name);
|
||||
}
|
||||
|
||||
Future<void> _loadData() async {
|
||||
try {
|
||||
final entries = await _entryService.fetchEntries();
|
||||
final totals = await _receiptService.fetchAllocatedTotals(entries.map((e) => e.id));
|
||||
final allocationRows = <_AllocationRow>[];
|
||||
if (widget.receipt != null) {
|
||||
final links = await _receiptService.fetchLinks(widget.receipt!.id);
|
||||
for (final link in links) {
|
||||
final current = totals[link.purchaseEntryId] ?? 0;
|
||||
totals[link.purchaseEntryId] = current - link.allocatedAmount;
|
||||
var entry = entries.firstWhere(
|
||||
(e) => e.id == link.purchaseEntryId,
|
||||
orElse: () => PurchaseEntry(
|
||||
id: link.purchaseEntryId,
|
||||
issueDate: DateTime.now(),
|
||||
status: PurchaseEntryStatus.draft,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
allocationRows.add(_AllocationRow(entry: entry, amount: link.allocatedAmount));
|
||||
}
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_entries = entries;
|
||||
_baseAllocated = totals;
|
||||
_allocations = allocationRows;
|
||||
_isInitializing = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _isInitializing = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('支払フォームの読み込みに失敗しました: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickSupplier() async {
|
||||
final selected = await showModalBottomSheet<Supplier>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (ctx) => FractionallySizedBox(
|
||||
heightFactor: 0.9,
|
||||
child: SupplierPickerModal(
|
||||
onSupplierSelected: (supplier) {
|
||||
Navigator.pop(ctx, supplier);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
if (selected == null) return;
|
||||
setState(() {
|
||||
_supplierId = selected.id;
|
||||
_supplierName = selected.name;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pickDate() async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _paymentDate,
|
||||
firstDate: DateTime(2015),
|
||||
lastDate: DateTime(2100),
|
||||
);
|
||||
if (picked != null) {
|
||||
setState(() => _paymentDate = picked);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _addAllocation() async {
|
||||
if (_entries.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('割当対象となる仕入伝票がありません')));
|
||||
return;
|
||||
}
|
||||
final entry = await showModalBottomSheet<PurchaseEntry>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => _PurchaseEntryPickerSheet(
|
||||
entries: _entries,
|
||||
dateFormat: _dateFormat,
|
||||
currencyFormat: _currencyFormat,
|
||||
getOutstanding: _availableForEntry,
|
||||
),
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (entry == null) return;
|
||||
final maxForEntry = _availableForEntry(entry);
|
||||
if (maxForEntry <= 0) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('選択した仕入伝票には割当余力がありません')));
|
||||
return;
|
||||
}
|
||||
final receiptAmount = _receiptAmount;
|
||||
final remainingReceipt = receiptAmount > 0 ? receiptAmount - _sumAllocations : maxForEntry;
|
||||
final initial = remainingReceipt > 0 ? remainingReceipt.clamp(0, maxForEntry).toInt() : maxForEntry;
|
||||
setState(() {
|
||||
_allocations.add(_AllocationRow(entry: entry, amount: initial));
|
||||
});
|
||||
}
|
||||
|
||||
int get _receiptAmount => int.tryParse(_amountController.text) ?? 0;
|
||||
|
||||
int get _sumAllocations => _allocations.fold<int>(0, (sum, row) => sum + row.amount);
|
||||
|
||||
int _availableForEntry(PurchaseEntry entry, [_AllocationRow? excluding]) {
|
||||
final base = _baseAllocated[entry.id] ?? 0;
|
||||
final others = _allocations.where((row) => row.entry.id == entry.id && row != excluding).fold<int>(0, (sum, row) => sum + row.amount);
|
||||
return entry.amountTaxIncl - base - others;
|
||||
}
|
||||
|
||||
int _maxForRow(_AllocationRow row) {
|
||||
return _availableForEntry(row.entry, row) + row.amount;
|
||||
}
|
||||
|
||||
void _handleAllocationChanged(_AllocationRow row) {
|
||||
final value = row.amount;
|
||||
final max = _maxForRow(row);
|
||||
if (value > max) {
|
||||
row.setAmount(max);
|
||||
} else if (value < 0) {
|
||||
row.setAmount(0);
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _removeAllocation(_AllocationRow row) {
|
||||
setState(() {
|
||||
_allocations.remove(row);
|
||||
row.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (_isSaving) return;
|
||||
final amount = _receiptAmount;
|
||||
if (amount <= 0) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('支払額を入力してください')));
|
||||
return;
|
||||
}
|
||||
final totalAlloc = _sumAllocations;
|
||||
if (totalAlloc > amount) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('割当総額が支払額を超えています')));
|
||||
return;
|
||||
}
|
||||
setState(() => _isSaving = true);
|
||||
try {
|
||||
PurchaseReceipt saved;
|
||||
final allocations = _allocations
|
||||
.where((row) => row.amount > 0)
|
||||
.map((row) => PurchaseReceiptAllocationInput(purchaseEntryId: row.entry.id, amount: row.amount))
|
||||
.toList();
|
||||
if (widget.receipt == null) {
|
||||
saved = await _receiptService.createReceipt(
|
||||
supplierId: _supplierId,
|
||||
paymentDate: _paymentDate,
|
||||
amount: amount,
|
||||
method: _method,
|
||||
notes: _notesController.text.trim().isEmpty ? null : _notesController.text.trim(),
|
||||
allocations: allocations,
|
||||
);
|
||||
} else {
|
||||
final updated = widget.receipt!.copyWith(
|
||||
supplierId: _supplierId,
|
||||
paymentDate: _paymentDate,
|
||||
amount: amount,
|
||||
method: _method,
|
||||
notes: _notesController.text.trim().isEmpty ? null : _notesController.text.trim(),
|
||||
);
|
||||
saved = await _receiptService.updateReceipt(receipt: updated, allocations: allocations);
|
||||
}
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context, saved);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _isSaving = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('保存に失敗しました: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final title = widget.receipt == null ? '支払を登録' : '支払を編集';
|
||||
final receiptAmount = _receiptAmount;
|
||||
final allocSum = _sumAllocations;
|
||||
final remaining = (receiptAmount - allocSum).clamp(-999999999, 999999999).toInt();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: const BackButton(),
|
||||
title: ScreenAppBarTitle(
|
||||
screenId: 'P4',
|
||||
title: title == '支払を登録' ? '支払登録' : '支払編集',
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: _isSaving ? null : _save, child: const Text('保存')),
|
||||
],
|
||||
),
|
||||
body: _isInitializing
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: KeyboardInsetWrapper(
|
||||
safeAreaTop: false,
|
||||
basePadding: const EdgeInsets.all(16),
|
||||
extraBottom: 24,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _amountController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(labelText: '支払額 (円)'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('支払日'),
|
||||
subtitle: Text(_dateFormat.format(_paymentDate)),
|
||||
trailing: TextButton(onPressed: _pickDate, child: const Text('変更')),
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(_supplierName ?? '仕入先を選択'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: _pickSupplier,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _method,
|
||||
decoration: const InputDecoration(labelText: '支払方法'),
|
||||
items: const [
|
||||
DropdownMenuItem(value: '銀行振込', child: Text('銀行振込')),
|
||||
DropdownMenuItem(value: '現金', child: Text('現金')),
|
||||
DropdownMenuItem(value: '振替', child: Text('口座振替')),
|
||||
DropdownMenuItem(value: 'カード', child: Text('カード払い')),
|
||||
DropdownMenuItem(value: 'その他', child: Text('その他')),
|
||||
],
|
||||
onChanged: (val) => setState(() => _method = val),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _notesController,
|
||||
maxLines: 3,
|
||||
decoration: const InputDecoration(labelText: 'メモ (任意)'),
|
||||
),
|
||||
const Divider(height: 32),
|
||||
Row(
|
||||
children: [
|
||||
Text('割当: ${_currencyFormat.format(allocSum)} / ${_currencyFormat.format(receiptAmount)}'),
|
||||
const Spacer(),
|
||||
Text(
|
||||
remaining >= 0 ? '残り: ${_currencyFormat.format(remaining)}' : '超過: ${_currencyFormat.format(remaining.abs())}',
|
||||
style: TextStyle(color: remaining >= 0 ? Colors.black87 : Colors.red),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
for (final row in _allocations)
|
||||
Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(row.entry.subject?.isNotEmpty == true ? row.entry.subject! : '仕入伝票',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 4),
|
||||
Text('${_dateFormat.format(row.entry.issueDate)} / ${_currencyFormat.format(row.entry.amountTaxIncl)}'),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(onPressed: () => _removeAllocation(row), icon: const Icon(Icons.delete_outline)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: row.controller,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: '割当額',
|
||||
helperText: '残余 ${_currencyFormat.format((_maxForRow(row) - row.amount).clamp(0, double.infinity))}',
|
||||
),
|
||||
onChanged: (_) => _handleAllocationChanged(row),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: _addAllocation,
|
||||
icon: const Icon(Icons.playlist_add),
|
||||
label: const Text('仕入伝票を割当'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AllocationRow {
|
||||
_AllocationRow({required this.entry, required int amount})
|
||||
: controller = TextEditingController(text: amount.toString()),
|
||||
_amount = amount;
|
||||
|
||||
final PurchaseEntry entry;
|
||||
final TextEditingController controller;
|
||||
int _amount;
|
||||
|
||||
int get amount => _amount;
|
||||
|
||||
void setAmount(int value) {
|
||||
_amount = value;
|
||||
controller.text = value.toString();
|
||||
}
|
||||
|
||||
void dispose() => controller.dispose();
|
||||
}
|
||||
|
||||
class _PurchaseEntryPickerSheet extends StatelessWidget {
|
||||
const _PurchaseEntryPickerSheet({
|
||||
required this.entries,
|
||||
required this.dateFormat,
|
||||
required this.currencyFormat,
|
||||
required this.getOutstanding,
|
||||
});
|
||||
|
||||
final List<PurchaseEntry> entries;
|
||||
final DateFormat dateFormat;
|
||||
final NumberFormat currencyFormat;
|
||||
final int Function(PurchaseEntry entry) getOutstanding;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: Row(
|
||||
children: const [
|
||||
Text('仕入伝票を選択', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: entries.length,
|
||||
itemBuilder: (context, index) {
|
||||
final entry = entries[index];
|
||||
final outstanding = getOutstanding(entry);
|
||||
return ListTile(
|
||||
title: Text(entry.subject?.isNotEmpty == true ? entry.subject! : '仕入伝票'),
|
||||
subtitle: Text(
|
||||
'${entry.supplierNameSnapshot ?? '仕入先未設定'}\n${dateFormat.format(entry.issueDate)} / ${currencyFormat.format(entry.amountTaxIncl)}',
|
||||
),
|
||||
trailing: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
const Text('残余', style: TextStyle(fontSize: 12, color: Colors.black54)),
|
||||
Text(currencyFormat.format(outstanding),
|
||||
style: TextStyle(color: outstanding > 0 ? Colors.green.shade700 : Colors.redAccent)),
|
||||
],
|
||||
),
|
||||
onTap: () => Navigator.pop(context, entry),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
162
lib/screens/sales_dashboard_screen.dart
Normal file
162
lib/screens/sales_dashboard_screen.dart
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'sales_report_screen.dart';
|
||||
|
||||
class SalesDashboardScreen extends StatelessWidget {
|
||||
const SalesDashboardScreen({super.key});
|
||||
|
||||
void _openAnalytics(BuildContext context) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const SalesReportScreen()),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: const BackButton(),
|
||||
title: const Text('R0:売上ダッシュボード'),
|
||||
backgroundColor: Colors.indigo,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_buildHeroCard(theme),
|
||||
const SizedBox(height: 16),
|
||||
_buildLauncherCard(context, theme),
|
||||
const SizedBox(height: 16),
|
||||
_buildComingSoonCard(theme),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeroCard(ThemeData theme) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [Colors.indigo.shade600, Colors.indigo.shade300],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.indigo.withValues(alpha: 0.25),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'売上管理モジュール',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'ダッシュボードはランチャーとして機能し、分析モジュールや将来のサブ機能へのエントリーポイントをまとめて提供します。',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: Colors.white70),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLauncherCard(BuildContext context, ThemeData theme) {
|
||||
return Card(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.analytics_outlined, color: Colors.indigo.shade600),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'売上分析モジュール',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'年間売上やトップ顧客の内訳を詳細に確認したい場合はこちらからアクセスしてください。',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.check_circle, color: Colors.indigo),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: const [
|
||||
Text('年度切り替え / ドキュメント種別フィルタ'),
|
||||
SizedBox(height: 4),
|
||||
Text('トップ顧客・月次トレンド・ドラフト状況の把握'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton.icon(
|
||||
onPressed: () => _openAnalytics(context),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.indigo,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 18),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
),
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
label: const Text('売上分析モジュールを開く'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildComingSoonCard(ThemeData theme) {
|
||||
return Card(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: const [
|
||||
Icon(Icons.extension, color: Colors.grey),
|
||||
SizedBox(width: 8),
|
||||
Text('近日提供予定のメニュー', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text('・ 売掛残高の自動集計 / 着地予測'),
|
||||
const Text('・ 商品カテゴリ別の利益率ダッシュボード'),
|
||||
const Text('・ 重点顧客へのフォローアップリマインダー'),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'ダッシュボードはランチャーとして、こうした追加モジュールへの入り口を順次揃えていきます。',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1671
lib/screens/sales_entries_screen.dart
Normal file
1671
lib/screens/sales_entries_screen.dart
Normal file
File diff suppressed because it is too large
Load diff
2443
lib/screens/sales_orders_screen.dart
Normal file
2443
lib/screens/sales_orders_screen.dart
Normal file
File diff suppressed because it is too large
Load diff
762
lib/screens/sales_receipts_screen.dart
Normal file
762
lib/screens/sales_receipts_screen.dart
Normal file
|
|
@ -0,0 +1,762 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../models/customer_model.dart';
|
||||
import '../models/sales_entry_models.dart';
|
||||
import '../services/customer_repository.dart';
|
||||
import '../services/sales_entry_service.dart';
|
||||
import '../services/sales_receipt_service.dart';
|
||||
import 'customer_picker_modal.dart';
|
||||
|
||||
class SalesReceiptsScreen extends StatefulWidget {
|
||||
const SalesReceiptsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SalesReceiptsScreen> createState() => _SalesReceiptsScreenState();
|
||||
}
|
||||
|
||||
class _SalesReceiptsScreenState extends State<SalesReceiptsScreen> {
|
||||
final SalesReceiptService _receiptService = SalesReceiptService();
|
||||
final CustomerRepository _customerRepository = CustomerRepository();
|
||||
final NumberFormat _currencyFormat = NumberFormat.currency(locale: 'ja_JP', symbol: '¥');
|
||||
final DateFormat _dateFormat = DateFormat('yyyy/MM/dd');
|
||||
|
||||
bool _isLoading = true;
|
||||
bool _isRefreshing = false;
|
||||
List<SalesReceipt> _receipts = const [];
|
||||
Map<String, int> _receiptAllocations = const {};
|
||||
Map<String, String> _customerNames = const {};
|
||||
DateTime? _startDate;
|
||||
DateTime? _endDate;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadReceipts();
|
||||
}
|
||||
|
||||
Future<void> _loadReceipts() async {
|
||||
if (!_isRefreshing) {
|
||||
setState(() => _isLoading = true);
|
||||
}
|
||||
try {
|
||||
final receipts = await _receiptService.fetchReceipts(startDate: _startDate, endDate: _endDate);
|
||||
final allocationMap = <String, int>{};
|
||||
for (final receipt in receipts) {
|
||||
final links = await _receiptService.fetchLinks(receipt.id);
|
||||
allocationMap[receipt.id] = links.fold<int>(0, (sum, link) => sum + link.allocatedAmount);
|
||||
}
|
||||
final customerIds = receipts.map((r) => r.customerId).whereType<String>().toSet();
|
||||
final customerNames = Map<String, String>.from(_customerNames);
|
||||
for (final id in customerIds) {
|
||||
if (customerNames.containsKey(id)) continue;
|
||||
final customer = await _customerRepository.findById(id);
|
||||
if (customer != null) {
|
||||
customerNames[id] = customer.invoiceName;
|
||||
}
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_receipts = receipts;
|
||||
_receiptAllocations = allocationMap;
|
||||
_customerNames = customerNames;
|
||||
_isLoading = false;
|
||||
_isRefreshing = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_isRefreshing = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('入金データの取得に失敗しました: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleRefresh() async {
|
||||
setState(() => _isRefreshing = true);
|
||||
await _loadReceipts();
|
||||
}
|
||||
|
||||
Future<void> _pickDate({required bool isStart}) async {
|
||||
final initial = isStart ? (_startDate ?? DateTime.now().subtract(const Duration(days: 30))) : (_endDate ?? DateTime.now());
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: initial,
|
||||
firstDate: DateTime(2015),
|
||||
lastDate: DateTime(2100),
|
||||
);
|
||||
if (picked == null) return;
|
||||
setState(() {
|
||||
if (isStart) {
|
||||
_startDate = picked;
|
||||
} else {
|
||||
_endDate = picked;
|
||||
}
|
||||
});
|
||||
_loadReceipts();
|
||||
}
|
||||
|
||||
void _clearFilters() {
|
||||
setState(() {
|
||||
_startDate = null;
|
||||
_endDate = null;
|
||||
});
|
||||
_loadReceipts();
|
||||
}
|
||||
|
||||
Future<void> _openEditor({SalesReceipt? receipt}) async {
|
||||
final updated = await Navigator.of(context).push<SalesReceipt>(
|
||||
MaterialPageRoute(builder: (_) => SalesReceiptEditorPage(receipt: receipt)),
|
||||
);
|
||||
if (updated != null) {
|
||||
await _loadReceipts();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('入金データを保存しました')));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(SalesReceipt receipt) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('入金を削除'),
|
||||
content: Text('${_dateFormat.format(receipt.paymentDate)}の¥${_currencyFormat.format(receipt.amount).replaceAll('¥¥', '')}を削除しますか?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('キャンセル')),
|
||||
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('削除')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
try {
|
||||
await _receiptService.deleteReceipt(receipt.id);
|
||||
if (!mounted) return;
|
||||
await _loadReceipts();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('入金を削除しました')));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('削除に失敗しました: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
String _customerLabel(SalesReceipt receipt) {
|
||||
if (receipt.customerId == null) {
|
||||
return '取引先未設定';
|
||||
}
|
||||
return _customerNames[receipt.customerId] ?? '顧客読み込み中';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final filterLabel = [
|
||||
if (_startDate != null) '開始: ${_dateFormat.format(_startDate!)}',
|
||||
if (_endDate != null) '終了: ${_dateFormat.format(_endDate!)}',
|
||||
].join(' / ');
|
||||
|
||||
final body = _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: RefreshIndicator(
|
||||
onRefresh: _handleRefresh,
|
||||
child: _receipts.isEmpty
|
||||
? ListView(
|
||||
children: const [
|
||||
SizedBox(height: 140),
|
||||
Icon(Icons.account_balance_wallet_outlined, size: 64, color: Colors.grey),
|
||||
SizedBox(height: 12),
|
||||
Center(child: Text('入金データがありません。右下のボタンから登録してください。')),
|
||||
],
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 120),
|
||||
itemCount: _receipts.length,
|
||||
itemBuilder: (context, index) => _buildReceiptCard(_receipts[index]),
|
||||
),
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: const BackButton(),
|
||||
title: const Text('U3:入金管理'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: '開始日を選択',
|
||||
icon: const Icon(Icons.calendar_today),
|
||||
onPressed: () => _pickDate(isStart: true),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '終了日を選択',
|
||||
icon: const Icon(Icons.event),
|
||||
onPressed: () => _pickDate(isStart: false),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'フィルターをクリア',
|
||||
icon: const Icon(Icons.filter_alt_off),
|
||||
onPressed: (_startDate == null && _endDate == null) ? null : _clearFilters,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
bottom: filterLabel.isEmpty
|
||||
? null
|
||||
: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(32),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(filterLabel, style: const TextStyle(color: Colors.white70)),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: body,
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => _openEditor(),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('入金を登録'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReceiptCard(SalesReceipt receipt) {
|
||||
final allocated = _receiptAllocations[receipt.id] ?? 0;
|
||||
final allocationRatio = receipt.amount == 0 ? 0.0 : allocated / receipt.amount;
|
||||
final statusColor = allocationRatio >= 0.999
|
||||
? Colors.green
|
||||
: allocationRatio <= 0
|
||||
? Colors.orange
|
||||
: Colors.blue;
|
||||
final customer = _customerLabel(receipt);
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: ListTile(
|
||||
onTap: () => _openEditor(receipt: receipt),
|
||||
title: Text(
|
||||
_currencyFormat.format(receipt.amount),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
|
||||
),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(customer),
|
||||
const SizedBox(height: 4),
|
||||
Text('割当: ${_currencyFormat.format(allocated)} / ${_currencyFormat.format(receipt.amount)}'),
|
||||
if (receipt.notes?.isNotEmpty == true) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(receipt.notes!, style: const TextStyle(fontSize: 12, color: Colors.black87)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
trailing: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(_dateFormat.format(receipt.paymentDate)),
|
||||
const SizedBox(height: 4),
|
||||
Text(receipt.method ?? '未設定', style: const TextStyle(fontSize: 12, color: Colors.black54)),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
decoration: BoxDecoration(color: statusColor.withAlpha(32), borderRadius: BorderRadius.circular(12)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
child: Text(
|
||||
allocationRatio >= 0.999
|
||||
? '全額割当済'
|
||||
: allocationRatio <= 0
|
||||
? '未割当'
|
||||
: '一部割当',
|
||||
style: TextStyle(color: statusColor, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
isThreeLine: true,
|
||||
contentPadding: const EdgeInsets.all(16),
|
||||
tileColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
selectedColor: Theme.of(context).colorScheme.primary,
|
||||
selectedTileColor: Theme.of(context).colorScheme.primaryContainer,
|
||||
onLongPress: () => _confirmDelete(receipt),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SalesReceiptEditorPage extends StatefulWidget {
|
||||
const SalesReceiptEditorPage({super.key, this.receipt});
|
||||
|
||||
final SalesReceipt? receipt;
|
||||
|
||||
@override
|
||||
State<SalesReceiptEditorPage> createState() => _SalesReceiptEditorPageState();
|
||||
}
|
||||
|
||||
class _SalesReceiptEditorPageState extends State<SalesReceiptEditorPage> {
|
||||
final SalesReceiptService _receiptService = SalesReceiptService();
|
||||
final SalesEntryService _entryService = SalesEntryService();
|
||||
final CustomerRepository _customerRepository = CustomerRepository();
|
||||
final TextEditingController _amountController = TextEditingController();
|
||||
final TextEditingController _notesController = TextEditingController();
|
||||
final DateFormat _dateFormat = DateFormat('yyyy/MM/dd');
|
||||
final NumberFormat _currencyFormat = NumberFormat.currency(locale: 'ja_JP', symbol: '¥');
|
||||
|
||||
DateTime _paymentDate = DateTime.now();
|
||||
String? _customerId;
|
||||
String? _customerName;
|
||||
String? _method = '銀行振込';
|
||||
bool _isSaving = false;
|
||||
bool _isInitializing = true;
|
||||
|
||||
List<_AllocationRow> _allocations = [];
|
||||
List<SalesEntry> _entries = [];
|
||||
Map<String, int> _baseAllocated = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final receipt = widget.receipt;
|
||||
if (receipt != null) {
|
||||
_paymentDate = receipt.paymentDate;
|
||||
_amountController.text = receipt.amount.toString();
|
||||
_notesController.text = receipt.notes ?? '';
|
||||
_method = receipt.method ?? '銀行振込';
|
||||
_customerId = receipt.customerId;
|
||||
if (_customerId != null) {
|
||||
_loadCustomerName(_customerId!);
|
||||
}
|
||||
} else {
|
||||
_amountController.text = '';
|
||||
}
|
||||
_amountController.addListener(() => setState(() {}));
|
||||
_loadData();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_amountController.dispose();
|
||||
_notesController.dispose();
|
||||
for (final row in _allocations) {
|
||||
row.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadCustomerName(String customerId) async {
|
||||
final customer = await _customerRepository.findById(customerId);
|
||||
if (!mounted) return;
|
||||
setState(() => _customerName = customer?.invoiceName ?? '');
|
||||
}
|
||||
|
||||
Future<void> _loadData() async {
|
||||
try {
|
||||
final entries = await _entryService.fetchEntries();
|
||||
final totals = await _receiptService.fetchAllocatedTotals(entries.map((e) => e.id));
|
||||
final allocationRows = <_AllocationRow>[];
|
||||
if (widget.receipt != null) {
|
||||
final links = await _receiptService.fetchLinks(widget.receipt!.id);
|
||||
for (final link in links) {
|
||||
final current = totals[link.salesEntryId] ?? 0;
|
||||
totals[link.salesEntryId] = current - link.allocatedAmount;
|
||||
var entry = _findEntryById(link.salesEntryId, entries);
|
||||
entry ??= await _entryService.findById(link.salesEntryId);
|
||||
if (entry != null && entries.every((e) => e.id != entry!.id)) {
|
||||
entries.add(entry);
|
||||
}
|
||||
if (entry != null) {
|
||||
allocationRows.add(_AllocationRow(entry: entry, amount: link.allocatedAmount));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_entries = entries;
|
||||
_baseAllocated = totals;
|
||||
_allocations = allocationRows;
|
||||
_isInitializing = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _isInitializing = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('入金フォームの読み込みに失敗しました: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
SalesEntry? _findEntryById(String id, List<SalesEntry> entries) {
|
||||
for (final entry in entries) {
|
||||
if (entry.id == id) return entry;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _pickCustomer() async {
|
||||
final selected = await showModalBottomSheet<Customer?>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (ctx) => CustomerPickerModal(
|
||||
onCustomerSelected: (customer) {
|
||||
Navigator.pop(ctx, customer);
|
||||
},
|
||||
),
|
||||
);
|
||||
if (selected == null) return;
|
||||
setState(() {
|
||||
_customerId = selected.id;
|
||||
_customerName = selected.invoiceName;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pickDate() async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _paymentDate,
|
||||
firstDate: DateTime(2015),
|
||||
lastDate: DateTime(2100),
|
||||
);
|
||||
if (picked != null) {
|
||||
setState(() => _paymentDate = picked);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _addAllocation() async {
|
||||
if (_entries.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('割当対象となる売上伝票がありません')));
|
||||
return;
|
||||
}
|
||||
final entry = await showModalBottomSheet<SalesEntry>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => _SalesEntryPickerSheet(
|
||||
entries: _entries,
|
||||
dateFormat: _dateFormat,
|
||||
currencyFormat: _currencyFormat,
|
||||
getOutstanding: _availableForEntry,
|
||||
),
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (entry == null) return;
|
||||
final maxForEntry = _availableForEntry(entry);
|
||||
if (maxForEntry <= 0) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('選択した売上伝票には割当余力がありません')));
|
||||
return;
|
||||
}
|
||||
final receiptAmount = _receiptAmount;
|
||||
final remainingReceipt = receiptAmount > 0 ? receiptAmount - _sumAllocations : maxForEntry;
|
||||
final initial = remainingReceipt > 0 ? remainingReceipt.clamp(0, maxForEntry).toInt() : maxForEntry;
|
||||
setState(() {
|
||||
_allocations.add(_AllocationRow(entry: entry, amount: initial));
|
||||
});
|
||||
}
|
||||
|
||||
int get _receiptAmount => int.tryParse(_amountController.text) ?? 0;
|
||||
|
||||
int get _sumAllocations => _allocations.fold<int>(0, (sum, row) => sum + row.amount);
|
||||
|
||||
int _availableForEntry(SalesEntry entry, [_AllocationRow? excluding]) {
|
||||
final base = _baseAllocated[entry.id] ?? 0;
|
||||
final others = _allocations.where((row) => row.entry.id == entry.id && row != excluding).fold<int>(0, (sum, row) => sum + row.amount);
|
||||
return entry.amountTaxIncl - base - others;
|
||||
}
|
||||
|
||||
int _maxForRow(_AllocationRow row) {
|
||||
return _availableForEntry(row.entry, row) + row.amount;
|
||||
}
|
||||
|
||||
void _handleAllocationChanged(_AllocationRow row) {
|
||||
final value = row.amount;
|
||||
final max = _maxForRow(row);
|
||||
if (value > max) {
|
||||
row.setAmount(max);
|
||||
} else if (value < 0) {
|
||||
row.setAmount(0);
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _removeAllocation(_AllocationRow row) {
|
||||
setState(() {
|
||||
_allocations.remove(row);
|
||||
row.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (_isSaving) return;
|
||||
final amount = _receiptAmount;
|
||||
if (amount <= 0) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('入金額を入力してください')));
|
||||
return;
|
||||
}
|
||||
final totalAlloc = _sumAllocations;
|
||||
if (totalAlloc > amount) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('割当総額が入金額を超えています')));
|
||||
return;
|
||||
}
|
||||
setState(() => _isSaving = true);
|
||||
try {
|
||||
SalesReceipt saved;
|
||||
final allocations = _allocations
|
||||
.where((row) => row.amount > 0)
|
||||
.map((row) => SalesReceiptAllocationInput(salesEntryId: row.entry.id, amount: row.amount))
|
||||
.toList();
|
||||
if (widget.receipt == null) {
|
||||
saved = await _receiptService.createReceipt(
|
||||
customerId: _customerId,
|
||||
paymentDate: _paymentDate,
|
||||
amount: amount,
|
||||
method: _method,
|
||||
notes: _notesController.text.trim().isEmpty ? null : _notesController.text.trim(),
|
||||
allocations: allocations,
|
||||
);
|
||||
} else {
|
||||
final updated = widget.receipt!.copyWith(
|
||||
customerId: _customerId,
|
||||
paymentDate: _paymentDate,
|
||||
amount: amount,
|
||||
method: _method,
|
||||
notes: _notesController.text.trim().isEmpty ? null : _notesController.text.trim(),
|
||||
);
|
||||
saved = await _receiptService.updateReceipt(receipt: updated, allocations: allocations);
|
||||
}
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context, saved);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _isSaving = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('保存に失敗しました: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final title = widget.receipt == null ? '入金を登録' : '入金を編集';
|
||||
final receiptAmount = _receiptAmount;
|
||||
final allocSum = _sumAllocations;
|
||||
final remaining = (receiptAmount - allocSum).clamp(-999999999, 999999999).toInt();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: const BackButton(),
|
||||
title: Text(title == '入金を登録' ? 'U4:入金登録' : 'U4:入金編集'),
|
||||
actions: [
|
||||
TextButton(onPressed: _isSaving ? null : _save, child: const Text('保存')),
|
||||
],
|
||||
),
|
||||
body: _isInitializing
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: SingleChildScrollView(
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom + 24),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _amountController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(labelText: '入金額 (円)'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('入金日'),
|
||||
subtitle: Text(_dateFormat.format(_paymentDate)),
|
||||
trailing: TextButton(onPressed: _pickDate, child: const Text('変更')),
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(_customerName ?? '取引先を選択'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: _pickCustomer,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _method,
|
||||
decoration: const InputDecoration(labelText: '入金方法'),
|
||||
items: const [
|
||||
DropdownMenuItem(value: '銀行振込', child: Text('銀行振込')),
|
||||
DropdownMenuItem(value: '現金', child: Text('現金')),
|
||||
DropdownMenuItem(value: '振替', child: Text('口座振替')),
|
||||
DropdownMenuItem(value: 'カード', child: Text('カード決済')),
|
||||
DropdownMenuItem(value: 'その他', child: Text('その他')),
|
||||
],
|
||||
onChanged: (val) => setState(() => _method = val),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _notesController,
|
||||
maxLines: 3,
|
||||
decoration: const InputDecoration(labelText: 'メモ (任意)'),
|
||||
),
|
||||
const Divider(height: 32),
|
||||
Row(
|
||||
children: [
|
||||
Text('割当: ${_currencyFormat.format(allocSum)} / ${_currencyFormat.format(receiptAmount)}'),
|
||||
const Spacer(),
|
||||
Text(
|
||||
remaining >= 0 ? '残り: ${_currencyFormat.format(remaining)}' : '超過: ${_currencyFormat.format(remaining.abs())}',
|
||||
style: TextStyle(color: remaining >= 0 ? Colors.black87 : Colors.red),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
for (final row in _allocations)
|
||||
Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(row.entry.subject?.isNotEmpty == true ? row.entry.subject! : '売上伝票',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 4),
|
||||
Text('${_dateFormat.format(row.entry.issueDate)} / ${_currencyFormat.format(row.entry.amountTaxIncl)}'),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(onPressed: () => _removeAllocation(row), icon: const Icon(Icons.delete_outline)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: row.controller,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: '割当額',
|
||||
helperText: '残余 ${_currencyFormat.format((_maxForRow(row) - row.amount).clamp(0, double.infinity))}',
|
||||
),
|
||||
onChanged: (_) => _handleAllocationChanged(row),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: _addAllocation,
|
||||
icon: const Icon(Icons.playlist_add),
|
||||
label: const Text('売上伝票を割当'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AllocationRow {
|
||||
_AllocationRow({required this.entry, int amount = 0}) : controller = TextEditingController(text: amount > 0 ? amount.toString() : '') {
|
||||
_amount = amount;
|
||||
}
|
||||
|
||||
final SalesEntry entry;
|
||||
final TextEditingController controller;
|
||||
int _amount = 0;
|
||||
|
||||
int get amount {
|
||||
final parsed = int.tryParse(controller.text.replaceAll(',', ''));
|
||||
_amount = parsed ?? 0;
|
||||
return _amount;
|
||||
}
|
||||
|
||||
void setAmount(int value) {
|
||||
_amount = value;
|
||||
controller
|
||||
..text = value.toString()
|
||||
..selection = TextSelection.collapsed(offset: controller.text.length);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
controller.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class _SalesEntryPickerSheet extends StatefulWidget {
|
||||
const _SalesEntryPickerSheet({required this.entries, required this.dateFormat, required this.currencyFormat, required this.getOutstanding});
|
||||
|
||||
final List<SalesEntry> entries;
|
||||
final DateFormat dateFormat;
|
||||
final NumberFormat currencyFormat;
|
||||
final int Function(SalesEntry entry) getOutstanding;
|
||||
|
||||
@override
|
||||
State<_SalesEntryPickerSheet> createState() => _SalesEntryPickerSheetState();
|
||||
}
|
||||
|
||||
class _SalesEntryPickerSheetState extends State<_SalesEntryPickerSheet> {
|
||||
final TextEditingController _keywordController = TextEditingController();
|
||||
String _keyword = '';
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_keywordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final filtered = widget.entries.where((entry) {
|
||||
if (_keyword.isEmpty) return true;
|
||||
final subject = entry.subject ?? '';
|
||||
final customer = entry.customerNameSnapshot ?? '';
|
||||
return subject.contains(_keyword) || customer.contains(_keyword);
|
||||
}).toList();
|
||||
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: 0.85,
|
||||
expand: false,
|
||||
builder: (_, controller) => Material(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(child: Text('売上伝票を選択', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold))),
|
||||
IconButton(onPressed: () => Navigator.pop(context), icon: const Icon(Icons.close)),
|
||||
],
|
||||
),
|
||||
TextField(
|
||||
controller: _keywordController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'キーワード (件名/顧客)',
|
||||
suffixIcon: IconButton(icon: const Icon(Icons.search), onPressed: () => setState(() => _keyword = _keywordController.text.trim())),
|
||||
),
|
||||
onSubmitted: (_) => setState(() => _keyword = _keywordController.text.trim()),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: filtered.isEmpty
|
||||
? const Center(child: Text('該当する売上伝票がありません'))
|
||||
: ListView.builder(
|
||||
controller: controller,
|
||||
itemCount: filtered.length,
|
||||
itemBuilder: (context, index) {
|
||||
final entry = filtered[index];
|
||||
final outstanding = widget.getOutstanding(entry);
|
||||
final disabled = outstanding <= 0;
|
||||
return ListTile(
|
||||
enabled: !disabled,
|
||||
title: Text(entry.subject?.isNotEmpty == true ? entry.subject! : '売上伝票'),
|
||||
subtitle: Text(
|
||||
'${entry.customerNameSnapshot ?? '取引先未設定'}\n${widget.dateFormat.format(entry.issueDate)} / ${widget.currencyFormat.format(entry.amountTaxIncl)}\n残: ${widget.currencyFormat.format(outstanding)}',
|
||||
),
|
||||
onTap: disabled ? null : () => Navigator.pop(context, entry),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,11 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../models/invoice_models.dart';
|
||||
import '../models/sales_summary.dart';
|
||||
import '../services/invoice_repository.dart';
|
||||
import '../widgets/analytics/analytics_summary_card.dart';
|
||||
import '../widgets/analytics/empty_state_card.dart';
|
||||
|
||||
class SalesReportScreen extends StatefulWidget {
|
||||
const SalesReportScreen({super.key});
|
||||
|
|
@ -12,8 +17,9 @@ class SalesReportScreen extends StatefulWidget {
|
|||
class _SalesReportScreenState extends State<SalesReportScreen> {
|
||||
final _invoiceRepo = InvoiceRepository();
|
||||
int _targetYear = DateTime.now().year;
|
||||
Map<String, int> _monthlySales = {};
|
||||
int _yearlyTotal = 0;
|
||||
DocumentType? _selectedType;
|
||||
bool _includeDrafts = false;
|
||||
SalesSummary? _summary;
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
|
|
@ -24,44 +30,60 @@ class _SalesReportScreenState extends State<SalesReportScreen> {
|
|||
|
||||
Future<void> _loadData() async {
|
||||
setState(() => _isLoading = true);
|
||||
final monthly = await _invoiceRepo.getMonthlySales(_targetYear);
|
||||
final yearly = await _invoiceRepo.getYearlyTotal(_targetYear);
|
||||
final summary = await _invoiceRepo.fetchSalesSummary(
|
||||
year: _targetYear,
|
||||
documentType: _selectedType,
|
||||
includeDrafts: _includeDrafts,
|
||||
topCustomerLimit: 5,
|
||||
);
|
||||
setState(() {
|
||||
_monthlySales = monthly;
|
||||
_yearlyTotal = yearly;
|
||||
_summary = summary;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fmt = NumberFormat("#,###");
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("売上・資金管理レポート"),
|
||||
leading: const BackButton(),
|
||||
title: const Text("R1:売上・資金レポート"),
|
||||
backgroundColor: Colors.indigo,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: Column(
|
||||
children: [
|
||||
_buildYearSelector(),
|
||||
_buildYearlySummary(fmt),
|
||||
const Divider(height: 1),
|
||||
Expanded(child: _buildMonthlyList(fmt)),
|
||||
],
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: _loadData,
|
||||
child: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _summary == null
|
||||
? const Center(child: Text('データを取得できませんでした'))
|
||||
: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_buildYearSelector(),
|
||||
const SizedBox(height: 12),
|
||||
_buildFilterRow(),
|
||||
const SizedBox(height: 16),
|
||||
_buildSummaryCards(),
|
||||
const SizedBox(height: 16),
|
||||
_buildTopCustomers(),
|
||||
const SizedBox(height: 16),
|
||||
_buildMonthlyList(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildYearSelector() {
|
||||
return Container(
|
||||
color: Colors.indigo.shade50,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.indigo.shade50,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chevron_left),
|
||||
|
|
@ -86,51 +108,188 @@ class _SalesReportScreenState extends State<SalesReportScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildYearlySummary(NumberFormat fmt) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.indigo.shade900,
|
||||
Widget _buildFilterRow() {
|
||||
final chips = <Widget>[];
|
||||
chips.add(_buildFilterChip(label: '全て', isActive: _selectedType == null, onTap: () {
|
||||
setState(() => _selectedType = null);
|
||||
_loadData();
|
||||
}));
|
||||
for (final type in DocumentType.values) {
|
||||
chips.add(_buildFilterChip(label: type.displayName, isActive: _selectedType == type, onTap: () {
|
||||
setState(() => _selectedType = type);
|
||||
_loadData();
|
||||
}));
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: chips,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
const Text('下書きを含める'),
|
||||
Switch(
|
||||
value: _includeDrafts,
|
||||
onChanged: (value) {
|
||||
setState(() => _includeDrafts = value);
|
||||
_loadData();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSummaryCards() {
|
||||
final summary = _summary!;
|
||||
final fmt = NumberFormat('#,###');
|
||||
final cards = [
|
||||
AnalyticsSummaryCard(
|
||||
title: '年間売上合計',
|
||||
value: '¥${fmt.format(summary.yearlyTotal)}',
|
||||
subtitle: summary.documentType == null ? '全ドキュメント種別' : summary.documentType!.displayName,
|
||||
icon: Icons.ssid_chart,
|
||||
color: Colors.indigo,
|
||||
),
|
||||
AnalyticsSummaryCard(
|
||||
title: '最高月',
|
||||
value: summary.bestMonth == 0 ? '-' : '${summary.bestMonth}月',
|
||||
subtitle: summary.bestMonthTotal > 0 ? '¥${fmt.format(summary.bestMonthTotal)}' : 'データなし',
|
||||
icon: Icons.emoji_events,
|
||||
color: Colors.orange,
|
||||
),
|
||||
AnalyticsSummaryCard(
|
||||
title: '平均月額',
|
||||
value: '¥${fmt.format(summary.averageMonthly.round())}',
|
||||
subtitle: '12ヶ月換算',
|
||||
icon: Icons.stacked_line_chart,
|
||||
color: Colors.teal,
|
||||
),
|
||||
];
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
cards[0],
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: cards[1]),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: cards[2]),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTopCustomers() {
|
||||
final summary = _summary!;
|
||||
if (summary.customerStats.isEmpty) {
|
||||
return const EmptyStateCard(message: '確定済みの売上データがありません', icon: Icons.person_off);
|
||||
}
|
||||
|
||||
final total = summary.customerStats.fold<int>(0, (sum, stat) => sum + stat.totalAmount);
|
||||
final fmt = NumberFormat('#,###');
|
||||
return Card(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('トップ顧客', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
const SizedBox(height: 12),
|
||||
...summary.customerStats.map((stat) {
|
||||
final ratio = total == 0 ? 0.0 : stat.totalAmount / total;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(child: Text(stat.customerName, style: const TextStyle(fontWeight: FontWeight.w600))),
|
||||
Text('¥${fmt.format(stat.totalAmount)}'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: LinearProgressIndicator(
|
||||
value: ratio,
|
||||
minHeight: 8,
|
||||
color: Colors.indigo,
|
||||
backgroundColor: Colors.indigo.withValues(alpha: 0.15),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMonthlyList() {
|
||||
final summary = _summary!;
|
||||
final fmt = NumberFormat('#,###');
|
||||
final months = List<int>.generate(12, (index) => index + 1);
|
||||
if (summary.monthlyTotals.values.every((value) => value == 0)) {
|
||||
return const EmptyStateCard(message: 'この年度の売上データがありません');
|
||||
}
|
||||
return Card(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text("年間売上合計 (請求確定分)", style: TextStyle(color: Colors.white70)),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
"¥${fmt.format(_yearlyTotal)}",
|
||||
style: const TextStyle(color: Colors.white, fontSize: 32, fontWeight: FontWeight.bold),
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(20, 20, 20, 0),
|
||||
child: Text('月別サマリー', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
),
|
||||
const Divider(height: 24),
|
||||
...months.map((month) {
|
||||
final amount = summary.monthlyTotals[month] ?? 0;
|
||||
final share = summary.yearlyTotal == 0 ? 0.0 : amount / summary.yearlyTotal;
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: Colors.indigo.withValues(alpha: 0.1),
|
||||
foregroundColor: Colors.indigo,
|
||||
child: Text(month.toString()),
|
||||
),
|
||||
title: Text('$month月の売上'),
|
||||
subtitle: amount > 0 ? Text('シェア ${(share * 100).toStringAsFixed(1)}%') : const Text('データなし'),
|
||||
trailing: Text(
|
||||
'¥${fmt.format(amount)}',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
color: amount > 0 ? Colors.black87 : Colors.grey,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMonthlyList(NumberFormat fmt) {
|
||||
return ListView.builder(
|
||||
itemCount: 12,
|
||||
itemBuilder: (context, index) {
|
||||
final month = (index + 1).toString().padLeft(2, '0');
|
||||
final amount = _monthlySales[month] ?? 0;
|
||||
final percentage = _yearlyTotal > 0 ? (amount / _yearlyTotal * 100).toStringAsFixed(1) : "0.0";
|
||||
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: Colors.blueGrey.shade100,
|
||||
child: Text("${index + 1}", style: const TextStyle(color: Colors.indigo)),
|
||||
),
|
||||
title: Text("${index + 1}月の売上"),
|
||||
subtitle: amount > 0 ? Text("シェア: $percentage%") : null,
|
||||
trailing: Text(
|
||||
"¥${fmt.format(amount)}",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
color: amount > 0 ? Colors.black87 : Colors.grey,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
Widget _buildFilterChip({required String label, required bool isActive, required VoidCallback onTap}) {
|
||||
return ChoiceChip(
|
||||
label: Text(label),
|
||||
selected: isActive,
|
||||
onSelected: (_) => onTap(),
|
||||
selectedColor: Colors.indigo,
|
||||
labelStyle: TextStyle(color: isActive ? Colors.white : Colors.black87),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
270
lib/screens/staff_master_screen.dart
Normal file
270
lib/screens/staff_master_screen.dart
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/department_model.dart';
|
||||
import '../models/staff_model.dart';
|
||||
import '../services/department_repository.dart';
|
||||
import '../services/staff_repository.dart';
|
||||
|
||||
class StaffMasterScreen extends StatefulWidget {
|
||||
const StaffMasterScreen({super.key});
|
||||
|
||||
@override
|
||||
State<StaffMasterScreen> createState() => _StaffMasterScreenState();
|
||||
}
|
||||
|
||||
class _StaffMasterScreenState extends State<StaffMasterScreen> {
|
||||
final StaffRepository _staffRepository = StaffRepository();
|
||||
final DepartmentRepository _departmentRepository = DepartmentRepository();
|
||||
final Uuid _uuid = const Uuid();
|
||||
|
||||
bool _isLoading = true;
|
||||
bool _includeInactive = false;
|
||||
List<StaffMember> _staff = const [];
|
||||
List<Department> _departments = const [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadData();
|
||||
}
|
||||
|
||||
Future<void> _loadData() async {
|
||||
setState(() => _isLoading = true);
|
||||
final results = await Future.wait([
|
||||
_staffRepository.fetchStaff(includeInactive: _includeInactive),
|
||||
_departmentRepository.fetchDepartments(includeInactive: true),
|
||||
]);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_staff = results.first as List<StaffMember>;
|
||||
_departments = results.last as List<Department>;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _openForm({StaffMember? staff}) async {
|
||||
final result = await showDialog<StaffMember>(
|
||||
context: context,
|
||||
builder: (ctx) => _StaffFormDialog(
|
||||
staff: staff,
|
||||
departments: _departments,
|
||||
onSubmit: (member) => Navigator.of(ctx).pop(member),
|
||||
),
|
||||
);
|
||||
if (result == null) return;
|
||||
final saving = result.copyWith(id: result.id.isEmpty ? _uuid.v4() : result.id, updatedAt: DateTime.now());
|
||||
await _staffRepository.saveStaff(saving);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('担当者を保存しました')));
|
||||
_loadData();
|
||||
}
|
||||
|
||||
Future<void> _deleteStaff(StaffMember staff) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('担当者を削除'),
|
||||
content: Text('${staff.name} を削除しますか?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('キャンセル')),
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('削除', style: TextStyle(color: Colors.red))),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
await _staffRepository.deleteStaff(staff.id);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('担当者を削除しました')));
|
||||
_loadData();
|
||||
}
|
||||
|
||||
String _departmentName(String? departmentId) {
|
||||
if (departmentId == null) return '部門未設定';
|
||||
return _departments.firstWhere(
|
||||
(d) => d.id == departmentId,
|
||||
orElse: () => Department(id: departmentId, name: '不明部門', updatedAt: DateTime.now()),
|
||||
).name;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: const BackButton(),
|
||||
title: const Text('M6:担当者マスター'),
|
||||
actions: [
|
||||
SwitchListTile.adaptive(
|
||||
value: _includeInactive,
|
||||
onChanged: (value) {
|
||||
setState(() => _includeInactive = value);
|
||||
_loadData();
|
||||
},
|
||||
title: const Text('退職者も表示'),
|
||||
contentPadding: const EdgeInsets.only(right: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => _openForm(),
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _staff.isEmpty
|
||||
? const _EmptyState(message: '担当者が登録されていません')
|
||||
: RefreshIndicator(
|
||||
onRefresh: _loadData,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: _staff.length,
|
||||
itemBuilder: (context, index) {
|
||||
final staff = _staff[index];
|
||||
final subtitleLines = [
|
||||
_departmentName(staff.departmentId),
|
||||
if (staff.email?.isNotEmpty == true) 'メール: ${staff.email}',
|
||||
if (staff.tel?.isNotEmpty == true) 'TEL: ${staff.tel}',
|
||||
if (staff.role?.isNotEmpty == true) '役割: ${staff.role}',
|
||||
if (staff.permissionLevel?.isNotEmpty == true) '権限: ${staff.permissionLevel}',
|
||||
staff.isActive ? '稼働中' : '退職/無効',
|
||||
];
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
child: ListTile(
|
||||
title: Text(staff.name, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
subtitle: Text(subtitleLines.where((line) => line.isNotEmpty).join('\n')),
|
||||
trailing: PopupMenuButton<String>(
|
||||
onSelected: (value) {
|
||||
switch (value) {
|
||||
case 'edit':
|
||||
_openForm(staff: staff);
|
||||
break;
|
||||
case 'delete':
|
||||
_deleteStaff(staff);
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => const [
|
||||
PopupMenuItem(value: 'edit', child: Text('編集')),
|
||||
PopupMenuItem(value: 'delete', child: Text('削除')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StaffFormDialog extends StatefulWidget {
|
||||
const _StaffFormDialog({required this.onSubmit, required this.departments, this.staff});
|
||||
|
||||
final StaffMember? staff;
|
||||
final List<Department> departments;
|
||||
final ValueChanged<StaffMember> onSubmit;
|
||||
|
||||
@override
|
||||
State<_StaffFormDialog> createState() => _StaffFormDialogState();
|
||||
}
|
||||
|
||||
class _StaffFormDialogState extends State<_StaffFormDialog> {
|
||||
late final TextEditingController _nameController;
|
||||
late final TextEditingController _emailController;
|
||||
late final TextEditingController _telController;
|
||||
late final TextEditingController _roleController;
|
||||
late final TextEditingController _permissionController;
|
||||
String? _departmentId;
|
||||
bool _isActive = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final staff = widget.staff;
|
||||
_nameController = TextEditingController(text: staff?.name ?? '');
|
||||
_emailController = TextEditingController(text: staff?.email ?? '');
|
||||
_telController = TextEditingController(text: staff?.tel ?? '');
|
||||
_roleController = TextEditingController(text: staff?.role ?? '');
|
||||
_permissionController = TextEditingController(text: staff?.permissionLevel ?? '');
|
||||
_departmentId = staff?.departmentId;
|
||||
_isActive = staff?.isActive ?? true;
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
if (_nameController.text.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('氏名は必須です')));
|
||||
return;
|
||||
}
|
||||
widget.onSubmit(
|
||||
StaffMember(
|
||||
id: widget.staff?.id ?? '',
|
||||
name: _nameController.text.trim(),
|
||||
email: _emailController.text.trim().isEmpty ? null : _emailController.text.trim(),
|
||||
tel: _telController.text.trim().isEmpty ? null : _telController.text.trim(),
|
||||
role: _roleController.text.trim().isEmpty ? null : _roleController.text.trim(),
|
||||
departmentId: _departmentId,
|
||||
permissionLevel: _permissionController.text.trim().isEmpty ? null : _permissionController.text.trim(),
|
||||
isActive: _isActive,
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(widget.staff == null ? '担当者を追加' : '担当者を編集'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(controller: _nameController, decoration: const InputDecoration(labelText: '氏名 *')),
|
||||
TextField(controller: _emailController, decoration: const InputDecoration(labelText: 'メール'), keyboardType: TextInputType.emailAddress),
|
||||
TextField(controller: _telController, decoration: const InputDecoration(labelText: '電話番号'), keyboardType: TextInputType.phone),
|
||||
DropdownButtonFormField<String?>(
|
||||
initialValue: _departmentId,
|
||||
decoration: const InputDecoration(labelText: '所属部門'),
|
||||
items: [
|
||||
const DropdownMenuItem<String?>(value: null, child: Text('未設定')),
|
||||
...widget.departments.map((dept) => DropdownMenuItem<String?>(value: dept.id, child: Text(dept.name))),
|
||||
],
|
||||
onChanged: (value) => setState(() => _departmentId = value),
|
||||
),
|
||||
TextField(controller: _roleController, decoration: const InputDecoration(labelText: '役割/職位')),
|
||||
TextField(controller: _permissionController, decoration: const InputDecoration(labelText: '権限レベル')),
|
||||
SwitchListTile(
|
||||
title: const Text('稼働中'),
|
||||
value: _isActive,
|
||||
onChanged: (value) => setState(() => _isActive = value),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')),
|
||||
FilledButton(onPressed: _submit, child: const Text('保存')),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyState extends StatelessWidget {
|
||||
const _EmptyState({required this.message});
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.person_off, size: 64, color: Colors.grey),
|
||||
const SizedBox(height: 16),
|
||||
Text(message),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
268
lib/screens/supplier_master_screen.dart
Normal file
268
lib/screens/supplier_master_screen.dart
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/supplier_model.dart';
|
||||
import '../services/supplier_repository.dart';
|
||||
|
||||
class SupplierMasterScreen extends StatefulWidget {
|
||||
const SupplierMasterScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SupplierMasterScreen> createState() => _SupplierMasterScreenState();
|
||||
}
|
||||
|
||||
class _SupplierMasterScreenState extends State<SupplierMasterScreen> {
|
||||
final SupplierRepository _repository = SupplierRepository();
|
||||
final Uuid _uuid = const Uuid();
|
||||
|
||||
bool _isLoading = true;
|
||||
bool _showHidden = false;
|
||||
List<Supplier> _suppliers = const [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSuppliers();
|
||||
}
|
||||
|
||||
Future<void> _loadSuppliers() async {
|
||||
setState(() => _isLoading = true);
|
||||
final suppliers = await _repository.fetchSuppliers(includeHidden: _showHidden);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_suppliers = suppliers;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _openForm({Supplier? supplier}) async {
|
||||
final result = await showDialog<Supplier>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => _SupplierFormDialog(
|
||||
supplier: supplier,
|
||||
onSubmit: (data) => Navigator.of(ctx).pop(data),
|
||||
),
|
||||
);
|
||||
if (result == null) return;
|
||||
final saving = result.copyWith(id: result.id.isEmpty ? _uuid.v4() : result.id, updatedAt: DateTime.now());
|
||||
await _repository.saveSupplier(saving);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('仕入先を保存しました')));
|
||||
_loadSuppliers();
|
||||
}
|
||||
|
||||
Future<void> _deleteSupplier(Supplier supplier) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('仕入先を削除'),
|
||||
content: Text('${supplier.name} を削除しますか?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('キャンセル')),
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('削除', style: TextStyle(color: Colors.red))),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
await _repository.deleteSupplier(supplier.id);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('仕入先を削除しました')));
|
||||
_loadSuppliers();
|
||||
}
|
||||
|
||||
String _closingLabel(Supplier supplier) {
|
||||
if (supplier.closingDay == null) return '締日未設定';
|
||||
return '毎月${supplier.closingDay}日締め / ${supplier.paymentSiteDays}日サイト';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: const BackButton(),
|
||||
title: const Text('M5:仕入先マスター'),
|
||||
actions: [
|
||||
Switch.adaptive(
|
||||
value: _showHidden,
|
||||
onChanged: (value) {
|
||||
setState(() => _showHidden = value);
|
||||
_loadSuppliers();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => _openForm(),
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _suppliers.isEmpty
|
||||
? const _EmptyState(message: '仕入先が登録されていません')
|
||||
: RefreshIndicator(
|
||||
onRefresh: _loadSuppliers,
|
||||
child: ListView.builder(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
itemCount: _suppliers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final supplier = _suppliers[index];
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: ListTile(
|
||||
title: Text(supplier.name, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
subtitle: Text([
|
||||
if (supplier.contactPerson?.isNotEmpty == true) '担当: ${supplier.contactPerson}',
|
||||
if (supplier.tel?.isNotEmpty == true) 'TEL: ${supplier.tel}',
|
||||
_closingLabel(supplier),
|
||||
].where((e) => e.isNotEmpty).join('\n')),
|
||||
trailing: PopupMenuButton<String>(
|
||||
onSelected: (value) {
|
||||
switch (value) {
|
||||
case 'edit':
|
||||
_openForm(supplier: supplier);
|
||||
break;
|
||||
case 'delete':
|
||||
_deleteSupplier(supplier);
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => const [
|
||||
PopupMenuItem(value: 'edit', child: Text('編集')),
|
||||
PopupMenuItem(value: 'delete', child: Text('削除')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SupplierFormDialog extends StatefulWidget {
|
||||
const _SupplierFormDialog({required this.onSubmit, this.supplier});
|
||||
|
||||
final Supplier? supplier;
|
||||
final ValueChanged<Supplier> onSubmit;
|
||||
|
||||
@override
|
||||
State<_SupplierFormDialog> createState() => _SupplierFormDialogState();
|
||||
}
|
||||
|
||||
class _SupplierFormDialogState extends State<_SupplierFormDialog> {
|
||||
late final TextEditingController _nameController;
|
||||
late final TextEditingController _contactController;
|
||||
late final TextEditingController _emailController;
|
||||
late final TextEditingController _telController;
|
||||
late final TextEditingController _addressController;
|
||||
late final TextEditingController _paymentSiteController;
|
||||
late final TextEditingController _notesController;
|
||||
int? _closingDay;
|
||||
bool _isHidden = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final supplier = widget.supplier;
|
||||
_nameController = TextEditingController(text: supplier?.name ?? '');
|
||||
_contactController = TextEditingController(text: supplier?.contactPerson ?? '');
|
||||
_emailController = TextEditingController(text: supplier?.email ?? '');
|
||||
_telController = TextEditingController(text: supplier?.tel ?? '');
|
||||
_addressController = TextEditingController(text: supplier?.address ?? '');
|
||||
_paymentSiteController = TextEditingController(text: (supplier?.paymentSiteDays ?? 30).toString());
|
||||
_notesController = TextEditingController(text: supplier?.notes ?? '');
|
||||
_closingDay = supplier?.closingDay;
|
||||
_isHidden = supplier?.isHidden ?? false;
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
if (_nameController.text.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('仕入先名は必須です')));
|
||||
return;
|
||||
}
|
||||
final paymentSite = int.tryParse(_paymentSiteController.text) ?? 30;
|
||||
widget.onSubmit(
|
||||
Supplier(
|
||||
id: widget.supplier?.id ?? '',
|
||||
name: _nameController.text.trim(),
|
||||
contactPerson: _contactController.text.trim().isEmpty ? null : _contactController.text.trim(),
|
||||
email: _emailController.text.trim().isEmpty ? null : _emailController.text.trim(),
|
||||
tel: _telController.text.trim().isEmpty ? null : _telController.text.trim(),
|
||||
address: _addressController.text.trim().isEmpty ? null : _addressController.text.trim(),
|
||||
closingDay: _closingDay,
|
||||
paymentSiteDays: paymentSite,
|
||||
notes: _notesController.text.trim().isEmpty ? null : _notesController.text.trim(),
|
||||
isHidden: _isHidden,
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final closingOptions = [null, ...List<int>.generate(31, (index) => index + 1)];
|
||||
return AlertDialog(
|
||||
title: Text(widget.supplier == null ? '仕入先を追加' : '仕入先を編集'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(controller: _nameController, decoration: const InputDecoration(labelText: '仕入先名 *')),
|
||||
TextField(controller: _contactController, decoration: const InputDecoration(labelText: '担当者')),
|
||||
TextField(controller: _emailController, decoration: const InputDecoration(labelText: 'メール'), keyboardType: TextInputType.emailAddress),
|
||||
TextField(controller: _telController, decoration: const InputDecoration(labelText: '電話番号'), keyboardType: TextInputType.phone),
|
||||
TextField(controller: _addressController, decoration: const InputDecoration(labelText: '住所')),
|
||||
DropdownButtonFormField<int?>(
|
||||
initialValue: _closingDay,
|
||||
items: closingOptions
|
||||
.map((day) => DropdownMenuItem(
|
||||
value: day,
|
||||
child: Text(day == null ? '締日未設定' : '$day日締め'),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (val) => setState(() => _closingDay = val),
|
||||
decoration: const InputDecoration(labelText: '締日'),
|
||||
),
|
||||
TextField(
|
||||
controller: _paymentSiteController,
|
||||
decoration: const InputDecoration(labelText: '支払サイト(日)'),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
TextField(controller: _notesController, decoration: const InputDecoration(labelText: '備考'), maxLines: 2),
|
||||
SwitchListTile(
|
||||
title: const Text('非表示にする'),
|
||||
value: _isHidden,
|
||||
onChanged: (val) => setState(() => _isHidden = val),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')),
|
||||
FilledButton(onPressed: _submit, child: const Text('保存')),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyState extends StatelessWidget {
|
||||
const _EmptyState({required this.message});
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.inbox, size: 64, color: Colors.grey),
|
||||
const SizedBox(height: 16),
|
||||
Text(message),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
309
lib/screens/supplier_picker_modal.dart
Normal file
309
lib/screens/supplier_picker_modal.dart
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/supplier_model.dart';
|
||||
import '../services/supplier_repository.dart';
|
||||
import '../widgets/keyboard_inset_wrapper.dart';
|
||||
import '../widgets/modal_utils.dart';
|
||||
import '../widgets/screen_id_title.dart';
|
||||
|
||||
class SupplierPickerModal extends StatefulWidget {
|
||||
const SupplierPickerModal({super.key, required this.onSupplierSelected});
|
||||
|
||||
final ValueChanged<Supplier> onSupplierSelected;
|
||||
|
||||
@override
|
||||
State<SupplierPickerModal> createState() => _SupplierPickerModalState();
|
||||
}
|
||||
|
||||
class _SupplierPickerModalState extends State<SupplierPickerModal> {
|
||||
final SupplierRepository _repository = SupplierRepository();
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final Uuid _uuid = const Uuid();
|
||||
|
||||
List<Supplier> _suppliers = const [];
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSuppliers();
|
||||
}
|
||||
|
||||
Future<void> _loadSuppliers([String keyword = '']) async {
|
||||
setState(() => _isLoading = true);
|
||||
final all = await _repository.fetchSuppliers(includeHidden: true);
|
||||
final filtered = keyword.trim().isEmpty
|
||||
? all
|
||||
: all.where((s) => s.name.toLowerCase().contains(keyword.toLowerCase())).toList();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_suppliers = filtered;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _openEditor({Supplier? supplier}) async {
|
||||
final result = await showFeatureModalBottomSheet<Supplier>(
|
||||
context: context,
|
||||
builder: (ctx) => _SupplierFormSheet(
|
||||
supplier: supplier,
|
||||
onSubmit: (data) => Navigator.of(ctx).pop(data),
|
||||
),
|
||||
);
|
||||
if (result == null) return;
|
||||
final saving = result.copyWith(id: result.id.isEmpty ? _uuid.v4() : result.id, updatedAt: DateTime.now());
|
||||
await _repository.saveSupplier(saving);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('仕入先を保存しました')));
|
||||
await _loadSuppliers(_searchController.text);
|
||||
if (!mounted) return;
|
||||
widget.onSupplierSelected(saving);
|
||||
}
|
||||
|
||||
Future<void> _deleteSupplier(Supplier supplier) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('仕入先を削除'),
|
||||
content: Text('${supplier.name} を削除しますか?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('キャンセル')),
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('削除')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
await _repository.deleteSupplier(supplier.id);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('仕入先を削除しました')));
|
||||
await _loadSuppliers(_searchController.text);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return SafeArea(
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: const ScreenAppBarTitle(screenId: 'P5', title: '仕入先選択'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: '仕入先を追加',
|
||||
onPressed: _openEditor,
|
||||
icon: const Icon(Icons.add_circle_outline),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: KeyboardInsetWrapper(
|
||||
safeAreaTop: false,
|
||||
basePadding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
extraBottom: 24,
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: '仕入先名で検索',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _searchController.text.isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
_loadSuppliers('');
|
||||
},
|
||||
),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: _loadSuppliers,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _suppliers.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'仕入先が見つかりません。右上の + から追加できます。',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: _suppliers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final supplier = _suppliers[index];
|
||||
return Card(
|
||||
child: ListTile(
|
||||
title: Text(supplier.name, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (supplier.contactPerson?.isNotEmpty == true) Text('担当: ${supplier.contactPerson}'),
|
||||
if (supplier.tel?.isNotEmpty == true) Text('TEL: ${supplier.tel}'),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
widget.onSupplierSelected(supplier);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
trailing: PopupMenuButton<String>(
|
||||
onSelected: (value) {
|
||||
switch (value) {
|
||||
case 'edit':
|
||||
_openEditor(supplier: supplier);
|
||||
break;
|
||||
case 'delete':
|
||||
_deleteSupplier(supplier);
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => const [
|
||||
PopupMenuItem(value: 'edit', child: Text('編集')),
|
||||
PopupMenuItem(value: 'delete', child: Text('削除')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SupplierFormSheet extends StatefulWidget {
|
||||
const _SupplierFormSheet({required this.onSubmit, this.supplier});
|
||||
|
||||
final Supplier? supplier;
|
||||
final ValueChanged<Supplier> onSubmit;
|
||||
|
||||
@override
|
||||
State<_SupplierFormSheet> createState() => _SupplierFormSheetState();
|
||||
}
|
||||
|
||||
class _SupplierFormSheetState extends State<_SupplierFormSheet> {
|
||||
late final TextEditingController _nameController;
|
||||
late final TextEditingController _contactController;
|
||||
late final TextEditingController _telController;
|
||||
late final TextEditingController _emailController;
|
||||
late final TextEditingController _notesController;
|
||||
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
bool _isSaving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final supplier = widget.supplier;
|
||||
_nameController = TextEditingController(text: supplier?.name ?? '');
|
||||
_contactController = TextEditingController(text: supplier?.contactPerson ?? '');
|
||||
_telController = TextEditingController(text: supplier?.tel ?? '');
|
||||
_emailController = TextEditingController(text: supplier?.email ?? '');
|
||||
_notesController = TextEditingController(text: supplier?.notes ?? '');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_contactController.dispose();
|
||||
_telController.dispose();
|
||||
_emailController.dispose();
|
||||
_notesController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleSubmit() {
|
||||
if (_isSaving) return;
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
setState(() => _isSaving = true);
|
||||
widget.onSubmit(
|
||||
Supplier(
|
||||
id: widget.supplier?.id ?? '',
|
||||
name: _nameController.text.trim(),
|
||||
contactPerson: _contactController.text.trim().isEmpty ? null : _contactController.text.trim(),
|
||||
tel: _telController.text.trim().isEmpty ? null : _telController.text.trim(),
|
||||
email: _emailController.text.trim().isEmpty ? null : _emailController.text.trim(),
|
||||
notes: _notesController.text.trim().isEmpty ? null : _notesController.text.trim(),
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final title = widget.supplier == null ? '仕入先を追加' : '仕入先を編集';
|
||||
return SafeArea(
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
child: Material(
|
||||
color: Colors.white,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(onPressed: () => Navigator.pop(context), icon: const Icon(Icons.close)),
|
||||
const SizedBox(width: 4),
|
||||
Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: _isSaving ? null : _handleSubmit,
|
||||
child: _isSaving
|
||||
? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Text('保存'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: KeyboardInsetWrapper(
|
||||
safeAreaTop: false,
|
||||
basePadding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
extraBottom: 24,
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(labelText: '仕入先名 *'),
|
||||
validator: (value) => value == null || value.trim().isEmpty ? '必須項目です' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(controller: _contactController, decoration: const InputDecoration(labelText: '担当者')),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(controller: _telController, decoration: const InputDecoration(labelText: '電話番号')),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(controller: _emailController, decoration: const InputDecoration(labelText: 'メール')),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(controller: _notesController, decoration: const InputDecoration(labelText: '備考'), maxLines: 3),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
105
lib/screens/tax_setting_screen.dart
Normal file
105
lib/screens/tax_setting_screen.dart
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/tax_setting_model.dart';
|
||||
import '../services/tax_setting_repository.dart';
|
||||
|
||||
class TaxSettingScreen extends StatefulWidget {
|
||||
const TaxSettingScreen({super.key});
|
||||
|
||||
@override
|
||||
State<TaxSettingScreen> createState() => _TaxSettingScreenState();
|
||||
}
|
||||
|
||||
class _TaxSettingScreenState extends State<TaxSettingScreen> {
|
||||
final TaxSettingRepository _repository = TaxSettingRepository();
|
||||
final TextEditingController _rateController = TextEditingController();
|
||||
String _roundingMode = 'round';
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() => _isLoading = true);
|
||||
final setting = await _repository.fetchCurrentSetting();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_rateController.text = (setting.rate * 100).toStringAsFixed(1);
|
||||
_roundingMode = setting.roundingMode;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final ratePercent = double.tryParse(_rateController.text);
|
||||
if (ratePercent == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('税込率は数値で入力してください')));
|
||||
return;
|
||||
}
|
||||
final setting = TaxSetting(
|
||||
id: const Uuid().v4(),
|
||||
rate: ratePercent / 100,
|
||||
roundingMode: _roundingMode,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
await _repository.saveSetting(setting);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('税設定を保存しました')));
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_rateController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: const BackButton(),
|
||||
title: const Text('M7:消費税・端数設定'),
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _rateController,
|
||||
decoration: const InputDecoration(labelText: '税率 (%)', suffixText: '%'),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _roundingMode,
|
||||
decoration: const InputDecoration(labelText: '端数処理'),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'round', child: Text('四捨五入')),
|
||||
DropdownMenuItem(value: 'ceil', child: Text('切り上げ')),
|
||||
DropdownMenuItem(value: 'floor', child: Text('切り捨て')),
|
||||
],
|
||||
onChanged: (value) => setState(() => _roundingMode = value ?? 'round'),
|
||||
),
|
||||
const Spacer(),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
icon: const Icon(Icons.save),
|
||||
onPressed: _save,
|
||||
label: const Text('保存'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,14 @@ class AppSettingsRepository {
|
|||
static const _kDashboardHistoryUnlocked = 'dashboard_history_unlocked';
|
||||
static const _kTheme = 'app_theme'; // light / dark / system
|
||||
static const _kSummaryTheme = 'summary_theme'; // 'white' or 'blue'
|
||||
static const _kGoogleCalendarEnabled = 'google_calendar_enabled';
|
||||
static const _kGoogleCalendarId = 'google_calendar_id';
|
||||
static const _kGoogleCalendarAccount = 'google_calendar_account';
|
||||
static const _kGrossProfitEnabled = 'gross_profit_enabled';
|
||||
static const _kGrossProfitToggleVisible = 'gross_profit_toggle_visible';
|
||||
static const _kGrossProfitIncludeProvisional = 'gross_profit_include_provisional';
|
||||
static const _kSalesEntryCashModeDefault = 'sales_entry_cash_mode_default';
|
||||
static const _kSalesEntryShowGross = 'sales_entry_show_gross';
|
||||
|
||||
final DatabaseHelper _dbHelper = DatabaseHelper();
|
||||
|
||||
|
|
@ -78,9 +86,84 @@ class AppSettingsRepository {
|
|||
Future<String> getSummaryTheme() async => await getString(_kSummaryTheme) ?? 'white';
|
||||
Future<void> setSummaryTheme(String theme) async => setString(_kSummaryTheme, theme);
|
||||
|
||||
Future<bool> getGoogleCalendarEnabled({bool defaultValue = false}) async {
|
||||
return getBool(_kGoogleCalendarEnabled, defaultValue: defaultValue);
|
||||
}
|
||||
|
||||
Future<void> setGoogleCalendarEnabled(bool enabled) async {
|
||||
await setBool(_kGoogleCalendarEnabled, enabled);
|
||||
}
|
||||
|
||||
Future<String?> getGoogleCalendarId() async => getString(_kGoogleCalendarId);
|
||||
|
||||
Future<void> setGoogleCalendarId(String? calendarId) async {
|
||||
if (calendarId == null || calendarId.isEmpty) {
|
||||
await deleteKey(_kGoogleCalendarId);
|
||||
} else {
|
||||
await setString(_kGoogleCalendarId, calendarId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> getGoogleCalendarAccountEmail() async => getString(_kGoogleCalendarAccount);
|
||||
|
||||
Future<void> setGoogleCalendarAccountEmail(String? email) async {
|
||||
if (email == null || email.isEmpty) {
|
||||
await deleteKey(_kGoogleCalendarAccount);
|
||||
} else {
|
||||
await setString(_kGoogleCalendarAccount, email);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearGoogleCalendarSettings() async {
|
||||
await setGoogleCalendarEnabled(false);
|
||||
await setGoogleCalendarId(null);
|
||||
await setGoogleCalendarAccountEmail(null);
|
||||
}
|
||||
|
||||
Future<bool> getGrossProfitEnabled({bool defaultValue = true}) async {
|
||||
return getBool(_kGrossProfitEnabled, defaultValue: defaultValue);
|
||||
}
|
||||
|
||||
Future<void> setGrossProfitEnabled(bool enabled) async {
|
||||
await setBool(_kGrossProfitEnabled, enabled);
|
||||
}
|
||||
|
||||
Future<bool> getGrossProfitToggleVisible({bool defaultValue = true}) async {
|
||||
return getBool(_kGrossProfitToggleVisible, defaultValue: defaultValue);
|
||||
}
|
||||
|
||||
Future<void> setGrossProfitToggleVisible(bool visible) async {
|
||||
await setBool(_kGrossProfitToggleVisible, visible);
|
||||
}
|
||||
|
||||
Future<bool> getGrossProfitIncludeProvisional({bool defaultValue = false}) async {
|
||||
return getBool(_kGrossProfitIncludeProvisional, defaultValue: defaultValue);
|
||||
}
|
||||
|
||||
Future<void> setGrossProfitIncludeProvisional(bool include) async {
|
||||
await setBool(_kGrossProfitIncludeProvisional, include);
|
||||
}
|
||||
|
||||
Future<bool> getSalesEntryCashModeDefault({bool defaultValue = false}) async {
|
||||
return getBool(_kSalesEntryCashModeDefault, defaultValue: defaultValue);
|
||||
}
|
||||
|
||||
Future<void> setSalesEntryCashModeDefault(bool enabled) async {
|
||||
await setBool(_kSalesEntryCashModeDefault, enabled);
|
||||
}
|
||||
|
||||
Future<bool> getSalesEntryShowGross({bool defaultValue = true}) async {
|
||||
return getBool(_kSalesEntryShowGross, defaultValue: defaultValue);
|
||||
}
|
||||
|
||||
Future<void> setSalesEntryShowGross(bool display) async {
|
||||
await setBool(_kSalesEntryShowGross, display);
|
||||
}
|
||||
|
||||
// Generic helpers
|
||||
Future<String?> getString(String key) async => _getValue(key);
|
||||
Future<void> setString(String key, String value) async => _setValue(key, value);
|
||||
Future<void> deleteKey(String key) async => _deleteValue(key);
|
||||
|
||||
Future<bool> getBool(String key, {bool defaultValue = false}) async {
|
||||
final v = await _getValue(key);
|
||||
|
|
@ -103,6 +186,12 @@ class AppSettingsRepository {
|
|||
final db = await _dbHelper.database;
|
||||
await db.insert('app_settings', {'key': key, 'value': value}, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
|
||||
Future<void> _deleteValue(String key) async {
|
||||
await _ensureTable();
|
||||
final db = await _dbHelper.database;
|
||||
await db.delete('app_settings', where: 'key = ?', whereArgs: [key]);
|
||||
}
|
||||
}
|
||||
|
||||
class DashboardMenuItem {
|
||||
|
|
|
|||
116
lib/services/business_calendar_mapper.dart
Normal file
116
lib/services/business_calendar_mapper.dart
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../models/receivable_models.dart';
|
||||
import '../models/shipment_models.dart';
|
||||
import 'app_settings_repository.dart';
|
||||
import 'calendar_sync_service.dart';
|
||||
|
||||
/// Maps domain entities (shipments, receivables, etc.) to Google Calendar events.
|
||||
class BusinessCalendarMapper {
|
||||
BusinessCalendarMapper({
|
||||
CalendarSyncService? calendarSyncService,
|
||||
AppSettingsRepository? settingsRepository,
|
||||
}) : _calendarSyncService = calendarSyncService ?? CalendarSyncService(),
|
||||
_settingsRepository = settingsRepository ?? AppSettingsRepository();
|
||||
|
||||
final CalendarSyncService _calendarSyncService;
|
||||
final AppSettingsRepository _settingsRepository;
|
||||
final NumberFormat _currencyFormat = NumberFormat.currency(locale: 'ja_JP', symbol: '¥');
|
||||
|
||||
Future<bool> _ensureReady() async {
|
||||
final enabled = await _settingsRepository.getGoogleCalendarEnabled();
|
||||
if (!enabled) return false;
|
||||
final ready = await _calendarSyncService.ensureSignedIn();
|
||||
return ready;
|
||||
}
|
||||
|
||||
Future<void> syncShipments(List<Shipment> shipments) async {
|
||||
if (shipments.isEmpty) return;
|
||||
if (!await _ensureReady()) return;
|
||||
for (final shipment in shipments) {
|
||||
await _syncShipment(shipment);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> syncShipment(Shipment shipment) async {
|
||||
if (!await _ensureReady()) return;
|
||||
await _syncShipment(shipment);
|
||||
}
|
||||
|
||||
Future<void> syncReceivables(List<ReceivableInvoiceSummary> summaries) async {
|
||||
if (summaries.isEmpty) return;
|
||||
if (!await _ensureReady()) return;
|
||||
for (final summary in summaries) {
|
||||
await _syncReceivable(summary);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> syncReceivable(ReceivableInvoiceSummary summary) async {
|
||||
if (!await _ensureReady()) return;
|
||||
await _syncReceivable(summary);
|
||||
}
|
||||
|
||||
Future<void> _syncShipment(Shipment shipment) async {
|
||||
final date = shipment.scheduledShipDate ?? shipment.actualShipDate;
|
||||
if (date == null) return;
|
||||
final start = DateTime(date.year, date.month, date.day, 9);
|
||||
final end = start.add(const Duration(hours: 2));
|
||||
final summary = '[出荷] ${(shipment.customerNameSnapshot ?? '取引先未設定')}';
|
||||
final buffer = StringBuffer()
|
||||
..writeln('受注番号: ${shipment.orderNumberSnapshot ?? '-'}')
|
||||
..writeln('ステータス: ${shipment.status.displayName}')
|
||||
..writeln('配送業者: ${shipment.carrierName ?? '-'}')
|
||||
..writeln('追跡番号: ${shipment.trackingNumber ?? '-'}');
|
||||
if (shipment.trackingUrl?.isNotEmpty == true) {
|
||||
buffer.writeln('トラッキングURL: ${shipment.trackingUrl}');
|
||||
}
|
||||
try {
|
||||
await _calendarSyncService.createOrUpdateEvent(
|
||||
eventId: 'shipment-${shipment.id}',
|
||||
summary: summary,
|
||||
description: buffer.toString(),
|
||||
start: start,
|
||||
end: end,
|
||||
extendedProperties: {
|
||||
'type': 'shipment',
|
||||
'shipmentId': shipment.id,
|
||||
if (shipment.orderNumberSnapshot != null) 'orderNumber': shipment.orderNumberSnapshot!,
|
||||
if (shipment.customerNameSnapshot != null) 'customer': shipment.customerNameSnapshot!,
|
||||
},
|
||||
);
|
||||
} catch (e, stack) {
|
||||
debugPrint('Failed to sync shipment ${shipment.id} to calendar: $e\n$stack');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _syncReceivable(ReceivableInvoiceSummary summary) async {
|
||||
final dueDate = summary.dueDate;
|
||||
final start = DateTime(dueDate.year, dueDate.month, dueDate.day, 10);
|
||||
final end = start.add(const Duration(hours: 1));
|
||||
final title = '[入金予定] ${summary.customerName}';
|
||||
final description = StringBuffer()
|
||||
..writeln('請求書番号: ${summary.invoiceNumber}')
|
||||
..writeln('請求日: ${DateFormat('yyyy/MM/dd').format(summary.invoiceDate)}')
|
||||
..writeln('期日: ${DateFormat('yyyy/MM/dd').format(summary.dueDate)}')
|
||||
..writeln('請求額: ${_currencyFormat.format(summary.totalAmount)}')
|
||||
..writeln('入金済み: ${_currencyFormat.format(summary.paidAmount)}')
|
||||
..writeln('残高: ${_currencyFormat.format(summary.outstandingAmount)}');
|
||||
try {
|
||||
await _calendarSyncService.createOrUpdateEvent(
|
||||
eventId: 'receivable-${summary.invoiceId}',
|
||||
summary: title,
|
||||
description: description.toString(),
|
||||
start: start,
|
||||
end: end,
|
||||
extendedProperties: {
|
||||
'type': 'receivable',
|
||||
'invoiceId': summary.invoiceId,
|
||||
'invoiceNumber': summary.invoiceNumber,
|
||||
},
|
||||
);
|
||||
} catch (e, stack) {
|
||||
debugPrint('Failed to sync receivable ${summary.invoiceId} to calendar: $e\n$stack');
|
||||
}
|
||||
}
|
||||
}
|
||||
65
lib/services/calendar_sync_diagnostics.dart
Normal file
65
lib/services/calendar_sync_diagnostics.dart
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/receivable_models.dart';
|
||||
import '../models/shipment_models.dart';
|
||||
import 'business_calendar_mapper.dart';
|
||||
import 'receivable_repository.dart';
|
||||
import 'shipment_repository.dart';
|
||||
|
||||
/// Result object describing a manual Google Calendar sync run.
|
||||
class CalendarSyncDiagnosticsResult {
|
||||
const CalendarSyncDiagnosticsResult({
|
||||
required this.shipmentsSynced,
|
||||
required this.receivablesSynced,
|
||||
required this.errors,
|
||||
});
|
||||
|
||||
final int shipmentsSynced;
|
||||
final int receivablesSynced;
|
||||
final List<String> errors;
|
||||
|
||||
bool get hasErrors => errors.isNotEmpty;
|
||||
}
|
||||
|
||||
/// Utility helper to trigger a full calendar sync for diagnostics or manual refresh.
|
||||
class CalendarSyncDiagnostics {
|
||||
CalendarSyncDiagnostics({
|
||||
ShipmentRepository? shipmentRepository,
|
||||
ReceivableRepository? receivableRepository,
|
||||
BusinessCalendarMapper? calendarMapper,
|
||||
}) : _shipmentRepository = shipmentRepository ?? ShipmentRepository(),
|
||||
_receivableRepository = receivableRepository ?? ReceivableRepository(),
|
||||
_calendarMapper = calendarMapper ?? BusinessCalendarMapper();
|
||||
|
||||
final ShipmentRepository _shipmentRepository;
|
||||
final ReceivableRepository _receivableRepository;
|
||||
final BusinessCalendarMapper _calendarMapper;
|
||||
|
||||
Future<CalendarSyncDiagnosticsResult> runFullSync({bool includeSettledReceivables = true}) async {
|
||||
final errors = <String>[];
|
||||
List<Shipment> shipments = const [];
|
||||
List<ReceivableInvoiceSummary> receivables = const [];
|
||||
|
||||
try {
|
||||
shipments = await _shipmentRepository.fetchShipments();
|
||||
await _calendarMapper.syncShipments(shipments);
|
||||
} catch (e, stack) {
|
||||
debugPrint('Calendar diagnostics: shipment sync failed: $e\n$stack');
|
||||
errors.add('出荷イベント同期に失敗: $e');
|
||||
}
|
||||
|
||||
try {
|
||||
receivables = await _receivableRepository.fetchSummaries(includeSettled: includeSettledReceivables);
|
||||
await _calendarMapper.syncReceivables(receivables);
|
||||
} catch (e, stack) {
|
||||
debugPrint('Calendar diagnostics: receivable sync failed: $e\n$stack');
|
||||
errors.add('債権イベント同期に失敗: $e');
|
||||
}
|
||||
|
||||
return CalendarSyncDiagnosticsResult(
|
||||
shipmentsSynced: shipments.length,
|
||||
receivablesSynced: receivables.length,
|
||||
errors: errors,
|
||||
);
|
||||
}
|
||||
}
|
||||
126
lib/services/calendar_sync_service.dart
Normal file
126
lib/services/calendar_sync_service.dart
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:google_sign_in/google_sign_in.dart';
|
||||
import 'package:googleapis/calendar/v3.dart' as gcal;
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/retry.dart';
|
||||
|
||||
import 'app_settings_repository.dart';
|
||||
|
||||
/// Googleカレンダーとの認証・イベント同期を担当するサービス。
|
||||
class CalendarSyncService {
|
||||
CalendarSyncService({AppSettingsRepository? settingsRepository})
|
||||
: _settingsRepository = settingsRepository ?? AppSettingsRepository();
|
||||
|
||||
final AppSettingsRepository _settingsRepository;
|
||||
GoogleSignInAccount? _currentAccount;
|
||||
gcal.CalendarApi? _calendarApi;
|
||||
static const List<String> _scopes = [gcal.CalendarApi.calendarScope];
|
||||
|
||||
GoogleSignIn get _googleSignIn => GoogleSignIn(scopes: _scopes);
|
||||
|
||||
Future<bool> ensureSignedIn() async {
|
||||
final enabled = await _settingsRepository.getGoogleCalendarEnabled();
|
||||
if (!enabled) return false;
|
||||
_currentAccount = await _googleSignIn.signInSilently();
|
||||
_currentAccount ??= await _googleSignIn.signIn();
|
||||
if (_currentAccount == null) {
|
||||
await _settingsRepository.clearGoogleCalendarSettings();
|
||||
return false;
|
||||
}
|
||||
await _initializeCalendarApi();
|
||||
await _settingsRepository.setGoogleCalendarAccountEmail(_currentAccount!.email);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> signOut() async {
|
||||
await _googleSignIn.disconnect();
|
||||
await _settingsRepository.clearGoogleCalendarSettings();
|
||||
_calendarApi = null;
|
||||
}
|
||||
|
||||
Future<void> _initializeCalendarApi() async {
|
||||
final account = _currentAccount;
|
||||
if (account == null) return;
|
||||
final authHeaders = await account.authHeaders;
|
||||
final authClient = _AuthClientDecorator(authHeaders, http.Client());
|
||||
final retryClient = RetryClient(authClient, retries: 3);
|
||||
_calendarApi = gcal.CalendarApi(retryClient);
|
||||
}
|
||||
|
||||
Future<List<gcal.CalendarListEntry>> fetchCalendars() async {
|
||||
if (_calendarApi == null) {
|
||||
final ready = await ensureSignedIn();
|
||||
if (!ready) return [];
|
||||
}
|
||||
final list = await _calendarApi!.calendarList.list(showHidden: false, minAccessRole: 'writer');
|
||||
return list.items ?? [];
|
||||
}
|
||||
|
||||
Future<void> createOrUpdateEvent({
|
||||
required String eventId,
|
||||
required String summary,
|
||||
String? description,
|
||||
DateTime? start,
|
||||
DateTime? end,
|
||||
String? calendarId,
|
||||
Map<String, String>? extendedProperties,
|
||||
}) async {
|
||||
if (_calendarApi == null) {
|
||||
final ready = await ensureSignedIn();
|
||||
if (!ready) return;
|
||||
}
|
||||
final targetCalendarId = calendarId ?? (await _settingsRepository.getGoogleCalendarId()) ?? 'primary';
|
||||
final event = gcal.Event()
|
||||
..id = eventId
|
||||
..summary = summary
|
||||
..description = description
|
||||
..start = start != null ? _timeFromDate(start) : null
|
||||
..end = end != null ? _timeFromDate(end) : null
|
||||
..extendedProperties = extendedProperties == null
|
||||
? null
|
||||
: gcal.EventExtendedProperties(private: extendedProperties);
|
||||
|
||||
try {
|
||||
await _calendarApi!.events.patch(event, targetCalendarId, eventId);
|
||||
} on gcal.DetailedApiRequestError catch (e) {
|
||||
if (e.status == 404) {
|
||||
await _calendarApi!.events.insert(event, targetCalendarId);
|
||||
} else {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteEvent(String eventId, {String? calendarId}) async {
|
||||
if (_calendarApi == null) {
|
||||
final ready = await ensureSignedIn();
|
||||
if (!ready) return;
|
||||
}
|
||||
final targetCalendarId = calendarId ?? (await _settingsRepository.getGoogleCalendarId()) ?? 'primary';
|
||||
await _calendarApi!.events.delete(targetCalendarId, eventId);
|
||||
}
|
||||
|
||||
gcal.EventDateTime _timeFromDate(DateTime date) {
|
||||
return gcal.EventDateTime(dateTime: date, timeZone: date.timeZoneName);
|
||||
}
|
||||
}
|
||||
|
||||
class _AuthClientDecorator extends http.BaseClient {
|
||||
_AuthClientDecorator(this._headers, this._inner);
|
||||
|
||||
final Map<String, String> _headers;
|
||||
final http.Client _inner;
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) {
|
||||
request.headers.addAll(_headers);
|
||||
return _inner.send(request);
|
||||
}
|
||||
|
||||
@override
|
||||
void close() {
|
||||
_inner.close();
|
||||
super.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -36,6 +36,13 @@ class CustomerRepository {
|
|||
return List.generate(maps.length, (i) => Customer.fromMap(maps[i]));
|
||||
}
|
||||
|
||||
Future<Customer?> findById(String id) async {
|
||||
final db = await _dbHelper.database;
|
||||
final rows = await db.query('customers', where: 'id = ?', whereArgs: [id], limit: 1);
|
||||
if (rows.isEmpty) return null;
|
||||
return Customer.fromMap(rows.first);
|
||||
}
|
||||
|
||||
Future<void> ensureCustomerColumns() async {
|
||||
final db = await _dbHelper.database;
|
||||
// best-effort, ignore errors if columns already exist
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import 'package:sqflite/sqflite.dart';
|
|||
import 'package:path/path.dart';
|
||||
|
||||
class DatabaseHelper {
|
||||
static const _databaseVersion = 26;
|
||||
static const _databaseVersion = 37;
|
||||
static final DatabaseHelper _instance = DatabaseHelper._internal();
|
||||
static Database? _database;
|
||||
|
||||
|
|
@ -210,6 +210,49 @@ class DatabaseHelper {
|
|||
''');
|
||||
await db.execute('CREATE INDEX IF NOT EXISTS idx_chat_messages_created_at ON chat_messages(created_at)');
|
||||
}
|
||||
if (oldVersion < 27) {
|
||||
await _createSalesOrderTables(db);
|
||||
}
|
||||
if (oldVersion < 28) {
|
||||
await _createShipmentTables(db);
|
||||
}
|
||||
if (oldVersion < 29) {
|
||||
await _createInventoryTables(db);
|
||||
}
|
||||
if (oldVersion < 30) {
|
||||
await _createReceivableTables(db);
|
||||
}
|
||||
if (oldVersion < 31) {
|
||||
await _safeAddColumn(db, 'invoices', 'previous_chain_hash TEXT');
|
||||
await _safeAddColumn(db, 'invoices', 'chain_hash TEXT');
|
||||
await _safeAddColumn(db, 'invoices', "chain_status TEXT DEFAULT 'pending'");
|
||||
}
|
||||
if (oldVersion < 32) {
|
||||
await _createSupplierTables(db);
|
||||
await _createDepartmentTables(db);
|
||||
await _createStaffTables(db);
|
||||
await _createTaxSettingsTable(db);
|
||||
}
|
||||
if (oldVersion < 33) {
|
||||
await _safeAddColumn(db, 'shipments', 'tracking_url TEXT');
|
||||
await _safeAddColumn(db, 'shipments', 'label_pdf_path TEXT');
|
||||
}
|
||||
if (oldVersion < 34) {
|
||||
await _createSalesEntryTables(db);
|
||||
}
|
||||
if (oldVersion < 35) {
|
||||
await _safeAddColumn(db, 'products', 'wholesale_price INTEGER DEFAULT 0');
|
||||
await _safeAddColumn(db, 'sales_line_items', 'cost_amount INTEGER DEFAULT 0');
|
||||
await _safeAddColumn(db, 'sales_line_items', 'cost_is_provisional INTEGER DEFAULT 0');
|
||||
}
|
||||
if (oldVersion < 36) {
|
||||
await _safeAddColumn(db, 'sales_entries', 'settlement_method TEXT');
|
||||
await _safeAddColumn(db, 'sales_entries', 'settlement_card_company TEXT');
|
||||
await _safeAddColumn(db, 'sales_entries', 'settlement_due_date TEXT');
|
||||
}
|
||||
if (oldVersion < 37) {
|
||||
await _createPurchaseEntryTables(db);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onCreate(Database db, int version) async {
|
||||
|
|
@ -266,6 +309,7 @@ class DatabaseHelper {
|
|||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
default_unit_price INTEGER,
|
||||
wholesale_price INTEGER DEFAULT 0,
|
||||
barcode TEXT,
|
||||
category TEXT,
|
||||
stock_quantity INTEGER DEFAULT 0,
|
||||
|
|
@ -287,6 +331,11 @@ class DatabaseHelper {
|
|||
''');
|
||||
await db.execute('CREATE INDEX idx_master_hidden_type ON master_hidden(master_type)');
|
||||
|
||||
await _createSupplierTables(db);
|
||||
await _createDepartmentTables(db);
|
||||
await _createStaffTables(db);
|
||||
await _createTaxSettingsTable(db);
|
||||
|
||||
// 伝票マスター
|
||||
await db.execute('''
|
||||
CREATE TABLE invoices (
|
||||
|
|
@ -388,6 +437,15 @@ class DatabaseHelper {
|
|||
)
|
||||
''');
|
||||
await db.execute('CREATE INDEX idx_chat_messages_created_at ON chat_messages(created_at)');
|
||||
await _createSalesOrderTables(db);
|
||||
await _createShipmentTables(db);
|
||||
await _createInventoryTables(db);
|
||||
await _createReceivableTables(db);
|
||||
await _createSalesEntryTables(db);
|
||||
await _createPurchaseEntryTables(db);
|
||||
await _safeAddColumn(db, 'invoices', 'previous_chain_hash TEXT');
|
||||
await _safeAddColumn(db, 'invoices', 'chain_hash TEXT');
|
||||
await _safeAddColumn(db, 'invoices', "chain_status TEXT DEFAULT 'pending'");
|
||||
}
|
||||
|
||||
Future<void> _safeAddColumn(Database db, String table, String columnDef) async {
|
||||
|
|
@ -397,4 +455,339 @@ class DatabaseHelper {
|
|||
// Ignore if the column already exists.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _createSalesOrderTables(Database db) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS sales_orders (
|
||||
id TEXT PRIMARY KEY,
|
||||
order_number TEXT,
|
||||
customer_id TEXT NOT NULL,
|
||||
customer_name_snapshot TEXT,
|
||||
order_date TEXT NOT NULL,
|
||||
requested_ship_date TEXT,
|
||||
status TEXT NOT NULL,
|
||||
subtotal INTEGER DEFAULT 0,
|
||||
tax_amount INTEGER DEFAULT 0,
|
||||
total_amount INTEGER DEFAULT 0,
|
||||
notes TEXT,
|
||||
assigned_to TEXT,
|
||||
workflow_stage TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(customer_id) REFERENCES customers(id)
|
||||
)
|
||||
''');
|
||||
await db.execute('CREATE INDEX IF NOT EXISTS idx_sales_orders_customer ON sales_orders(customer_id)');
|
||||
await db.execute('CREATE INDEX IF NOT EXISTS idx_sales_orders_status ON sales_orders(status)');
|
||||
await db.execute('CREATE INDEX IF NOT EXISTS idx_sales_orders_date ON sales_orders(order_date)');
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS sales_order_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
order_id TEXT NOT NULL,
|
||||
product_id TEXT,
|
||||
description TEXT NOT NULL,
|
||||
quantity INTEGER NOT NULL,
|
||||
unit_price INTEGER NOT NULL,
|
||||
tax_rate REAL DEFAULT 0,
|
||||
sort_index INTEGER DEFAULT 0,
|
||||
FOREIGN KEY(order_id) REFERENCES sales_orders(id) ON DELETE CASCADE
|
||||
)
|
||||
''');
|
||||
await db.execute('CREATE INDEX IF NOT EXISTS idx_sales_order_items_order ON sales_order_items(order_id)');
|
||||
}
|
||||
|
||||
Future<void> _createShipmentTables(Database db) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS shipments (
|
||||
id TEXT PRIMARY KEY,
|
||||
order_id TEXT,
|
||||
order_number_snapshot TEXT,
|
||||
customer_name_snapshot TEXT,
|
||||
scheduled_ship_date TEXT,
|
||||
actual_ship_date TEXT,
|
||||
status TEXT NOT NULL,
|
||||
carrier_name TEXT,
|
||||
tracking_number TEXT,
|
||||
tracking_url TEXT,
|
||||
label_pdf_path TEXT,
|
||||
notes TEXT,
|
||||
picking_completed_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(order_id) REFERENCES sales_orders(id)
|
||||
)
|
||||
''');
|
||||
await db.execute('CREATE INDEX IF NOT EXISTS idx_shipments_status ON shipments(status)');
|
||||
await db.execute('CREATE INDEX IF NOT EXISTS idx_shipments_order ON shipments(order_id)');
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS shipment_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
shipment_id TEXT NOT NULL,
|
||||
order_item_id TEXT,
|
||||
description TEXT NOT NULL,
|
||||
quantity INTEGER NOT NULL,
|
||||
FOREIGN KEY(shipment_id) REFERENCES shipments(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(order_item_id) REFERENCES sales_order_items(id)
|
||||
)
|
||||
''');
|
||||
await db.execute('CREATE INDEX IF NOT EXISTS idx_shipment_items_shipment ON shipment_items(shipment_id)');
|
||||
}
|
||||
|
||||
Future<void> _createInventoryTables(Database db) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS inventory_movements (
|
||||
id TEXT PRIMARY KEY,
|
||||
product_id TEXT NOT NULL,
|
||||
product_name_snapshot TEXT,
|
||||
movement_type TEXT NOT NULL,
|
||||
quantity INTEGER NOT NULL,
|
||||
quantity_delta INTEGER NOT NULL,
|
||||
reference TEXT,
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(product_id) REFERENCES products(id)
|
||||
)
|
||||
''');
|
||||
await db.execute('CREATE INDEX IF NOT EXISTS idx_inventory_movements_product ON inventory_movements(product_id)');
|
||||
await db.execute('CREATE INDEX IF NOT EXISTS idx_inventory_movements_created ON inventory_movements(created_at)');
|
||||
}
|
||||
|
||||
Future<void> _createReceivableTables(Database db) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS receivable_payments (
|
||||
id TEXT PRIMARY KEY,
|
||||
invoice_id TEXT NOT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
payment_date TEXT NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(invoice_id) REFERENCES invoices(id) ON DELETE CASCADE
|
||||
)
|
||||
''');
|
||||
await db.execute('CREATE INDEX IF NOT EXISTS idx_receivable_payments_invoice ON receivable_payments(invoice_id)');
|
||||
await db.execute('CREATE INDEX IF NOT EXISTS idx_receivable_payments_date ON receivable_payments(payment_date)');
|
||||
}
|
||||
|
||||
Future<void> _createSupplierTables(Database db) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS suppliers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
contact_person TEXT,
|
||||
email TEXT,
|
||||
tel TEXT,
|
||||
address TEXT,
|
||||
closing_day INTEGER,
|
||||
payment_site_days INTEGER DEFAULT 30,
|
||||
notes TEXT,
|
||||
is_hidden INTEGER DEFAULT 0,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
await db.execute('CREATE INDEX IF NOT EXISTS idx_suppliers_name ON suppliers(name)');
|
||||
}
|
||||
|
||||
Future<void> _createDepartmentTables(Database db) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS departments (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
code TEXT,
|
||||
description TEXT,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
await db.execute('CREATE INDEX IF NOT EXISTS idx_departments_name ON departments(name)');
|
||||
}
|
||||
|
||||
Future<void> _createStaffTables(Database db) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS staff_members (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT,
|
||||
tel TEXT,
|
||||
role TEXT,
|
||||
department_id TEXT,
|
||||
permission_level TEXT,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(department_id) REFERENCES departments(id) ON DELETE SET NULL
|
||||
)
|
||||
''');
|
||||
await db.execute('CREATE INDEX IF NOT EXISTS idx_staff_department ON staff_members(department_id)');
|
||||
}
|
||||
|
||||
Future<void> _createTaxSettingsTable(Database db) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS tax_settings (
|
||||
id TEXT PRIMARY KEY,
|
||||
rate REAL NOT NULL,
|
||||
rounding_mode TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
final existing = await db.query('tax_settings', limit: 1);
|
||||
if (existing.isEmpty) {
|
||||
await db.insert('tax_settings', {
|
||||
'id': 'default',
|
||||
'rate': 0.1,
|
||||
'rounding_mode': 'round',
|
||||
'updated_at': DateTime.now().toIso8601String(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _createSalesEntryTables(Database db) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS sales_entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
customer_id TEXT,
|
||||
customer_name_snapshot TEXT,
|
||||
subject TEXT,
|
||||
issue_date TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
amount_tax_excl INTEGER DEFAULT 0,
|
||||
tax_amount INTEGER DEFAULT 0,
|
||||
amount_tax_incl INTEGER DEFAULT 0,
|
||||
notes TEXT,
|
||||
settlement_method TEXT,
|
||||
settlement_card_company TEXT,
|
||||
settlement_due_date TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(customer_id) REFERENCES customers(id)
|
||||
)
|
||||
''');
|
||||
await db.execute('CREATE INDEX IF NOT EXISTS idx_sales_entries_date ON sales_entries(issue_date)');
|
||||
await db.execute('CREATE INDEX IF NOT EXISTS idx_sales_entries_status ON sales_entries(status)');
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS sales_line_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
sales_entry_id TEXT NOT NULL,
|
||||
product_id TEXT,
|
||||
description TEXT NOT NULL,
|
||||
quantity INTEGER NOT NULL,
|
||||
unit_price INTEGER NOT NULL,
|
||||
tax_rate REAL DEFAULT 0,
|
||||
line_total INTEGER NOT NULL,
|
||||
cost_amount INTEGER DEFAULT 0,
|
||||
cost_is_provisional INTEGER DEFAULT 0,
|
||||
source_invoice_id TEXT,
|
||||
source_invoice_item_id TEXT,
|
||||
FOREIGN KEY(sales_entry_id) REFERENCES sales_entries(id) ON DELETE CASCADE
|
||||
)
|
||||
''');
|
||||
await db.execute('CREATE INDEX IF NOT EXISTS idx_sales_line_items_entry ON sales_line_items(sales_entry_id)');
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS sales_entry_sources (
|
||||
id TEXT PRIMARY KEY,
|
||||
sales_entry_id TEXT NOT NULL,
|
||||
invoice_id TEXT NOT NULL,
|
||||
imported_at TEXT NOT NULL,
|
||||
invoice_hash_snapshot TEXT,
|
||||
FOREIGN KEY(sales_entry_id) REFERENCES sales_entries(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(invoice_id) REFERENCES invoices(id)
|
||||
)
|
||||
''');
|
||||
await db.execute('CREATE UNIQUE INDEX IF NOT EXISTS idx_sales_entry_sources_unique ON sales_entry_sources(sales_entry_id, invoice_id)');
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS sales_receipts (
|
||||
id TEXT PRIMARY KEY,
|
||||
customer_id TEXT,
|
||||
payment_date TEXT NOT NULL,
|
||||
method TEXT,
|
||||
amount INTEGER NOT NULL,
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(customer_id) REFERENCES customers(id)
|
||||
)
|
||||
''');
|
||||
await db.execute('CREATE INDEX IF NOT EXISTS idx_sales_receipts_date ON sales_receipts(payment_date)');
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS sales_receipt_links (
|
||||
receipt_id TEXT NOT NULL,
|
||||
sales_entry_id TEXT NOT NULL,
|
||||
allocated_amount INTEGER NOT NULL,
|
||||
PRIMARY KEY(receipt_id, sales_entry_id),
|
||||
FOREIGN KEY(receipt_id) REFERENCES sales_receipts(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(sales_entry_id) REFERENCES sales_entries(id) ON DELETE CASCADE
|
||||
)
|
||||
''');
|
||||
}
|
||||
|
||||
Future<void> _createPurchaseEntryTables(Database db) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS purchase_entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
supplier_id TEXT,
|
||||
supplier_name_snapshot TEXT,
|
||||
subject TEXT,
|
||||
issue_date TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
amount_tax_excl INTEGER DEFAULT 0,
|
||||
tax_amount INTEGER DEFAULT 0,
|
||||
amount_tax_incl INTEGER DEFAULT 0,
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(supplier_id) REFERENCES suppliers(id)
|
||||
)
|
||||
''');
|
||||
await db.execute('CREATE INDEX IF NOT EXISTS idx_purchase_entries_supplier ON purchase_entries(supplier_id)');
|
||||
await db.execute('CREATE INDEX IF NOT EXISTS idx_purchase_entries_issue_date ON purchase_entries(issue_date)');
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS purchase_line_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
purchase_entry_id TEXT NOT NULL,
|
||||
product_id TEXT,
|
||||
description TEXT NOT NULL,
|
||||
quantity INTEGER NOT NULL,
|
||||
unit_price INTEGER NOT NULL,
|
||||
tax_rate REAL DEFAULT 0,
|
||||
line_total INTEGER DEFAULT 0,
|
||||
FOREIGN KEY(purchase_entry_id) REFERENCES purchase_entries(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(product_id) REFERENCES products(id)
|
||||
)
|
||||
''');
|
||||
await db.execute('CREATE INDEX IF NOT EXISTS idx_purchase_line_items_entry ON purchase_line_items(purchase_entry_id)');
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS purchase_receipts (
|
||||
id TEXT PRIMARY KEY,
|
||||
supplier_id TEXT,
|
||||
payment_date TEXT NOT NULL,
|
||||
method TEXT,
|
||||
amount INTEGER NOT NULL,
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(supplier_id) REFERENCES suppliers(id)
|
||||
)
|
||||
''');
|
||||
await db.execute('CREATE INDEX IF NOT EXISTS idx_purchase_receipts_supplier ON purchase_receipts(supplier_id)');
|
||||
await db.execute('CREATE INDEX IF NOT EXISTS idx_purchase_receipts_payment_date ON purchase_receipts(payment_date)');
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS purchase_receipt_links (
|
||||
receipt_id TEXT NOT NULL,
|
||||
purchase_entry_id TEXT NOT NULL,
|
||||
allocated_amount INTEGER NOT NULL,
|
||||
PRIMARY KEY(receipt_id, purchase_entry_id),
|
||||
FOREIGN KEY(receipt_id) REFERENCES purchase_receipts(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(purchase_entry_id) REFERENCES purchase_entries(id) ON DELETE CASCADE
|
||||
)
|
||||
''');
|
||||
await db.execute('CREATE INDEX IF NOT EXISTS idx_purchase_receipt_links_entry ON purchase_receipt_links(purchase_entry_id)');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
51
lib/services/database_maintenance_service.dart
Normal file
51
lib/services/database_maintenance_service.dart
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
/// Helper that encapsulates direct SQLite file access so UI layers remain decoupled
|
||||
/// from sqflite APIs. Use this when exporting or backing up the on-device DB file.
|
||||
class DatabaseMaintenanceService {
|
||||
const DatabaseMaintenanceService();
|
||||
|
||||
/// Returns the absolute path to the primary application database file.
|
||||
Future<String> databasePath() async {
|
||||
final dbDir = await getDatabasesPath();
|
||||
return p.join(dbDir, 'gemi_invoice.db');
|
||||
}
|
||||
|
||||
/// Returns the database file if it currently exists on disk.
|
||||
Future<File?> getDatabaseFile() async {
|
||||
final path = await databasePath();
|
||||
final file = File(path);
|
||||
if (await file.exists()) {
|
||||
return file;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Ensures the database directory exists (useful before copying / restoring).
|
||||
Future<Directory> ensureDatabaseDirectory() async {
|
||||
final dbDir = await getDatabasesPath();
|
||||
final dir = Directory(dbDir);
|
||||
if (!await dir.exists()) {
|
||||
await dir.create(recursive: true);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
/// Copies the current database file to the provided destination path.
|
||||
Future<File?> copyDatabaseTo(String destinationPath) async {
|
||||
final file = await getDatabaseFile();
|
||||
if (file == null) return null;
|
||||
final destFile = await File(destinationPath).create(recursive: true);
|
||||
return file.copy(destFile.path);
|
||||
}
|
||||
|
||||
/// Replaces the current database file with the provided source file.
|
||||
Future<void> replaceDatabaseWith(File source) async {
|
||||
final dir = await ensureDatabaseDirectory();
|
||||
final destPath = p.join(dir.path, 'gemi_invoice.db');
|
||||
await source.copy(destPath);
|
||||
}
|
||||
}
|
||||
52
lib/services/debug_webhook_logger.dart
Normal file
52
lib/services/debug_webhook_logger.dart
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../config/app_config.dart';
|
||||
|
||||
class DebugWebhookLogger {
|
||||
const DebugWebhookLogger({http.Client? httpClient}) : _httpClient = httpClient;
|
||||
|
||||
final http.Client? _httpClient;
|
||||
|
||||
bool get _isEnabled => AppConfig.enableDebugWebhookLogging && AppConfig.debugWebhookUrl.isNotEmpty;
|
||||
|
||||
Future<void> sendNodePing({String? note}) async {
|
||||
if (!_isEnabled) return;
|
||||
final client = _httpClient ?? http.Client();
|
||||
try {
|
||||
final hostname = Platform.localHostname;
|
||||
final os = Platform.operatingSystem;
|
||||
final osVersion = Platform.operatingSystemVersion;
|
||||
final timestamp = DateTime.now().toIso8601String();
|
||||
final buffer = StringBuffer()
|
||||
..writeln(':mag: **販売アシスト1号 Debug**')
|
||||
..writeln('- Timestamp: $timestamp')
|
||||
..writeln('- Node: $hostname')
|
||||
..writeln('- OS: $os')
|
||||
..writeln('- OS Version: $osVersion')
|
||||
..writeln('- App Version: ${AppConfig.version}');
|
||||
if (note != null && note.isNotEmpty) {
|
||||
buffer.writeln('- Note: $note');
|
||||
}
|
||||
final payload = jsonEncode({'text': buffer.toString()});
|
||||
final response = await client.post(
|
||||
Uri.parse(AppConfig.debugWebhookUrl),
|
||||
headers: {HttpHeaders.contentTypeHeader: 'application/json'},
|
||||
body: payload,
|
||||
);
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
debugPrint('[DebugWebhook] Failed ${response.statusCode}: ${response.body}');
|
||||
}
|
||||
} catch (err, stack) {
|
||||
debugPrint('[DebugWebhook] Error: $err');
|
||||
debugPrint('$stack');
|
||||
} finally {
|
||||
if (_httpClient == null) {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
34
lib/services/department_repository.dart
Normal file
34
lib/services/department_repository.dart
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
import '../models/department_model.dart';
|
||||
import 'database_helper.dart';
|
||||
|
||||
class DepartmentRepository {
|
||||
DepartmentRepository();
|
||||
|
||||
final DatabaseHelper _dbHelper = DatabaseHelper();
|
||||
|
||||
Future<List<Department>> fetchDepartments({bool includeInactive = true}) async {
|
||||
final db = await _dbHelper.database;
|
||||
final rows = await db.query(
|
||||
'departments',
|
||||
where: includeInactive ? null : 'is_active = 1',
|
||||
orderBy: 'name COLLATE NOCASE ASC',
|
||||
);
|
||||
return rows.map(Department.fromMap).toList();
|
||||
}
|
||||
|
||||
Future<void> saveDepartment(Department department) async {
|
||||
final db = await _dbHelper.database;
|
||||
await db.insert(
|
||||
'departments',
|
||||
department.toMap(),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteDepartment(String departmentId) async {
|
||||
final db = await _dbHelper.database;
|
||||
await db.delete('departments', where: 'id = ?', whereArgs: [departmentId]);
|
||||
}
|
||||
}
|
||||
101
lib/services/inventory_repository.dart
Normal file
101
lib/services/inventory_repository.dart
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/inventory_models.dart';
|
||||
import 'database_helper.dart';
|
||||
|
||||
class InventoryRepository {
|
||||
InventoryRepository();
|
||||
|
||||
final DatabaseHelper _dbHelper = DatabaseHelper();
|
||||
final Uuid _uuid = const Uuid();
|
||||
|
||||
Future<List<InventorySummary>> fetchSummaries({bool includeHidden = false}) async {
|
||||
final db = await _dbHelper.database;
|
||||
final whereClauses = <String>[];
|
||||
if (!includeHidden) {
|
||||
whereClauses.add('COALESCE(mh.is_hidden, p.is_hidden, 0) = 0');
|
||||
}
|
||||
final whereSql = whereClauses.isEmpty ? '' : 'WHERE ${whereClauses.join(' AND ')}';
|
||||
|
||||
final rows = await db.rawQuery('''
|
||||
SELECT p.id, p.name, p.category, p.default_unit_price, p.stock_quantity,
|
||||
MAX(m.created_at) AS last_movement_at
|
||||
FROM products p
|
||||
LEFT JOIN master_hidden mh ON mh.master_type = 'product' AND mh.master_id = p.id
|
||||
LEFT JOIN inventory_movements m ON m.product_id = p.id
|
||||
$whereSql
|
||||
GROUP BY p.id
|
||||
ORDER BY p.name COLLATE NOCASE ASC
|
||||
''');
|
||||
|
||||
return rows.map((row) {
|
||||
return InventorySummary(
|
||||
productId: row['id'] as String,
|
||||
productName: row['name'] as String? ?? '-',
|
||||
stockQuantity: row['stock_quantity'] as int? ?? 0,
|
||||
category: row['category'] as String?,
|
||||
defaultUnitPrice: row['default_unit_price'] as int?,
|
||||
lastMovementAt: row['last_movement_at'] != null ? DateTime.parse(row['last_movement_at'] as String) : null,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Future<List<InventoryMovement>> fetchMovements(String productId, {int limit = 50}) async {
|
||||
final db = await _dbHelper.database;
|
||||
final rows = await db.query(
|
||||
'inventory_movements',
|
||||
where: 'product_id = ?',
|
||||
whereArgs: [productId],
|
||||
orderBy: 'created_at DESC',
|
||||
limit: limit,
|
||||
);
|
||||
return rows.map(InventoryMovement.fromMap).toList();
|
||||
}
|
||||
|
||||
Future<InventorySummary> recordMovement({
|
||||
required String productId,
|
||||
required InventoryMovementType type,
|
||||
required int quantity,
|
||||
required int quantityDelta,
|
||||
String? reference,
|
||||
String? notes,
|
||||
}) async {
|
||||
final db = await _dbHelper.database;
|
||||
late InventorySummary summary;
|
||||
await db.transaction((txn) async {
|
||||
final productRows = await txn.query('products', where: 'id = ?', whereArgs: [productId], limit: 1);
|
||||
if (productRows.isEmpty) {
|
||||
throw StateError('product not found: $productId');
|
||||
}
|
||||
final product = productRows.first;
|
||||
final currentStock = product['stock_quantity'] as int? ?? 0;
|
||||
final nextStock = currentStock + quantityDelta;
|
||||
final now = DateTime.now();
|
||||
final movement = InventoryMovement(
|
||||
id: _uuid.v4(),
|
||||
productId: productId,
|
||||
productNameSnapshot: product['name'] as String? ?? '-',
|
||||
type: type,
|
||||
quantity: quantity,
|
||||
quantityDelta: quantityDelta,
|
||||
reference: reference,
|
||||
notes: notes,
|
||||
createdAt: now,
|
||||
);
|
||||
|
||||
await txn.insert('inventory_movements', movement.toMap(), conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
await txn.update('products', {'stock_quantity': nextStock}, where: 'id = ?', whereArgs: [productId]);
|
||||
|
||||
summary = InventorySummary(
|
||||
productId: productId,
|
||||
productName: product['name'] as String? ?? '-',
|
||||
stockQuantity: nextStock,
|
||||
category: product['category'] as String?,
|
||||
defaultUnitPrice: product['default_unit_price'] as int?,
|
||||
lastMovementAt: now,
|
||||
);
|
||||
});
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
51
lib/services/inventory_service.dart
Normal file
51
lib/services/inventory_service.dart
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import '../models/inventory_models.dart';
|
||||
import 'inventory_repository.dart';
|
||||
|
||||
class InventoryService {
|
||||
InventoryService({InventoryRepository? repository}) : _repository = repository ?? InventoryRepository();
|
||||
|
||||
final InventoryRepository _repository;
|
||||
|
||||
Future<List<InventorySummary>> fetchSummaries({bool includeHidden = false}) {
|
||||
return _repository.fetchSummaries(includeHidden: includeHidden);
|
||||
}
|
||||
|
||||
Future<List<InventoryMovement>> fetchMovements(String productId, {int limit = 50}) {
|
||||
return _repository.fetchMovements(productId, limit: limit);
|
||||
}
|
||||
|
||||
Future<InventorySummary> recordManualMovement({
|
||||
required String productId,
|
||||
required InventoryMovementType type,
|
||||
required int quantity,
|
||||
String? reference,
|
||||
String? notes,
|
||||
}) {
|
||||
if (quantity == 0 && type != InventoryMovementType.adjustment) {
|
||||
throw ArgumentError('quantity must be non-zero');
|
||||
}
|
||||
final normalizedQuantity = quantity.abs();
|
||||
final delta = _calculateDelta(type, quantity);
|
||||
final recordedQuantity = type == InventoryMovementType.adjustment ? normalizedQuantity : normalizedQuantity;
|
||||
|
||||
return _repository.recordMovement(
|
||||
productId: productId,
|
||||
type: type,
|
||||
quantity: recordedQuantity,
|
||||
quantityDelta: delta,
|
||||
reference: reference,
|
||||
notes: notes,
|
||||
);
|
||||
}
|
||||
|
||||
int _calculateDelta(InventoryMovementType type, int quantity) {
|
||||
switch (type) {
|
||||
case InventoryMovementType.receipt:
|
||||
return quantity.abs();
|
||||
case InventoryMovementType.issue:
|
||||
return -quantity.abs();
|
||||
case InventoryMovementType.adjustment:
|
||||
return quantity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,9 +3,12 @@ import 'dart:convert';
|
|||
import 'package:crypto/crypto.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../models/hash_chain_models.dart';
|
||||
import '../models/invoice_models.dart';
|
||||
import '../models/customer_model.dart';
|
||||
import '../models/customer_contact.dart';
|
||||
import '../models/sales_summary.dart';
|
||||
import 'database_helper.dart';
|
||||
import 'activity_log_repository.dart';
|
||||
import 'company_repository.dart';
|
||||
|
|
@ -60,6 +63,29 @@ class InvoiceRepository {
|
|||
metaHash: null,
|
||||
);
|
||||
|
||||
Invoice savingWithChain = savingWithContact;
|
||||
if (!savingWithContact.isDraft) {
|
||||
final previousEntry = await _fetchLatestChainEntry(txn);
|
||||
final previousHash = previousEntry?['chain_hash'] as String?;
|
||||
final computedHash = _computeChainHash(
|
||||
previousHash,
|
||||
savingWithContact.contentHash,
|
||||
savingWithContact.updatedAt,
|
||||
savingWithContact.id,
|
||||
);
|
||||
savingWithChain = savingWithContact.copyWith(
|
||||
previousChainHash: previousHash,
|
||||
chainHash: computedHash,
|
||||
chainStatus: 'pending',
|
||||
);
|
||||
} else {
|
||||
savingWithChain = savingWithContact.copyWith(
|
||||
previousChainHash: null,
|
||||
chainHash: null,
|
||||
chainStatus: 'draft',
|
||||
);
|
||||
}
|
||||
|
||||
// 在庫の調整(更新の場合、以前の数量を戻してから新しい数量を引く)
|
||||
final List<Map<String, dynamic>> oldItems = await txn.query(
|
||||
'invoice_items',
|
||||
|
|
@ -80,7 +106,7 @@ class InvoiceRepository {
|
|||
// 伝票ヘッダーの保存
|
||||
await txn.insert(
|
||||
'invoices',
|
||||
savingWithContact.toMap(),
|
||||
savingWithChain.toMap(),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
|
||||
|
|
@ -183,6 +209,9 @@ class InvoiceRepository {
|
|||
companySealHash: iMap['company_seal_hash'],
|
||||
metaJson: iMap['meta_json'],
|
||||
metaHash: iMap['meta_hash'],
|
||||
previousChainHash: iMap['previous_chain_hash'],
|
||||
chainHash: iMap['chain_hash'],
|
||||
chainStatus: iMap['chain_status'] ?? 'pending',
|
||||
));
|
||||
}
|
||||
return invoices;
|
||||
|
|
@ -296,33 +325,240 @@ class InvoiceRepository {
|
|||
return verifyInvoiceMeta(target);
|
||||
}
|
||||
|
||||
Future<Map<String, int>> getMonthlySales(int year) async {
|
||||
Future<HashChainVerificationResult> verifyHashChain() async {
|
||||
final db = await _dbHelper.database;
|
||||
final String yearStr = year.toString();
|
||||
final List<Map<String, dynamic>> results = await db.rawQuery('''
|
||||
SELECT strftime('%m', date) as month, SUM(total_amount) as total
|
||||
final now = DateTime.now();
|
||||
return await db.transaction((txn) async {
|
||||
final rows = await txn.query(
|
||||
'invoices',
|
||||
columns: [
|
||||
'id',
|
||||
'updated_at',
|
||||
'content_hash',
|
||||
'previous_chain_hash',
|
||||
'chain_hash',
|
||||
'document_type',
|
||||
'terminal_id',
|
||||
'date',
|
||||
],
|
||||
where: 'COALESCE(is_draft, 0) = 0',
|
||||
orderBy: 'updated_at ASC, id ASC',
|
||||
);
|
||||
|
||||
String? expectedPreviousHash;
|
||||
final breaks = <HashChainBreak>[];
|
||||
|
||||
for (final row in rows) {
|
||||
final invoiceId = row['id'] as String;
|
||||
final invoiceNumber = _buildInvoiceNumberFromRow(row);
|
||||
final updatedAtStr = row['updated_at'] as String;
|
||||
final updatedAt = DateTime.parse(updatedAtStr);
|
||||
final contentHash = row['content_hash'] as String? ?? '';
|
||||
final actualPrev = row['previous_chain_hash'] as String?;
|
||||
final actualHash = row['chain_hash'] as String?;
|
||||
final expectedHash = _computeChainHash(expectedPreviousHash, contentHash, updatedAt, invoiceId);
|
||||
|
||||
bool broken = false;
|
||||
if ((expectedPreviousHash ?? '') != (actualPrev ?? '')) {
|
||||
final info = HashChainBreak(
|
||||
invoiceId: invoiceId,
|
||||
invoiceNumber: invoiceNumber,
|
||||
issue: 'previous_hash_mismatch',
|
||||
expectedHash: expectedHash,
|
||||
actualHash: actualHash,
|
||||
expectedPreviousHash: expectedPreviousHash,
|
||||
actualPreviousHash: actualPrev,
|
||||
);
|
||||
breaks.add(info);
|
||||
await _logChainBreak(info);
|
||||
broken = true;
|
||||
}
|
||||
|
||||
if (actualHash == null || actualHash != expectedHash) {
|
||||
final info = HashChainBreak(
|
||||
invoiceId: invoiceId,
|
||||
invoiceNumber: invoiceNumber,
|
||||
issue: actualHash == null ? 'hash_missing' : 'hash_mismatch',
|
||||
expectedHash: expectedHash,
|
||||
actualHash: actualHash,
|
||||
expectedPreviousHash: expectedPreviousHash,
|
||||
actualPreviousHash: actualPrev,
|
||||
);
|
||||
breaks.add(info);
|
||||
await _logChainBreak(info);
|
||||
broken = true;
|
||||
}
|
||||
|
||||
await txn.update(
|
||||
'invoices',
|
||||
{'chain_status': broken ? 'broken' : 'healthy'},
|
||||
where: 'id = ?',
|
||||
whereArgs: [invoiceId],
|
||||
);
|
||||
|
||||
expectedPreviousHash = expectedHash;
|
||||
}
|
||||
|
||||
await _logRepo.logAction(
|
||||
action: 'HASH_CHAIN_VERIFY',
|
||||
targetType: 'INVOICE',
|
||||
details: jsonEncode({
|
||||
'checkedCount': rows.length,
|
||||
'breakCount': breaks.length,
|
||||
'verifiedAt': now.toIso8601String(),
|
||||
}),
|
||||
);
|
||||
|
||||
return HashChainVerificationResult(
|
||||
isHealthy: breaks.isEmpty,
|
||||
checkedCount: rows.length,
|
||||
verifiedAt: now,
|
||||
breaks: breaks,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<Map<String, Object?>?> _fetchLatestChainEntry(DatabaseExecutor txn) async {
|
||||
final rows = await txn.query(
|
||||
'invoices',
|
||||
columns: ['chain_hash'],
|
||||
where: 'COALESCE(is_draft, 0) = 0 AND chain_hash IS NOT NULL',
|
||||
orderBy: 'updated_at DESC, id DESC',
|
||||
limit: 1,
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
return rows.first;
|
||||
}
|
||||
|
||||
String _computeChainHash(String? previousHash, String contentHash, DateTime updatedAt, String id) {
|
||||
final payload = '${previousHash ?? ''}|$contentHash|${updatedAt.toIso8601String()}|$id';
|
||||
return sha256.convert(utf8.encode(payload)).toString();
|
||||
}
|
||||
|
||||
Future<void> _logChainBreak(HashChainBreak info) async {
|
||||
await _logRepo.logAction(
|
||||
action: 'HASH_CHAIN_BREAK',
|
||||
targetType: 'INVOICE',
|
||||
targetId: info.invoiceId,
|
||||
details: jsonEncode({
|
||||
'issue': info.issue,
|
||||
'invoiceNumber': info.invoiceNumber,
|
||||
'expectedHash': info.expectedHash,
|
||||
'actualHash': info.actualHash,
|
||||
'expectedPreviousHash': info.expectedPreviousHash,
|
||||
'actualPreviousHash': info.actualPreviousHash,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
String _buildInvoiceNumberFromRow(Map<String, Object?> row) {
|
||||
final docTypeName = row['document_type'] as String? ?? DocumentType.invoice.name;
|
||||
DocumentType docType;
|
||||
try {
|
||||
docType = DocumentType.values.firstWhere((e) => e.name == docTypeName);
|
||||
} catch (_) {
|
||||
docType = DocumentType.invoice;
|
||||
}
|
||||
final prefix = _documentPrefix(docType);
|
||||
final terminalId = row['terminal_id'] as String? ?? 'T1';
|
||||
final dateStr = row['date'] as String? ?? row['updated_at'] as String;
|
||||
final date = DateTime.tryParse(dateStr) ?? DateTime.now();
|
||||
final id = row['id'] as String;
|
||||
final suffix = id.length >= 4 ? id.substring(id.length - 4) : id;
|
||||
final formatter = DateFormat('yyyyMMdd');
|
||||
return '$prefix-$terminalId-${formatter.format(date)}-$suffix';
|
||||
}
|
||||
|
||||
String _documentPrefix(DocumentType type) {
|
||||
switch (type) {
|
||||
case DocumentType.estimation:
|
||||
return 'EST';
|
||||
case DocumentType.delivery:
|
||||
return 'DEL';
|
||||
case DocumentType.invoice:
|
||||
return 'INV';
|
||||
case DocumentType.receipt:
|
||||
return 'REC';
|
||||
}
|
||||
}
|
||||
|
||||
Future<SalesSummary> fetchSalesSummary({
|
||||
required int year,
|
||||
DocumentType? documentType,
|
||||
bool includeDrafts = false,
|
||||
int topCustomerLimit = 5,
|
||||
}) async {
|
||||
final db = await _dbHelper.database;
|
||||
final baseArgs = <Object?>[year.toString()];
|
||||
final whereBuffer = StringBuffer("strftime('%Y', date) = ?");
|
||||
if (documentType != null) {
|
||||
whereBuffer.write(" AND document_type = ?");
|
||||
baseArgs.add(documentType.name);
|
||||
}
|
||||
if (!includeDrafts) {
|
||||
whereBuffer.write(" AND COALESCE(is_draft, 0) = 0");
|
||||
}
|
||||
final whereClause = whereBuffer.toString();
|
||||
|
||||
final monthlyRows = await db.rawQuery(
|
||||
'''
|
||||
SELECT CAST(strftime('%m', date) AS INTEGER) as month, SUM(total_amount) as total
|
||||
FROM invoices
|
||||
WHERE strftime('%Y', date) = ? AND document_type = 'invoice'
|
||||
WHERE $whereClause
|
||||
GROUP BY month
|
||||
ORDER BY month ASC
|
||||
''', [yearStr]);
|
||||
'''.
|
||||
trim(),
|
||||
List<Object?>.from(baseArgs),
|
||||
);
|
||||
|
||||
Map<String, int> monthlyTotal = {};
|
||||
for (var r in results) {
|
||||
monthlyTotal[r['month']] = (r['total'] as num).toInt();
|
||||
final monthlyTotals = <int, int>{};
|
||||
for (final row in monthlyRows) {
|
||||
if (row['month'] == null || row['total'] == null) continue;
|
||||
monthlyTotals[(row['month'] as num).toInt()] = (row['total'] as num).toInt();
|
||||
}
|
||||
return monthlyTotal;
|
||||
|
||||
final yearlyTotal = monthlyTotals.values.fold<int>(0, (sum, value) => sum + value);
|
||||
|
||||
final customerRows = await db.rawQuery(
|
||||
'''
|
||||
SELECT COALESCE(customer_formal_name, customer_id) as customer_name, SUM(total_amount) as total
|
||||
FROM invoices
|
||||
WHERE $whereClause
|
||||
GROUP BY customer_name
|
||||
ORDER BY total DESC
|
||||
LIMIT ?
|
||||
'''.
|
||||
trim(),
|
||||
[...baseArgs, topCustomerLimit],
|
||||
);
|
||||
|
||||
final customerStats = customerRows
|
||||
.where((row) => row['customer_name'] != null && row['total'] != null)
|
||||
.map(
|
||||
(row) => SalesCustomerStat(
|
||||
customerName: row['customer_name'] as String,
|
||||
totalAmount: (row['total'] as num).toInt(),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
return SalesSummary(
|
||||
year: year,
|
||||
documentType: documentType,
|
||||
monthlyTotals: monthlyTotals,
|
||||
yearlyTotal: yearlyTotal,
|
||||
customerStats: customerStats,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, int>> getMonthlySales(int year) async {
|
||||
final summary = await fetchSalesSummary(year: year);
|
||||
return summary.monthlyTotals.map((key, value) => MapEntry(key.toString().padLeft(2, '0'), value));
|
||||
}
|
||||
|
||||
Future<int> getYearlyTotal(int year) async {
|
||||
final db = await _dbHelper.database;
|
||||
final List<Map<String, dynamic>> results = await db.rawQuery('''
|
||||
SELECT SUM(total_amount) as total
|
||||
FROM invoices
|
||||
WHERE strftime('%Y', date) = ? AND document_type = 'invoice'
|
||||
''', [year.toString()]);
|
||||
|
||||
if (results.isEmpty || results.first['total'] == null) return 0;
|
||||
return (results.first['total'] as num).toInt();
|
||||
final summary = await fetchSalesSummary(year: year);
|
||||
return summary.yearlyTotal;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
183
lib/services/order_service.dart
Normal file
183
lib/services/order_service.dart
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/order_models.dart';
|
||||
import 'company_profile_service.dart';
|
||||
import 'sales_order_repository.dart';
|
||||
|
||||
class SalesOrderLineInput {
|
||||
const SalesOrderLineInput({
|
||||
required this.description,
|
||||
required this.quantity,
|
||||
required this.unitPrice,
|
||||
this.productId,
|
||||
this.taxRate,
|
||||
});
|
||||
|
||||
final String description;
|
||||
final int quantity;
|
||||
final int unitPrice;
|
||||
final String? productId;
|
||||
final double? taxRate;
|
||||
}
|
||||
|
||||
class SalesOrderService {
|
||||
SalesOrderService({
|
||||
SalesOrderRepository? repository,
|
||||
CompanyProfileService? companyProfileService,
|
||||
}) : _repository = repository ?? SalesOrderRepository(),
|
||||
_companyProfileService = companyProfileService ?? CompanyProfileService();
|
||||
|
||||
final SalesOrderRepository _repository;
|
||||
final CompanyProfileService _companyProfileService;
|
||||
final Uuid _uuid = const Uuid();
|
||||
|
||||
static const Map<SalesOrderStatus, List<SalesOrderStatus>> _transitions = {
|
||||
SalesOrderStatus.draft: [SalesOrderStatus.confirmed, SalesOrderStatus.cancelled],
|
||||
SalesOrderStatus.confirmed: [SalesOrderStatus.picking, SalesOrderStatus.cancelled],
|
||||
SalesOrderStatus.picking: [SalesOrderStatus.shipped, SalesOrderStatus.cancelled],
|
||||
SalesOrderStatus.shipped: [SalesOrderStatus.closed],
|
||||
SalesOrderStatus.closed: [],
|
||||
SalesOrderStatus.cancelled: [],
|
||||
};
|
||||
|
||||
Future<List<SalesOrder>> fetchOrders({SalesOrderStatus? status}) {
|
||||
return _repository.fetchOrders(status: status);
|
||||
}
|
||||
|
||||
Future<SalesOrder> createOrder({
|
||||
required String customerId,
|
||||
required String customerName,
|
||||
List<SalesOrderLineInput> lines = const [],
|
||||
DateTime? requestedShipDate,
|
||||
String? notes,
|
||||
String? assignedTo,
|
||||
}) async {
|
||||
final profile = await _companyProfileService.loadProfile();
|
||||
final now = DateTime.now();
|
||||
final orderId = _uuid.v4();
|
||||
final lineItems = _buildItems(orderId, lines);
|
||||
final order = SalesOrder(
|
||||
id: orderId,
|
||||
orderNumber: _generateOrderNumber(now),
|
||||
customerId: customerId,
|
||||
customerNameSnapshot: customerName,
|
||||
orderDate: now,
|
||||
requestedShipDate: requestedShipDate,
|
||||
status: SalesOrderStatus.draft,
|
||||
subtotal: 0,
|
||||
taxAmount: 0,
|
||||
totalAmount: 0,
|
||||
notes: notes,
|
||||
assignedTo: assignedTo,
|
||||
workflowStage: _workflowStage(SalesOrderStatus.draft),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
items: lineItems,
|
||||
).recalculateTotals(defaultTaxRate: profile.taxRate);
|
||||
|
||||
await _repository.upsertOrder(order);
|
||||
return order;
|
||||
}
|
||||
|
||||
Future<SalesOrder> updateOrder(
|
||||
SalesOrder order, {
|
||||
List<SalesOrderLineInput>? replacedLines,
|
||||
DateTime? requestedShipDate,
|
||||
String? notes,
|
||||
String? assignedTo,
|
||||
}) async {
|
||||
final profile = await _companyProfileService.loadProfile();
|
||||
final now = DateTime.now();
|
||||
final nextItems = replacedLines != null ? _buildItems(order.id, replacedLines) : order.items;
|
||||
final updated = order
|
||||
.copyWith(
|
||||
requestedShipDate: requestedShipDate ?? order.requestedShipDate,
|
||||
notes: notes ?? order.notes,
|
||||
assignedTo: assignedTo ?? order.assignedTo,
|
||||
updatedAt: now,
|
||||
items: nextItems,
|
||||
)
|
||||
.recalculateTotals(defaultTaxRate: profile.taxRate);
|
||||
await _repository.upsertOrder(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
Future<SalesOrder> transitionStatus(String orderId, SalesOrderStatus nextStatus, {bool force = false}) async {
|
||||
final order = await _repository.findById(orderId);
|
||||
if (order == null) {
|
||||
throw StateError('order not found: $orderId');
|
||||
}
|
||||
if (!force && !_canTransition(order.status, nextStatus)) {
|
||||
throw StateError('invalid transition ${order.status.name} -> ${nextStatus.name}');
|
||||
}
|
||||
final now = DateTime.now();
|
||||
final updated = order.copyWith(
|
||||
status: nextStatus,
|
||||
workflowStage: _workflowStage(nextStatus),
|
||||
updatedAt: now,
|
||||
);
|
||||
await _repository.upsertOrder(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
Future<SalesOrder> advanceStatus(String orderId) async {
|
||||
final order = await _repository.findById(orderId);
|
||||
if (order == null) {
|
||||
throw StateError('order not found: $orderId');
|
||||
}
|
||||
final candidates = _transitions[order.status];
|
||||
if (candidates == null || candidates.isEmpty) {
|
||||
return order;
|
||||
}
|
||||
return transitionStatus(orderId, candidates.first);
|
||||
}
|
||||
|
||||
bool _canTransition(SalesOrderStatus current, SalesOrderStatus next) {
|
||||
final allowed = _transitions[current];
|
||||
return allowed?.contains(next) ?? false;
|
||||
}
|
||||
|
||||
List<SalesOrderStatus> nextStatuses(SalesOrderStatus current) {
|
||||
return List.unmodifiable(_transitions[current] ?? const []);
|
||||
}
|
||||
|
||||
List<SalesOrderItem> _buildItems(String orderId, List<SalesOrderLineInput> lines) {
|
||||
return lines.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final line = entry.value;
|
||||
return SalesOrderItem(
|
||||
id: _uuid.v4(),
|
||||
orderId: orderId,
|
||||
productId: line.productId,
|
||||
description: line.description,
|
||||
quantity: line.quantity,
|
||||
unitPrice: line.unitPrice,
|
||||
taxRate: line.taxRate ?? 0,
|
||||
sortIndex: index,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
String _generateOrderNumber(DateTime timestamp) {
|
||||
final datePart = '${timestamp.year}${timestamp.month.toString().padLeft(2, '0')}${timestamp.day.toString().padLeft(2, '0')}';
|
||||
final timePart = '${timestamp.hour.toString().padLeft(2, '0')}${timestamp.minute.toString().padLeft(2, '0')}';
|
||||
return 'SO$datePart-$timePart-${timestamp.millisecondsSinceEpoch % 1000}'.toUpperCase();
|
||||
}
|
||||
|
||||
String _workflowStage(SalesOrderStatus status) {
|
||||
switch (status) {
|
||||
case SalesOrderStatus.draft:
|
||||
return 'order';
|
||||
case SalesOrderStatus.confirmed:
|
||||
return 'ready';
|
||||
case SalesOrderStatus.picking:
|
||||
return 'picking';
|
||||
case SalesOrderStatus.shipped:
|
||||
return 'shipping';
|
||||
case SalesOrderStatus.closed:
|
||||
return 'closed';
|
||||
case SalesOrderStatus.cancelled:
|
||||
return 'cancelled';
|
||||
}
|
||||
}
|
||||
}
|
||||
66
lib/services/purchase_entry_repository.dart
Normal file
66
lib/services/purchase_entry_repository.dart
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
import '../models/purchase_entry_models.dart';
|
||||
import 'database_helper.dart';
|
||||
|
||||
class PurchaseEntryRepository {
|
||||
PurchaseEntryRepository();
|
||||
|
||||
final DatabaseHelper _dbHelper = DatabaseHelper();
|
||||
|
||||
Future<void> upsertEntry(PurchaseEntry entry) async {
|
||||
final db = await _dbHelper.database;
|
||||
await db.transaction((txn) async {
|
||||
await txn.insert('purchase_entries', entry.toMap(), conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
await txn.delete('purchase_line_items', where: 'purchase_entry_id = ?', whereArgs: [entry.id]);
|
||||
for (final item in entry.items) {
|
||||
await txn.insert('purchase_line_items', item.toMap(), conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<PurchaseEntry?> findById(String id) async {
|
||||
final db = await _dbHelper.database;
|
||||
final rows = await db.query('purchase_entries', where: 'id = ?', whereArgs: [id], limit: 1);
|
||||
if (rows.isEmpty) return null;
|
||||
final items = await _fetchItems(db, id);
|
||||
return PurchaseEntry.fromMap(rows.first, items: items);
|
||||
}
|
||||
|
||||
Future<List<PurchaseEntry>> fetchEntries({PurchaseEntryStatus? status, int? limit}) async {
|
||||
final db = await _dbHelper.database;
|
||||
final where = <String>[];
|
||||
final args = <Object?>[];
|
||||
if (status != null) {
|
||||
where.add('status = ?');
|
||||
args.add(status.name);
|
||||
}
|
||||
final rows = await db.query(
|
||||
'purchase_entries',
|
||||
where: where.isEmpty ? null : where.join(' AND '),
|
||||
whereArgs: where.isEmpty ? null : args,
|
||||
orderBy: 'issue_date DESC, updated_at DESC',
|
||||
limit: limit,
|
||||
);
|
||||
final result = <PurchaseEntry>[];
|
||||
for (final row in rows) {
|
||||
final items = await _fetchItems(db, row['id'] as String);
|
||||
result.add(PurchaseEntry.fromMap(row, items: items));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> deleteEntry(String id) async {
|
||||
final db = await _dbHelper.database;
|
||||
await db.transaction((txn) async {
|
||||
await txn.delete('purchase_line_items', where: 'purchase_entry_id = ?', whereArgs: [id]);
|
||||
await txn.delete('purchase_receipt_links', where: 'purchase_entry_id = ?', whereArgs: [id]);
|
||||
await txn.delete('purchase_entries', where: 'id = ?', whereArgs: [id]);
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<PurchaseLineItem>> _fetchItems(DatabaseExecutor db, String entryId) async {
|
||||
final rows = await db.query('purchase_line_items', where: 'purchase_entry_id = ?', whereArgs: [entryId]);
|
||||
return rows.map(PurchaseLineItem.fromMap).toList();
|
||||
}
|
||||
}
|
||||
61
lib/services/purchase_entry_service.dart
Normal file
61
lib/services/purchase_entry_service.dart
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/purchase_entry_models.dart';
|
||||
import 'purchase_entry_repository.dart';
|
||||
import 'purchase_receipt_repository.dart';
|
||||
|
||||
class PurchaseEntryService {
|
||||
PurchaseEntryService({
|
||||
PurchaseEntryRepository? entryRepository,
|
||||
PurchaseReceiptRepository? receiptRepository,
|
||||
}) : _entryRepository = entryRepository ?? PurchaseEntryRepository(),
|
||||
_receiptRepository = receiptRepository ?? PurchaseReceiptRepository();
|
||||
|
||||
final PurchaseEntryRepository _entryRepository;
|
||||
final PurchaseReceiptRepository _receiptRepository;
|
||||
final Uuid _uuid = const Uuid();
|
||||
|
||||
Future<List<PurchaseEntry>> fetchEntries({PurchaseEntryStatus? status, int? limit}) {
|
||||
return _entryRepository.fetchEntries(status: status, limit: limit);
|
||||
}
|
||||
|
||||
Future<PurchaseEntry?> findById(String id) {
|
||||
return _entryRepository.findById(id);
|
||||
}
|
||||
|
||||
Future<void> deleteEntry(String id) {
|
||||
return _entryRepository.deleteEntry(id);
|
||||
}
|
||||
|
||||
Future<PurchaseEntry> saveEntry(PurchaseEntry entry) async {
|
||||
final updated = entry.recalcTotals().copyWith(updatedAt: DateTime.now());
|
||||
await _entryRepository.upsertEntry(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
Future<PurchaseEntry> createQuickEntry({
|
||||
String? supplierId,
|
||||
String? supplierNameSnapshot,
|
||||
String? subject,
|
||||
DateTime? issueDate,
|
||||
List<PurchaseLineItem>? items,
|
||||
}) async {
|
||||
final now = DateTime.now();
|
||||
final entry = PurchaseEntry(
|
||||
id: _uuid.v4(),
|
||||
supplierId: supplierId,
|
||||
supplierNameSnapshot: supplierNameSnapshot,
|
||||
subject: subject,
|
||||
issueDate: issueDate ?? now,
|
||||
status: PurchaseEntryStatus.draft,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
items: items ?? const [],
|
||||
);
|
||||
return saveEntry(entry);
|
||||
}
|
||||
|
||||
Future<Map<String, int>> fetchAllocatedTotals(Iterable<String> entryIds) {
|
||||
return _receiptRepository.fetchAllocatedTotals(entryIds);
|
||||
}
|
||||
}
|
||||
81
lib/services/purchase_receipt_repository.dart
Normal file
81
lib/services/purchase_receipt_repository.dart
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
import '../models/purchase_entry_models.dart';
|
||||
import 'database_helper.dart';
|
||||
|
||||
class PurchaseReceiptRepository {
|
||||
PurchaseReceiptRepository();
|
||||
|
||||
final DatabaseHelper _dbHelper = DatabaseHelper();
|
||||
|
||||
Future<void> upsertReceipt(PurchaseReceipt receipt, List<PurchaseReceiptLink> links) async {
|
||||
final db = await _dbHelper.database;
|
||||
await db.transaction((txn) async {
|
||||
await txn.insert('purchase_receipts', receipt.toMap(), conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
await txn.delete('purchase_receipt_links', where: 'receipt_id = ?', whereArgs: [receipt.id]);
|
||||
for (final link in links) {
|
||||
await txn.insert('purchase_receipt_links', link.toMap(), conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<PurchaseReceipt>> fetchReceipts({DateTime? startDate, DateTime? endDate}) async {
|
||||
final db = await _dbHelper.database;
|
||||
final where = <String>[];
|
||||
final args = <Object?>[];
|
||||
if (startDate != null) {
|
||||
where.add('payment_date >= ?');
|
||||
args.add(startDate.toIso8601String());
|
||||
}
|
||||
if (endDate != null) {
|
||||
where.add('payment_date <= ?');
|
||||
args.add(endDate.toIso8601String());
|
||||
}
|
||||
final rows = await db.query(
|
||||
'purchase_receipts',
|
||||
where: where.isEmpty ? null : where.join(' AND '),
|
||||
whereArgs: where.isEmpty ? null : args,
|
||||
orderBy: 'payment_date DESC, updated_at DESC',
|
||||
);
|
||||
return rows.map(PurchaseReceipt.fromMap).toList();
|
||||
}
|
||||
|
||||
Future<PurchaseReceipt?> findById(String id) async {
|
||||
final db = await _dbHelper.database;
|
||||
final rows = await db.query('purchase_receipts', where: 'id = ?', whereArgs: [id], limit: 1);
|
||||
if (rows.isEmpty) return null;
|
||||
return PurchaseReceipt.fromMap(rows.first);
|
||||
}
|
||||
|
||||
Future<List<PurchaseReceiptLink>> fetchLinks(String receiptId) async {
|
||||
final db = await _dbHelper.database;
|
||||
final rows = await db.query('purchase_receipt_links', where: 'receipt_id = ?', whereArgs: [receiptId]);
|
||||
return rows.map(PurchaseReceiptLink.fromMap).toList();
|
||||
}
|
||||
|
||||
Future<Map<String, int>> fetchAllocatedTotals(Iterable<String> purchaseEntryIds) async {
|
||||
final ids = purchaseEntryIds.where((id) => id.isNotEmpty).toSet().toList();
|
||||
if (ids.isEmpty) return {};
|
||||
final db = await _dbHelper.database;
|
||||
final placeholders = List.filled(ids.length, '?').join(',');
|
||||
final rows = await db.rawQuery(
|
||||
'SELECT purchase_entry_id, SUM(allocated_amount) AS total FROM purchase_receipt_links WHERE purchase_entry_id IN ($placeholders) GROUP BY purchase_entry_id',
|
||||
ids,
|
||||
);
|
||||
final result = <String, int>{};
|
||||
for (final row in rows) {
|
||||
final entryId = row['purchase_entry_id'] as String?;
|
||||
if (entryId == null) continue;
|
||||
result[entryId] = (row['total'] as num?)?.toInt() ?? 0;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> deleteReceipt(String id) async {
|
||||
final db = await _dbHelper.database;
|
||||
await db.transaction((txn) async {
|
||||
await txn.delete('purchase_receipt_links', where: 'receipt_id = ?', whereArgs: [id]);
|
||||
await txn.delete('purchase_receipts', where: 'id = ?', whereArgs: [id]);
|
||||
});
|
||||
}
|
||||
}
|
||||
135
lib/services/purchase_receipt_service.dart
Normal file
135
lib/services/purchase_receipt_service.dart
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/purchase_entry_models.dart';
|
||||
import 'purchase_entry_repository.dart';
|
||||
import 'purchase_receipt_repository.dart';
|
||||
|
||||
class PurchaseReceiptService {
|
||||
PurchaseReceiptService({
|
||||
PurchaseReceiptRepository? receiptRepository,
|
||||
PurchaseEntryRepository? entryRepository,
|
||||
}) : _receiptRepository = receiptRepository ?? PurchaseReceiptRepository(),
|
||||
_entryRepository = entryRepository ?? PurchaseEntryRepository();
|
||||
|
||||
final PurchaseReceiptRepository _receiptRepository;
|
||||
final PurchaseEntryRepository _entryRepository;
|
||||
final Uuid _uuid = const Uuid();
|
||||
|
||||
Future<List<PurchaseReceipt>> fetchReceipts({DateTime? startDate, DateTime? endDate}) {
|
||||
return _receiptRepository.fetchReceipts(startDate: startDate, endDate: endDate);
|
||||
}
|
||||
|
||||
Future<Map<String, int>> fetchAllocatedTotals(Iterable<String> entryIds) {
|
||||
return _receiptRepository.fetchAllocatedTotals(entryIds);
|
||||
}
|
||||
|
||||
Future<List<PurchaseReceiptLink>> fetchLinks(String receiptId) {
|
||||
return _receiptRepository.fetchLinks(receiptId);
|
||||
}
|
||||
|
||||
Future<PurchaseReceipt?> findById(String id) {
|
||||
return _receiptRepository.findById(id);
|
||||
}
|
||||
|
||||
Future<void> deleteReceipt(String id) {
|
||||
return _receiptRepository.deleteReceipt(id);
|
||||
}
|
||||
|
||||
Future<PurchaseReceipt> createReceipt({
|
||||
String? supplierId,
|
||||
required DateTime paymentDate,
|
||||
required int amount,
|
||||
String? method,
|
||||
String? notes,
|
||||
List<PurchaseReceiptAllocationInput> allocations = const [],
|
||||
}) async {
|
||||
if (amount <= 0) {
|
||||
throw ArgumentError('amount must be greater than 0');
|
||||
}
|
||||
final receipt = PurchaseReceipt(
|
||||
id: _uuid.v4(),
|
||||
supplierId: supplierId,
|
||||
paymentDate: paymentDate,
|
||||
method: method,
|
||||
amount: amount,
|
||||
notes: notes,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
return _saveReceipt(receipt: receipt, allocations: allocations);
|
||||
}
|
||||
|
||||
Future<PurchaseReceipt> updateReceipt({
|
||||
required PurchaseReceipt receipt,
|
||||
List<PurchaseReceiptAllocationInput> allocations = const [],
|
||||
}) {
|
||||
final updated = receipt.copyWith(updatedAt: DateTime.now());
|
||||
return _saveReceipt(receipt: updated, allocations: allocations);
|
||||
}
|
||||
|
||||
Future<PurchaseReceipt> _saveReceipt({
|
||||
required PurchaseReceipt receipt,
|
||||
required List<PurchaseReceiptAllocationInput> allocations,
|
||||
}) async {
|
||||
final entries = await _loadEntries(allocations.map((a) => a.purchaseEntryId));
|
||||
final allocatedTotals = await _receiptRepository.fetchAllocatedTotals(entries.keys);
|
||||
|
||||
final links = <PurchaseReceiptLink>[];
|
||||
for (final allocation in allocations) {
|
||||
final entry = entries[allocation.purchaseEntryId];
|
||||
if (entry == null) {
|
||||
throw StateError('仕入伝票が見つかりません: ${allocation.purchaseEntryId}');
|
||||
}
|
||||
final currentAllocated = allocatedTotals[entry.id] ?? 0;
|
||||
final outstanding = entry.amountTaxIncl - currentAllocated;
|
||||
if (allocation.amount > outstanding) {
|
||||
throw StateError('割当額が支払残を超えています: ${entry.id}');
|
||||
}
|
||||
links.add(
|
||||
PurchaseReceiptLink(
|
||||
receiptId: receipt.id,
|
||||
purchaseEntryId: entry.id,
|
||||
allocatedAmount: allocation.amount,
|
||||
),
|
||||
);
|
||||
allocatedTotals[entry.id] = currentAllocated + allocation.amount;
|
||||
}
|
||||
|
||||
final totalAllocated = links.fold<int>(0, (sum, link) => sum + link.allocatedAmount);
|
||||
if (totalAllocated > receipt.amount) {
|
||||
throw StateError('割当総額が支払額を超えています');
|
||||
}
|
||||
|
||||
await _receiptRepository.upsertReceipt(receipt, links);
|
||||
await _updateEntryStatuses(entries.values, allocatedTotals);
|
||||
return receipt;
|
||||
}
|
||||
|
||||
Future<void> _updateEntryStatuses(Iterable<PurchaseEntry> entries, Map<String, int> allocatedTotals) async {
|
||||
for (final entry in entries) {
|
||||
final allocated = allocatedTotals[entry.id] ?? 0;
|
||||
PurchaseEntryStatus newStatus;
|
||||
if (allocated >= entry.amountTaxIncl) {
|
||||
newStatus = PurchaseEntryStatus.settled;
|
||||
} else if (allocated > 0) {
|
||||
newStatus = PurchaseEntryStatus.confirmed;
|
||||
} else {
|
||||
newStatus = entry.status;
|
||||
}
|
||||
if (newStatus != entry.status) {
|
||||
await _entryRepository.upsertEntry(entry.copyWith(status: newStatus, updatedAt: DateTime.now()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, PurchaseEntry>> _loadEntries(Iterable<String> entryIds) async {
|
||||
final map = <String, PurchaseEntry>{};
|
||||
for (final id in entryIds) {
|
||||
final entry = await _entryRepository.findById(id);
|
||||
if (entry != null) {
|
||||
map[id] = entry;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
}
|
||||
126
lib/services/receivable_repository.dart
Normal file
126
lib/services/receivable_repository.dart
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../models/invoice_models.dart';
|
||||
import '../models/receivable_models.dart';
|
||||
import 'database_helper.dart';
|
||||
|
||||
class ReceivableRepository {
|
||||
ReceivableRepository();
|
||||
|
||||
final DatabaseHelper _dbHelper = DatabaseHelper();
|
||||
final DateFormat _invoiceNumberDateFormat = DateFormat('yyyyMMdd');
|
||||
|
||||
Future<List<ReceivableInvoiceSummary>> fetchSummaries({bool includeSettled = false}) async {
|
||||
final db = await _dbHelper.database;
|
||||
final rows = await db.rawQuery('''
|
||||
SELECT i.id, i.customer_formal_name, i.date, i.total_amount, i.document_type, i.terminal_id,
|
||||
COALESCE(SUM(p.amount), 0) AS paid_amount
|
||||
FROM invoices i
|
||||
LEFT JOIN receivable_payments p ON p.invoice_id = i.id
|
||||
WHERE i.document_type = ? AND COALESCE(i.is_draft, 0) = 0
|
||||
GROUP BY i.id
|
||||
HAVING (? = 1) OR (i.total_amount - COALESCE(SUM(p.amount), 0)) > 0
|
||||
ORDER BY i.date DESC
|
||||
''', [DocumentType.invoice.name, includeSettled ? 1 : 0]);
|
||||
return rows.map(_mapToSummary).toList();
|
||||
}
|
||||
|
||||
Future<ReceivableInvoiceSummary?> findSummaryById(String invoiceId) async {
|
||||
final db = await _dbHelper.database;
|
||||
final rows = await db.rawQuery('''
|
||||
SELECT i.id, i.customer_formal_name, i.date, i.total_amount, i.document_type, i.terminal_id,
|
||||
COALESCE(SUM(p.amount), 0) AS paid_amount
|
||||
FROM invoices i
|
||||
LEFT JOIN receivable_payments p ON p.invoice_id = i.id
|
||||
WHERE i.id = ?
|
||||
GROUP BY i.id
|
||||
LIMIT 1
|
||||
''', [invoiceId]);
|
||||
if (rows.isEmpty) return null;
|
||||
return _mapToSummary(rows.first);
|
||||
}
|
||||
|
||||
Future<List<ReceivablePayment>> fetchPayments(String invoiceId) async {
|
||||
final db = await _dbHelper.database;
|
||||
final rows = await db.query(
|
||||
'receivable_payments',
|
||||
where: 'invoice_id = ?',
|
||||
whereArgs: [invoiceId],
|
||||
orderBy: 'payment_date DESC, created_at DESC',
|
||||
);
|
||||
return rows.map(ReceivablePayment.fromMap).toList();
|
||||
}
|
||||
|
||||
Future<ReceivablePayment?> findPaymentById(String paymentId) async {
|
||||
final db = await _dbHelper.database;
|
||||
final rows = await db.query(
|
||||
'receivable_payments',
|
||||
where: 'id = ?',
|
||||
whereArgs: [paymentId],
|
||||
limit: 1,
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
return ReceivablePayment.fromMap(rows.first);
|
||||
}
|
||||
|
||||
Future<void> insertPayment(ReceivablePayment payment) async {
|
||||
final db = await _dbHelper.database;
|
||||
await db.insert('receivable_payments', payment.toMap());
|
||||
}
|
||||
|
||||
Future<void> deletePayment(String paymentId) async {
|
||||
final db = await _dbHelper.database;
|
||||
await db.delete('receivable_payments', where: 'id = ?', whereArgs: [paymentId]);
|
||||
}
|
||||
|
||||
ReceivableInvoiceSummary _mapToSummary(Map<String, Object?> row) {
|
||||
final invoiceDate = DateTime.parse(row['date'] as String);
|
||||
final dueDate = invoiceDate.add(const Duration(days: 30));
|
||||
final totalAmount = row['total_amount'] as int? ?? 0;
|
||||
final paidAmount = row['paid_amount'] as int? ?? 0;
|
||||
final documentType = DocumentType.values.firstWhere(
|
||||
(type) => type.name == row['document_type'],
|
||||
orElse: () => DocumentType.invoice,
|
||||
);
|
||||
final invoiceNumber = _buildInvoiceNumber(
|
||||
prefix: _documentPrefix(documentType),
|
||||
terminalId: row['terminal_id'] as String? ?? 'T1',
|
||||
invoiceDate: invoiceDate,
|
||||
id: row['id'] as String,
|
||||
);
|
||||
|
||||
return ReceivableInvoiceSummary(
|
||||
invoiceId: row['id'] as String,
|
||||
invoiceNumber: invoiceNumber,
|
||||
customerName: row['customer_formal_name'] as String? ?? '-',
|
||||
invoiceDate: invoiceDate,
|
||||
dueDate: dueDate,
|
||||
totalAmount: totalAmount,
|
||||
paidAmount: paidAmount,
|
||||
);
|
||||
}
|
||||
|
||||
String _buildInvoiceNumber({
|
||||
required String prefix,
|
||||
required String terminalId,
|
||||
required DateTime invoiceDate,
|
||||
required String id,
|
||||
}) {
|
||||
final suffix = id.length >= 4 ? id.substring(id.length - 4) : id;
|
||||
final datePart = _invoiceNumberDateFormat.format(invoiceDate);
|
||||
return '$prefix-$terminalId-$datePart-$suffix';
|
||||
}
|
||||
|
||||
String _documentPrefix(DocumentType type) {
|
||||
switch (type) {
|
||||
case DocumentType.estimation:
|
||||
return 'EST';
|
||||
case DocumentType.delivery:
|
||||
return 'DEL';
|
||||
case DocumentType.invoice:
|
||||
return 'INV';
|
||||
case DocumentType.receipt:
|
||||
return 'REC';
|
||||
}
|
||||
}
|
||||
}
|
||||
79
lib/services/receivable_service.dart
Normal file
79
lib/services/receivable_service.dart
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/hash_chain_models.dart';
|
||||
import '../models/receivable_models.dart';
|
||||
import 'business_calendar_mapper.dart';
|
||||
import 'invoice_repository.dart';
|
||||
import 'receivable_repository.dart';
|
||||
|
||||
class ReceivableService {
|
||||
ReceivableService({ReceivableRepository? repository, BusinessCalendarMapper? calendarMapper})
|
||||
: _repository = repository ?? ReceivableRepository(),
|
||||
_calendarMapper = calendarMapper ?? BusinessCalendarMapper();
|
||||
|
||||
final ReceivableRepository _repository;
|
||||
final InvoiceRepository _invoiceRepository = InvoiceRepository();
|
||||
final BusinessCalendarMapper _calendarMapper;
|
||||
final Uuid _uuid = const Uuid();
|
||||
|
||||
Future<List<ReceivableInvoiceSummary>> fetchSummaries({bool includeSettled = false}) async {
|
||||
final summaries = await _repository.fetchSummaries(includeSettled: includeSettled);
|
||||
unawaited(_calendarMapper.syncReceivables(summaries));
|
||||
return summaries;
|
||||
}
|
||||
|
||||
Future<ReceivableInvoiceSummary?> findSummary(String invoiceId) async {
|
||||
final summary = await _repository.findSummaryById(invoiceId);
|
||||
if (summary != null) {
|
||||
unawaited(_calendarMapper.syncReceivable(summary));
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
Future<List<ReceivablePayment>> fetchPayments(String invoiceId) {
|
||||
return _repository.fetchPayments(invoiceId);
|
||||
}
|
||||
|
||||
Future<void> addPayment({
|
||||
required String invoiceId,
|
||||
required int amount,
|
||||
required DateTime paymentDate,
|
||||
required PaymentMethod method,
|
||||
String? notes,
|
||||
}) async {
|
||||
if (amount <= 0) {
|
||||
throw ArgumentError('amount must be greater than 0');
|
||||
}
|
||||
final payment = ReceivablePayment(
|
||||
id: _uuid.v4(),
|
||||
invoiceId: invoiceId,
|
||||
amount: amount,
|
||||
paymentDate: paymentDate,
|
||||
method: method,
|
||||
notes: notes,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
await _repository.insertPayment(payment);
|
||||
final summary = await _repository.findSummaryById(invoiceId);
|
||||
if (summary != null) {
|
||||
unawaited(_calendarMapper.syncReceivable(summary));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deletePayment(String paymentId) async {
|
||||
final payment = await _repository.findPaymentById(paymentId);
|
||||
await _repository.deletePayment(paymentId);
|
||||
if (payment != null) {
|
||||
final summary = await _repository.findSummaryById(payment.invoiceId);
|
||||
if (summary != null) {
|
||||
unawaited(_calendarMapper.syncReceivable(summary));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<HashChainVerificationResult> verifyHashChain() {
|
||||
return _invoiceRepository.verifyHashChain();
|
||||
}
|
||||
}
|
||||
83
lib/services/sales_entry_repository.dart
Normal file
83
lib/services/sales_entry_repository.dart
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
import '../models/sales_entry_models.dart';
|
||||
import 'database_helper.dart';
|
||||
|
||||
class SalesEntryRepository {
|
||||
SalesEntryRepository();
|
||||
|
||||
final DatabaseHelper _dbHelper = DatabaseHelper();
|
||||
|
||||
Future<void> upsertEntry(SalesEntry entry) async {
|
||||
final db = await _dbHelper.database;
|
||||
await db.transaction((txn) async {
|
||||
await txn.insert('sales_entries', entry.toMap(), conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
await txn.delete('sales_line_items', where: 'sales_entry_id = ?', whereArgs: [entry.id]);
|
||||
for (final item in entry.items) {
|
||||
await txn.insert('sales_line_items', item.toMap(), conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<SalesEntry?> findById(String id) async {
|
||||
final db = await _dbHelper.database;
|
||||
final rows = await db.query('sales_entries', where: 'id = ?', whereArgs: [id], limit: 1);
|
||||
if (rows.isEmpty) return null;
|
||||
final items = await _fetchItems(db, id);
|
||||
return SalesEntry.fromMap(rows.first, items: items);
|
||||
}
|
||||
|
||||
Future<List<SalesEntry>> fetchEntries({SalesEntryStatus? status, int? limit}) async {
|
||||
final db = await _dbHelper.database;
|
||||
final whereClauses = <String>[];
|
||||
final whereArgs = <Object?>[];
|
||||
if (status != null) {
|
||||
whereClauses.add('status = ?');
|
||||
whereArgs.add(status.name);
|
||||
}
|
||||
final rows = await db.query(
|
||||
'sales_entries',
|
||||
where: whereClauses.isNotEmpty ? whereClauses.join(' AND ') : null,
|
||||
whereArgs: whereClauses.isNotEmpty ? whereArgs : null,
|
||||
orderBy: 'issue_date DESC, updated_at DESC',
|
||||
limit: limit,
|
||||
);
|
||||
final result = <SalesEntry>[];
|
||||
for (final row in rows) {
|
||||
final items = await _fetchItems(db, row['id'] as String);
|
||||
result.add(SalesEntry.fromMap(row, items: items));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> deleteEntry(String id) async {
|
||||
final db = await _dbHelper.database;
|
||||
await db.transaction((txn) async {
|
||||
await txn.delete('sales_line_items', where: 'sales_entry_id = ?', whereArgs: [id]);
|
||||
await txn.delete('sales_entry_sources', where: 'sales_entry_id = ?', whereArgs: [id]);
|
||||
await txn.delete('sales_receipt_links', where: 'sales_entry_id = ?', whereArgs: [id]);
|
||||
await txn.delete('sales_entries', where: 'id = ?', whereArgs: [id]);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> upsertSources(String salesEntryId, List<SalesEntrySource> sources) async {
|
||||
final db = await _dbHelper.database;
|
||||
await db.transaction((txn) async {
|
||||
await txn.delete('sales_entry_sources', where: 'sales_entry_id = ?', whereArgs: [salesEntryId]);
|
||||
for (final source in sources) {
|
||||
await txn.insert('sales_entry_sources', source.toMap(), conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<SalesEntrySource>> fetchSources(String salesEntryId) async {
|
||||
final db = await _dbHelper.database;
|
||||
final rows = await db.query('sales_entry_sources', where: 'sales_entry_id = ?', whereArgs: [salesEntryId], orderBy: 'imported_at DESC');
|
||||
return rows.map(SalesEntrySource.fromMap).toList();
|
||||
}
|
||||
|
||||
Future<List<SalesLineItem>> _fetchItems(DatabaseExecutor db, String entryId) async {
|
||||
final rows = await db.query('sales_line_items', where: 'sales_entry_id = ?', whereArgs: [entryId]);
|
||||
return rows.map(SalesLineItem.fromMap).toList();
|
||||
}
|
||||
}
|
||||
330
lib/services/sales_entry_service.dart
Normal file
330
lib/services/sales_entry_service.dart
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
import 'package:intl/intl.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/invoice_models.dart';
|
||||
import '../models/sales_entry_models.dart';
|
||||
import 'database_helper.dart';
|
||||
import 'sales_entry_repository.dart';
|
||||
|
||||
class SalesEntryService {
|
||||
SalesEntryService({
|
||||
SalesEntryRepository? salesEntryRepository,
|
||||
DatabaseHelper? databaseHelper,
|
||||
}) : _salesEntryRepository = salesEntryRepository ?? SalesEntryRepository(),
|
||||
_dbHelper = databaseHelper ?? DatabaseHelper();
|
||||
|
||||
final SalesEntryRepository _salesEntryRepository;
|
||||
final DatabaseHelper _dbHelper;
|
||||
final Uuid _uuid = const Uuid();
|
||||
final DateFormat _numberDateFormat = DateFormat('yyyyMMdd');
|
||||
|
||||
Future<List<SalesEntry>> fetchEntries({SalesEntryStatus? status, int? limit}) {
|
||||
return _salesEntryRepository.fetchEntries(status: status, limit: limit);
|
||||
}
|
||||
|
||||
Future<SalesEntry?> findById(String id) {
|
||||
return _salesEntryRepository.findById(id);
|
||||
}
|
||||
|
||||
Future<void> deleteEntry(String id) {
|
||||
return _salesEntryRepository.deleteEntry(id);
|
||||
}
|
||||
|
||||
Future<SalesEntry> saveEntry(SalesEntry entry) async {
|
||||
final recalculated = entry.recalcTotals().copyWith(updatedAt: DateTime.now());
|
||||
await _salesEntryRepository.upsertEntry(recalculated);
|
||||
return recalculated;
|
||||
}
|
||||
|
||||
Future<List<SalesImportCandidate>> fetchImportCandidates({
|
||||
String? keyword,
|
||||
Set<DocumentType>? documentTypes,
|
||||
DateTime? startDate,
|
||||
DateTime? endDate,
|
||||
bool includeDrafts = false,
|
||||
}) async {
|
||||
final db = await _dbHelper.database;
|
||||
final whereClauses = <String>[];
|
||||
final args = <Object?>[];
|
||||
|
||||
if (!includeDrafts) {
|
||||
whereClauses.add('COALESCE(is_draft, 0) = 0');
|
||||
}
|
||||
if (keyword != null && keyword.trim().isNotEmpty) {
|
||||
whereClauses.add('(customer_formal_name LIKE ? OR subject LIKE ?)');
|
||||
final like = '%${keyword.trim()}%';
|
||||
args..add(like)..add(like);
|
||||
}
|
||||
if (startDate != null) {
|
||||
whereClauses.add('date >= ?');
|
||||
args.add(startDate.toIso8601String());
|
||||
}
|
||||
if (endDate != null) {
|
||||
whereClauses.add('date <= ?');
|
||||
args.add(endDate.toIso8601String());
|
||||
}
|
||||
if (documentTypes != null && documentTypes.isNotEmpty) {
|
||||
final placeholders = List.filled(documentTypes.length, '?').join(',');
|
||||
whereClauses.add('document_type IN ($placeholders)');
|
||||
args.addAll(documentTypes.map((d) => d.name));
|
||||
}
|
||||
|
||||
final whereSql = whereClauses.isEmpty ? '' : 'WHERE ${whereClauses.join(' AND ')}';
|
||||
final rows = await db.rawQuery('''
|
||||
SELECT id, customer_formal_name, date, total_amount, document_type, terminal_id,
|
||||
subject, is_locked, chain_status, content_hash
|
||||
FROM invoices
|
||||
$whereSql
|
||||
ORDER BY date DESC, id DESC
|
||||
LIMIT 200
|
||||
''', args);
|
||||
|
||||
return rows.map((row) {
|
||||
final docTypeName = row['document_type'] as String? ?? DocumentType.invoice.name;
|
||||
final documentType = DocumentType.values.firstWhere(
|
||||
(type) => type.name == docTypeName,
|
||||
orElse: () => DocumentType.invoice,
|
||||
);
|
||||
final invoiceNumber = _buildInvoiceNumber(
|
||||
documentType,
|
||||
row['terminal_id'] as String? ?? 'T1',
|
||||
DateTime.parse(row['date'] as String),
|
||||
row['id'] as String,
|
||||
);
|
||||
final map = {
|
||||
'id': row['id'],
|
||||
'invoice_number': invoiceNumber,
|
||||
'document_type': documentType.name,
|
||||
'date': row['date'],
|
||||
'customer_name': row['customer_formal_name'],
|
||||
'total_amount': row['total_amount'],
|
||||
'is_locked': row['is_locked'],
|
||||
'chain_status': row['chain_status'],
|
||||
'content_hash': row['content_hash'],
|
||||
'subject': row['subject'],
|
||||
};
|
||||
return SalesImportCandidate.fromMap(map);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Future<SalesEntry> createEntryFromInvoices(List<String> invoiceIds, {String? subject, DateTime? issueDate}) async {
|
||||
if (invoiceIds.isEmpty) {
|
||||
throw ArgumentError('invoiceIds must not be empty');
|
||||
}
|
||||
final invoices = await _loadInvoiceData(invoiceIds);
|
||||
if (invoices.isEmpty) {
|
||||
throw StateError('指定された伝票が見つかりませんでした');
|
||||
}
|
||||
final now = DateTime.now();
|
||||
final entryId = _uuid.v4();
|
||||
final built = _buildEntryFromInvoices(
|
||||
invoices: invoices,
|
||||
entryId: entryId,
|
||||
baseEntry: null,
|
||||
subjectOverride: subject,
|
||||
issueDateOverride: issueDate,
|
||||
now: now,
|
||||
);
|
||||
await _salesEntryRepository.upsertEntry(built.entry);
|
||||
await _salesEntryRepository.upsertSources(built.entry.id, built.sources);
|
||||
return built.entry;
|
||||
}
|
||||
|
||||
Future<SalesEntry> reimportEntry(String salesEntryId) async {
|
||||
final existing = await _salesEntryRepository.findById(salesEntryId);
|
||||
if (existing == null) {
|
||||
throw StateError('sales entry not found: $salesEntryId');
|
||||
}
|
||||
final sources = await _salesEntryRepository.fetchSources(salesEntryId);
|
||||
if (sources.isEmpty) {
|
||||
throw StateError('再インポート対象の元伝票が登録されていません');
|
||||
}
|
||||
final invoiceIds = sources.map((s) => s.invoiceId).toList();
|
||||
final invoices = await _loadInvoiceData(invoiceIds);
|
||||
if (invoices.isEmpty) {
|
||||
throw StateError('元伝票が見つかりませんでした');
|
||||
}
|
||||
final now = DateTime.now();
|
||||
final built = _buildEntryFromInvoices(
|
||||
invoices: invoices,
|
||||
entryId: existing.id,
|
||||
baseEntry: existing,
|
||||
now: now,
|
||||
);
|
||||
await _salesEntryRepository.upsertEntry(built.entry);
|
||||
await _salesEntryRepository.upsertSources(existing.id, built.sources);
|
||||
return built.entry;
|
||||
}
|
||||
|
||||
_ImportBuildResult _buildEntryFromInvoices({
|
||||
required List<SalesInvoiceImportData> invoices,
|
||||
required String entryId,
|
||||
SalesEntry? baseEntry,
|
||||
String? subjectOverride,
|
||||
DateTime? issueDateOverride,
|
||||
required DateTime now,
|
||||
}) {
|
||||
final items = <SalesLineItem>[];
|
||||
for (final invoice in invoices) {
|
||||
for (final line in invoice.items) {
|
||||
items.add(
|
||||
SalesLineItem(
|
||||
id: _uuid.v4(),
|
||||
salesEntryId: entryId,
|
||||
description: '[${invoice.documentType.displayName}] ${line.description}',
|
||||
quantity: line.quantity,
|
||||
unitPrice: line.unitPrice,
|
||||
lineTotal: line.subtotal,
|
||||
productId: line.productId,
|
||||
taxRate: invoice.taxRate,
|
||||
sourceInvoiceId: invoice.invoiceId,
|
||||
sourceInvoiceItemId: line.id,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
final issueDate = issueDateOverride ?? _latestDate(invoices) ?? baseEntry?.issueDate ?? now;
|
||||
final subject = subjectOverride ?? baseEntry?.subject ?? _deriveSubject(invoices);
|
||||
final customerId = _commonCustomerId(invoices) ?? baseEntry?.customerId;
|
||||
final customerNameSnapshot = _deriveCustomerSnapshot(invoices) ?? baseEntry?.customerNameSnapshot;
|
||||
|
||||
final entry = (baseEntry ??
|
||||
SalesEntry(
|
||||
id: entryId,
|
||||
customerId: customerId,
|
||||
customerNameSnapshot: customerNameSnapshot,
|
||||
subject: subject,
|
||||
issueDate: issueDate,
|
||||
status: SalesEntryStatus.draft,
|
||||
notes: baseEntry?.notes,
|
||||
createdAt: baseEntry?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
items: items,
|
||||
))
|
||||
.copyWith(
|
||||
customerId: customerId,
|
||||
customerNameSnapshot: customerNameSnapshot,
|
||||
subject: subject,
|
||||
issueDate: issueDate,
|
||||
status: baseEntry?.status ?? SalesEntryStatus.draft,
|
||||
items: items,
|
||||
updatedAt: now,
|
||||
)
|
||||
.recalcTotals();
|
||||
|
||||
final sources = invoices
|
||||
.map(
|
||||
(inv) => SalesEntrySource(
|
||||
id: _uuid.v4(),
|
||||
salesEntryId: entry.id,
|
||||
invoiceId: inv.invoiceId,
|
||||
importedAt: now,
|
||||
invoiceHashSnapshot: inv.contentHash,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
return _ImportBuildResult(entry: entry, sources: sources);
|
||||
}
|
||||
|
||||
Future<List<SalesInvoiceImportData>> _loadInvoiceData(List<String> invoiceIds) async {
|
||||
if (invoiceIds.isEmpty) return [];
|
||||
final db = await _dbHelper.database;
|
||||
final placeholders = List.filled(invoiceIds.length, '?').join(',');
|
||||
final invoiceRows = await db.rawQuery('''
|
||||
SELECT id, customer_id, customer_formal_name, date, tax_rate, total_amount,
|
||||
document_type, subject, is_locked, chain_status, content_hash
|
||||
FROM invoices
|
||||
WHERE id IN ($placeholders)
|
||||
''', invoiceIds);
|
||||
if (invoiceRows.isEmpty) return [];
|
||||
|
||||
final itemRows = await db.query(
|
||||
'invoice_items',
|
||||
where: 'invoice_id IN ($placeholders)',
|
||||
whereArgs: invoiceIds,
|
||||
);
|
||||
final itemsByInvoice = <String, List<InvoiceItem>>{};
|
||||
for (final row in itemRows) {
|
||||
final invoiceId = row['invoice_id'] as String;
|
||||
final list = itemsByInvoice.putIfAbsent(invoiceId, () => []);
|
||||
list.add(InvoiceItem.fromMap(row));
|
||||
}
|
||||
|
||||
return invoiceRows.map((row) {
|
||||
final docTypeName = row['document_type'] as String? ?? DocumentType.invoice.name;
|
||||
final documentType = DocumentType.values.firstWhere(
|
||||
(type) => type.name == docTypeName,
|
||||
orElse: () => DocumentType.invoice,
|
||||
);
|
||||
final invoiceId = row['id'] as String;
|
||||
return SalesInvoiceImportData(
|
||||
invoiceId: invoiceId,
|
||||
documentType: documentType,
|
||||
issueDate: DateTime.parse(row['date'] as String),
|
||||
taxRate: (row['tax_rate'] as num?)?.toDouble() ?? 0.1,
|
||||
totalAmount: (row['total_amount'] as num?)?.toInt() ?? 0,
|
||||
items: itemsByInvoice[invoiceId] ?? const [],
|
||||
isLocked: (row['is_locked'] as int?) == 1,
|
||||
chainStatus: row['chain_status'] as String? ?? 'pending',
|
||||
contentHash: row['content_hash'] as String? ?? '',
|
||||
customerId: row['customer_id'] as String?,
|
||||
customerFormalName: row['customer_formal_name'] as String?,
|
||||
subject: row['subject'] as String?,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
String? _commonCustomerId(List<SalesInvoiceImportData> invoices) {
|
||||
final ids = invoices.map((e) => e.customerId).whereType<String>().toSet();
|
||||
if (ids.length == 1) return ids.first;
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _deriveCustomerSnapshot(List<SalesInvoiceImportData> invoices) {
|
||||
final names = invoices.map((e) => e.customerFormalName).whereType<String>().toSet();
|
||||
if (names.isEmpty) return null;
|
||||
if (names.length == 1) return names.first;
|
||||
return '複数取引先';
|
||||
}
|
||||
|
||||
DateTime? _latestDate(List<SalesInvoiceImportData> invoices) {
|
||||
if (invoices.isEmpty) return null;
|
||||
return invoices.map((e) => e.issueDate).reduce((a, b) => a.isAfter(b) ? a : b);
|
||||
}
|
||||
|
||||
String _deriveSubject(List<SalesInvoiceImportData> invoices) {
|
||||
if (invoices.isEmpty) return '売上伝票';
|
||||
if (invoices.length == 1) {
|
||||
return invoices.first.subject ?? '${invoices.first.documentType.displayName}取込';
|
||||
}
|
||||
return '複数伝票取込(${invoices.length}件)';
|
||||
}
|
||||
|
||||
String _buildInvoiceNumber(DocumentType type, String terminalId, DateTime date, String id) {
|
||||
final suffix = id.length >= 4 ? id.substring(id.length - 4) : id;
|
||||
final datePart = _numberDateFormat.format(date);
|
||||
final prefix = _documentPrefix(type);
|
||||
return '$prefix-$terminalId-$datePart-$suffix';
|
||||
}
|
||||
|
||||
String _documentPrefix(DocumentType type) {
|
||||
switch (type) {
|
||||
case DocumentType.estimation:
|
||||
return 'EST';
|
||||
case DocumentType.delivery:
|
||||
return 'DEL';
|
||||
case DocumentType.invoice:
|
||||
return 'INV';
|
||||
case DocumentType.receipt:
|
||||
return 'REC';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _ImportBuildResult {
|
||||
const _ImportBuildResult({required this.entry, required this.sources});
|
||||
|
||||
final SalesEntry entry;
|
||||
final List<SalesEntrySource> sources;
|
||||
}
|
||||
84
lib/services/sales_order_repository.dart
Normal file
84
lib/services/sales_order_repository.dart
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
import '../models/order_models.dart';
|
||||
import 'database_helper.dart';
|
||||
|
||||
class SalesOrderRepository {
|
||||
SalesOrderRepository();
|
||||
|
||||
final DatabaseHelper _dbHelper = DatabaseHelper();
|
||||
|
||||
Future<void> upsertOrder(SalesOrder order) async {
|
||||
final db = await _dbHelper.database;
|
||||
await db.transaction((txn) async {
|
||||
await txn.insert('sales_orders', order.toMap(), conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
await txn.delete('sales_order_items', where: 'order_id = ?', whereArgs: [order.id]);
|
||||
for (final item in order.items) {
|
||||
await txn.insert('sales_order_items', item.toMap(), conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<SalesOrder>> fetchOrders({SalesOrderStatus? status, int? limit}) async {
|
||||
final db = await _dbHelper.database;
|
||||
final whereClauses = <String>[];
|
||||
final whereArgs = <Object?>[];
|
||||
if (status != null) {
|
||||
whereClauses.add('status = ?');
|
||||
whereArgs.add(status.name);
|
||||
}
|
||||
final orders = await db.query(
|
||||
'sales_orders',
|
||||
where: whereClauses.isNotEmpty ? whereClauses.join(' AND ') : null,
|
||||
whereArgs: whereClauses.isNotEmpty ? whereArgs : null,
|
||||
orderBy: 'order_date DESC',
|
||||
limit: limit,
|
||||
);
|
||||
|
||||
final result = <SalesOrder>[];
|
||||
for (final row in orders) {
|
||||
final items = await _fetchItemsByOrderId(db, row['id'] as String);
|
||||
result.add(SalesOrder.fromMap(row, items: items));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<SalesOrder?> findById(String id) async {
|
||||
final db = await _dbHelper.database;
|
||||
final rows = await db.query('sales_orders', where: 'id = ?', whereArgs: [id], limit: 1);
|
||||
if (rows.isEmpty) return null;
|
||||
final items = await _fetchItemsByOrderId(db, id);
|
||||
return SalesOrder.fromMap(rows.first, items: items);
|
||||
}
|
||||
|
||||
Future<void> deleteOrder(String id) async {
|
||||
final db = await _dbHelper.database;
|
||||
await db.transaction((txn) async {
|
||||
await txn.delete('sales_order_items', where: 'order_id = ?', whereArgs: [id]);
|
||||
await txn.delete('sales_orders', where: 'id = ?', whereArgs: [id]);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> updateStatus({required String orderId, required SalesOrderStatus status}) async {
|
||||
final db = await _dbHelper.database;
|
||||
await db.update(
|
||||
'sales_orders',
|
||||
{
|
||||
'status': status.name,
|
||||
'updated_at': DateTime.now().toIso8601String(),
|
||||
},
|
||||
where: 'id = ?',
|
||||
whereArgs: [orderId],
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<SalesOrderItem>> _fetchItemsByOrderId(DatabaseExecutor db, String orderId) async {
|
||||
final rows = await db.query(
|
||||
'sales_order_items',
|
||||
where: 'order_id = ?',
|
||||
whereArgs: [orderId],
|
||||
orderBy: 'sort_index ASC, rowid ASC',
|
||||
);
|
||||
return rows.map(SalesOrderItem.fromMap).toList();
|
||||
}
|
||||
}
|
||||
81
lib/services/sales_receipt_repository.dart
Normal file
81
lib/services/sales_receipt_repository.dart
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
import '../models/sales_entry_models.dart';
|
||||
import 'database_helper.dart';
|
||||
|
||||
class SalesReceiptRepository {
|
||||
SalesReceiptRepository();
|
||||
|
||||
final DatabaseHelper _dbHelper = DatabaseHelper();
|
||||
|
||||
Future<void> upsertReceipt(SalesReceipt receipt, List<SalesReceiptLink> links) async {
|
||||
final db = await _dbHelper.database;
|
||||
await db.transaction((txn) async {
|
||||
await txn.insert('sales_receipts', receipt.toMap(), conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
await txn.delete('sales_receipt_links', where: 'receipt_id = ?', whereArgs: [receipt.id]);
|
||||
for (final link in links) {
|
||||
await txn.insert('sales_receipt_links', link.toMap(), conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<SalesReceipt>> fetchReceipts({DateTime? startDate, DateTime? endDate}) async {
|
||||
final db = await _dbHelper.database;
|
||||
final whereClauses = <String>[];
|
||||
final args = <Object?>[];
|
||||
if (startDate != null) {
|
||||
whereClauses.add('payment_date >= ?');
|
||||
args.add(startDate.toIso8601String());
|
||||
}
|
||||
if (endDate != null) {
|
||||
whereClauses.add('payment_date <= ?');
|
||||
args.add(endDate.toIso8601String());
|
||||
}
|
||||
final rows = await db.query(
|
||||
'sales_receipts',
|
||||
where: whereClauses.isEmpty ? null : whereClauses.join(' AND '),
|
||||
whereArgs: whereClauses.isEmpty ? null : args,
|
||||
orderBy: 'payment_date DESC, updated_at DESC',
|
||||
);
|
||||
return rows.map(SalesReceipt.fromMap).toList();
|
||||
}
|
||||
|
||||
Future<SalesReceipt?> findById(String id) async {
|
||||
final db = await _dbHelper.database;
|
||||
final rows = await db.query('sales_receipts', where: 'id = ?', whereArgs: [id], limit: 1);
|
||||
if (rows.isEmpty) return null;
|
||||
return SalesReceipt.fromMap(rows.first);
|
||||
}
|
||||
|
||||
Future<List<SalesReceiptLink>> fetchLinks(String receiptId) async {
|
||||
final db = await _dbHelper.database;
|
||||
final rows = await db.query('sales_receipt_links', where: 'receipt_id = ?', whereArgs: [receiptId]);
|
||||
return rows.map(SalesReceiptLink.fromMap).toList();
|
||||
}
|
||||
|
||||
Future<void> deleteReceipt(String id) async {
|
||||
final db = await _dbHelper.database;
|
||||
await db.transaction((txn) async {
|
||||
await txn.delete('sales_receipt_links', where: 'receipt_id = ?', whereArgs: [id]);
|
||||
await txn.delete('sales_receipts', where: 'id = ?', whereArgs: [id]);
|
||||
});
|
||||
}
|
||||
|
||||
Future<Map<String, int>> fetchAllocatedTotals(Iterable<String> entryIds) async {
|
||||
final ids = entryIds.where((id) => id.isNotEmpty).toSet().toList();
|
||||
if (ids.isEmpty) return {};
|
||||
final db = await _dbHelper.database;
|
||||
final placeholders = List.filled(ids.length, '?').join(',');
|
||||
final rows = await db.rawQuery(
|
||||
'SELECT sales_entry_id, SUM(allocated_amount) AS total FROM sales_receipt_links WHERE sales_entry_id IN ($placeholders) GROUP BY sales_entry_id',
|
||||
ids,
|
||||
);
|
||||
final result = <String, int>{};
|
||||
for (final row in rows) {
|
||||
final entryId = row['sales_entry_id'] as String?;
|
||||
if (entryId == null) continue;
|
||||
result[entryId] = (row['total'] as num?)?.toInt() ?? 0;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
135
lib/services/sales_receipt_service.dart
Normal file
135
lib/services/sales_receipt_service.dart
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/sales_entry_models.dart';
|
||||
import 'sales_entry_repository.dart';
|
||||
import 'sales_receipt_repository.dart';
|
||||
|
||||
class SalesReceiptService {
|
||||
SalesReceiptService({
|
||||
SalesReceiptRepository? receiptRepository,
|
||||
SalesEntryRepository? entryRepository,
|
||||
}) : _receiptRepository = receiptRepository ?? SalesReceiptRepository(),
|
||||
_entryRepository = entryRepository ?? SalesEntryRepository();
|
||||
|
||||
final SalesReceiptRepository _receiptRepository;
|
||||
final SalesEntryRepository _entryRepository;
|
||||
final Uuid _uuid = const Uuid();
|
||||
|
||||
Future<List<SalesReceipt>> fetchReceipts({DateTime? startDate, DateTime? endDate}) {
|
||||
return _receiptRepository.fetchReceipts(startDate: startDate, endDate: endDate);
|
||||
}
|
||||
|
||||
Future<Map<String, int>> fetchAllocatedTotals(Iterable<String> entryIds) {
|
||||
return _receiptRepository.fetchAllocatedTotals(entryIds);
|
||||
}
|
||||
|
||||
Future<List<SalesReceiptLink>> fetchLinks(String receiptId) {
|
||||
return _receiptRepository.fetchLinks(receiptId);
|
||||
}
|
||||
|
||||
Future<SalesReceipt?> findById(String id) {
|
||||
return _receiptRepository.findById(id);
|
||||
}
|
||||
|
||||
Future<void> deleteReceipt(String id) {
|
||||
return _receiptRepository.deleteReceipt(id);
|
||||
}
|
||||
|
||||
Future<SalesReceipt> createReceipt({
|
||||
String? customerId,
|
||||
required DateTime paymentDate,
|
||||
required int amount,
|
||||
String? method,
|
||||
String? notes,
|
||||
List<SalesReceiptAllocationInput> allocations = const [],
|
||||
}) async {
|
||||
if (amount <= 0) {
|
||||
throw ArgumentError('amount must be greater than 0');
|
||||
}
|
||||
final receipt = SalesReceipt(
|
||||
id: _uuid.v4(),
|
||||
customerId: customerId,
|
||||
paymentDate: paymentDate,
|
||||
method: method,
|
||||
amount: amount,
|
||||
notes: notes,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
return _saveReceipt(receipt: receipt, allocations: allocations);
|
||||
}
|
||||
|
||||
Future<SalesReceipt> updateReceipt({
|
||||
required SalesReceipt receipt,
|
||||
List<SalesReceiptAllocationInput> allocations = const [],
|
||||
}) {
|
||||
final updated = receipt.copyWith(updatedAt: DateTime.now());
|
||||
return _saveReceipt(receipt: updated, allocations: allocations);
|
||||
}
|
||||
|
||||
Future<SalesReceipt> _saveReceipt({
|
||||
required SalesReceipt receipt,
|
||||
required List<SalesReceiptAllocationInput> allocations,
|
||||
}) async {
|
||||
final entries = await _loadEntries(allocations.map((a) => a.salesEntryId));
|
||||
final allocatedTotals = await _receiptRepository.fetchAllocatedTotals(entries.keys);
|
||||
|
||||
final links = <SalesReceiptLink>[];
|
||||
for (final allocation in allocations) {
|
||||
final entry = entries[allocation.salesEntryId];
|
||||
if (entry == null) {
|
||||
throw StateError('売上伝票が見つかりません: ${allocation.salesEntryId}');
|
||||
}
|
||||
final currentAllocated = allocatedTotals[entry.id] ?? 0;
|
||||
final outstanding = entry.amountTaxIncl - currentAllocated;
|
||||
if (allocation.amount > outstanding) {
|
||||
throw StateError('割当額が未収残を超えています: ${entry.id}');
|
||||
}
|
||||
links.add(
|
||||
SalesReceiptLink(
|
||||
receiptId: receipt.id,
|
||||
salesEntryId: entry.id,
|
||||
allocatedAmount: allocation.amount,
|
||||
),
|
||||
);
|
||||
allocatedTotals[entry.id] = currentAllocated + allocation.amount;
|
||||
}
|
||||
|
||||
final totalAllocated = links.fold<int>(0, (sum, link) => sum + link.allocatedAmount);
|
||||
if (totalAllocated > receipt.amount) {
|
||||
throw StateError('割当総額が入金額を超えています');
|
||||
}
|
||||
|
||||
await _receiptRepository.upsertReceipt(receipt, links);
|
||||
await _updateEntryStatuses(entries.values, allocatedTotals);
|
||||
return receipt;
|
||||
}
|
||||
|
||||
Future<void> _updateEntryStatuses(Iterable<SalesEntry> entries, Map<String, int> allocatedTotals) async {
|
||||
for (final entry in entries) {
|
||||
final allocated = allocatedTotals[entry.id] ?? 0;
|
||||
SalesEntryStatus newStatus;
|
||||
if (allocated >= entry.amountTaxIncl) {
|
||||
newStatus = SalesEntryStatus.settled;
|
||||
} else if (allocated > 0) {
|
||||
newStatus = SalesEntryStatus.confirmed;
|
||||
} else {
|
||||
newStatus = entry.status;
|
||||
}
|
||||
if (newStatus != entry.status) {
|
||||
await _entryRepository.upsertEntry(entry.copyWith(status: newStatus, updatedAt: DateTime.now()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, SalesEntry>> _loadEntries(Iterable<String> entryIds) async {
|
||||
final map = <String, SalesEntry>{};
|
||||
for (final id in entryIds) {
|
||||
final entry = await _entryRepository.findById(id);
|
||||
if (entry != null) {
|
||||
map[id] = entry;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
}
|
||||
82
lib/services/shipment_repository.dart
Normal file
82
lib/services/shipment_repository.dart
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
import '../models/shipment_models.dart';
|
||||
import 'database_helper.dart';
|
||||
|
||||
class ShipmentRepository {
|
||||
ShipmentRepository();
|
||||
|
||||
final DatabaseHelper _dbHelper = DatabaseHelper();
|
||||
|
||||
Future<void> upsertShipment(Shipment shipment) async {
|
||||
final db = await _dbHelper.database;
|
||||
await db.transaction((txn) async {
|
||||
await txn.insert('shipments', shipment.toMap(), conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
await txn.delete('shipment_items', where: 'shipment_id = ?', whereArgs: [shipment.id]);
|
||||
for (final item in shipment.items) {
|
||||
await txn.insert('shipment_items', item.toMap(), conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<Shipment>> fetchShipments({ShipmentStatus? status, int? limit}) async {
|
||||
final db = await _dbHelper.database;
|
||||
final whereClauses = <String>[];
|
||||
final whereArgs = <Object?>[];
|
||||
if (status != null) {
|
||||
whereClauses.add('status = ?');
|
||||
whereArgs.add(status.name);
|
||||
}
|
||||
final rows = await db.query(
|
||||
'shipments',
|
||||
where: whereClauses.isNotEmpty ? whereClauses.join(' AND ') : null,
|
||||
whereArgs: whereClauses.isNotEmpty ? whereArgs : null,
|
||||
orderBy: 'scheduled_ship_date IS NULL, scheduled_ship_date ASC, updated_at DESC',
|
||||
limit: limit,
|
||||
);
|
||||
return Future.wait(rows.map((row) async {
|
||||
final items = await _fetchItemsByShipmentId(db, row['id'] as String);
|
||||
return Shipment.fromMap(row, items: items);
|
||||
}));
|
||||
}
|
||||
|
||||
Future<Shipment?> findById(String id) async {
|
||||
final db = await _dbHelper.database;
|
||||
final rows = await db.query('shipments', where: 'id = ?', whereArgs: [id], limit: 1);
|
||||
if (rows.isEmpty) return null;
|
||||
final items = await _fetchItemsByShipmentId(db, id);
|
||||
return Shipment.fromMap(rows.first, items: items);
|
||||
}
|
||||
|
||||
Future<void> deleteShipment(String id) async {
|
||||
final db = await _dbHelper.database;
|
||||
await db.transaction((txn) async {
|
||||
await txn.delete('shipment_items', where: 'shipment_id = ?', whereArgs: [id]);
|
||||
await txn.delete('shipments', where: 'id = ?', whereArgs: [id]);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> updateStatus({required String shipmentId, required ShipmentStatus status, DateTime? actualShipDate}) async {
|
||||
final db = await _dbHelper.database;
|
||||
await db.update(
|
||||
'shipments',
|
||||
{
|
||||
'status': status.name,
|
||||
'actual_ship_date': actualShipDate?.toIso8601String(),
|
||||
'updated_at': DateTime.now().toIso8601String(),
|
||||
},
|
||||
where: 'id = ?',
|
||||
whereArgs: [shipmentId],
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<ShipmentItem>> _fetchItemsByShipmentId(DatabaseExecutor db, String shipmentId) async {
|
||||
final rows = await db.query(
|
||||
'shipment_items',
|
||||
where: 'shipment_id = ?',
|
||||
whereArgs: [shipmentId],
|
||||
orderBy: 'rowid ASC',
|
||||
);
|
||||
return rows.map(ShipmentItem.fromMap).toList();
|
||||
}
|
||||
}
|
||||
183
lib/services/shipment_service.dart
Normal file
183
lib/services/shipment_service.dart
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/shipment_models.dart';
|
||||
import 'business_calendar_mapper.dart';
|
||||
import 'sales_order_repository.dart';
|
||||
import 'shipment_repository.dart';
|
||||
|
||||
class ShipmentLineInput {
|
||||
const ShipmentLineInput({
|
||||
required this.description,
|
||||
required this.quantity,
|
||||
this.orderItemId,
|
||||
});
|
||||
|
||||
final String description;
|
||||
final int quantity;
|
||||
final String? orderItemId;
|
||||
}
|
||||
|
||||
class ShipmentService {
|
||||
ShipmentService({
|
||||
ShipmentRepository? shipmentRepository,
|
||||
SalesOrderRepository? orderRepository,
|
||||
BusinessCalendarMapper? calendarMapper,
|
||||
}) : _shipmentRepository = shipmentRepository ?? ShipmentRepository(),
|
||||
_orderRepository = orderRepository ?? SalesOrderRepository(),
|
||||
_calendarMapper = calendarMapper ?? BusinessCalendarMapper();
|
||||
|
||||
final ShipmentRepository _shipmentRepository;
|
||||
final SalesOrderRepository _orderRepository;
|
||||
final BusinessCalendarMapper _calendarMapper;
|
||||
final Uuid _uuid = const Uuid();
|
||||
|
||||
static const Map<ShipmentStatus, List<ShipmentStatus>> _transitions = {
|
||||
ShipmentStatus.pending: [ShipmentStatus.picking, ShipmentStatus.cancelled],
|
||||
ShipmentStatus.picking: [ShipmentStatus.ready, ShipmentStatus.cancelled],
|
||||
ShipmentStatus.ready: [ShipmentStatus.shipped, ShipmentStatus.cancelled],
|
||||
ShipmentStatus.shipped: [ShipmentStatus.delivered],
|
||||
ShipmentStatus.delivered: [],
|
||||
ShipmentStatus.cancelled: [],
|
||||
};
|
||||
|
||||
Future<List<Shipment>> fetchShipments({ShipmentStatus? status}) {
|
||||
return _shipmentRepository.fetchShipments(status: status);
|
||||
}
|
||||
|
||||
Future<Shipment> createShipment({
|
||||
String? orderId,
|
||||
String? orderNumberSnapshot,
|
||||
String? customerNameSnapshot,
|
||||
List<ShipmentLineInput> lines = const [],
|
||||
DateTime? scheduledShipDate,
|
||||
DateTime? actualShipDate,
|
||||
String? carrierName,
|
||||
String? trackingNumber,
|
||||
String? trackingUrl,
|
||||
String? labelPdfPath,
|
||||
String? notes,
|
||||
}) async {
|
||||
String? resolvedOrderId = orderId;
|
||||
String? resolvedOrderNumber = orderNumberSnapshot;
|
||||
String? resolvedCustomerName = customerNameSnapshot;
|
||||
|
||||
if (resolvedOrderId != null && (resolvedOrderNumber == null || resolvedCustomerName == null)) {
|
||||
final order = await _orderRepository.findById(resolvedOrderId);
|
||||
if (order != null) {
|
||||
resolvedOrderNumber = order.orderNumber ?? order.id.substring(0, 6);
|
||||
resolvedCustomerName = order.customerNameSnapshot;
|
||||
}
|
||||
}
|
||||
|
||||
resolvedCustomerName ??= '未設定';
|
||||
|
||||
final now = DateTime.now();
|
||||
final shipmentId = _uuid.v4();
|
||||
final shipment = Shipment(
|
||||
id: shipmentId,
|
||||
orderId: resolvedOrderId,
|
||||
orderNumberSnapshot: resolvedOrderNumber,
|
||||
customerNameSnapshot: resolvedCustomerName,
|
||||
scheduledShipDate: scheduledShipDate,
|
||||
actualShipDate: actualShipDate,
|
||||
status: ShipmentStatus.pending,
|
||||
carrierName: carrierName,
|
||||
trackingNumber: trackingNumber,
|
||||
trackingUrl: trackingUrl,
|
||||
labelPdfPath: labelPdfPath,
|
||||
notes: notes,
|
||||
pickingCompletedAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
items: _buildItems(shipmentId, lines),
|
||||
);
|
||||
await _shipmentRepository.upsertShipment(shipment);
|
||||
unawaited(_calendarMapper.syncShipment(shipment));
|
||||
return shipment;
|
||||
}
|
||||
|
||||
Future<Shipment> updateShipment(
|
||||
Shipment shipment, {
|
||||
List<ShipmentLineInput>? replacedLines,
|
||||
DateTime? scheduledShipDate,
|
||||
DateTime? actualShipDate,
|
||||
String? carrierName,
|
||||
String? trackingNumber,
|
||||
String? trackingUrl,
|
||||
String? labelPdfPath,
|
||||
String? notes,
|
||||
}) async {
|
||||
final updated = shipment.copyWith(
|
||||
scheduledShipDate: scheduledShipDate ?? shipment.scheduledShipDate,
|
||||
actualShipDate: actualShipDate ?? shipment.actualShipDate,
|
||||
carrierName: carrierName ?? shipment.carrierName,
|
||||
trackingNumber: trackingNumber ?? shipment.trackingNumber,
|
||||
trackingUrl: trackingUrl ?? shipment.trackingUrl,
|
||||
labelPdfPath: labelPdfPath ?? shipment.labelPdfPath,
|
||||
notes: notes ?? shipment.notes,
|
||||
updatedAt: DateTime.now(),
|
||||
items: replacedLines != null ? _buildItems(shipment.id, replacedLines) : shipment.items,
|
||||
);
|
||||
await _shipmentRepository.upsertShipment(updated);
|
||||
unawaited(_calendarMapper.syncShipment(updated));
|
||||
return updated;
|
||||
}
|
||||
|
||||
Future<Shipment> transitionStatus(String shipmentId, ShipmentStatus nextStatus, {bool force = false}) async {
|
||||
final shipment = await _shipmentRepository.findById(shipmentId);
|
||||
if (shipment == null) {
|
||||
throw StateError('shipment not found: $shipmentId');
|
||||
}
|
||||
if (!force && !_canTransition(shipment.status, nextStatus)) {
|
||||
throw StateError('invalid transition ${shipment.status.name} -> ${nextStatus.name}');
|
||||
}
|
||||
final now = DateTime.now();
|
||||
final updated = shipment.copyWith(
|
||||
status: nextStatus,
|
||||
updatedAt: now,
|
||||
actualShipDate: nextStatus == ShipmentStatus.shipped || nextStatus == ShipmentStatus.delivered
|
||||
? (shipment.actualShipDate ?? now)
|
||||
: shipment.actualShipDate,
|
||||
pickingCompletedAt: nextStatus == ShipmentStatus.ready ? (shipment.pickingCompletedAt ?? now) : shipment.pickingCompletedAt,
|
||||
);
|
||||
await _shipmentRepository.upsertShipment(updated);
|
||||
unawaited(_calendarMapper.syncShipment(updated));
|
||||
return updated;
|
||||
}
|
||||
|
||||
Future<Shipment> advanceStatus(String shipmentId) async {
|
||||
final shipment = await _shipmentRepository.findById(shipmentId);
|
||||
if (shipment == null) {
|
||||
throw StateError('shipment not found: $shipmentId');
|
||||
}
|
||||
final candidates = _transitions[shipment.status];
|
||||
if (candidates == null || candidates.isEmpty) {
|
||||
return shipment;
|
||||
}
|
||||
return transitionStatus(shipmentId, candidates.first);
|
||||
}
|
||||
|
||||
List<ShipmentStatus> nextStatuses(ShipmentStatus current) {
|
||||
return List.unmodifiable(_transitions[current] ?? const []);
|
||||
}
|
||||
|
||||
bool _canTransition(ShipmentStatus current, ShipmentStatus next) {
|
||||
final allowed = _transitions[current];
|
||||
return allowed?.contains(next) ?? false;
|
||||
}
|
||||
|
||||
List<ShipmentItem> _buildItems(String shipmentId, List<ShipmentLineInput> lines) {
|
||||
return lines.asMap().entries.map((entry) {
|
||||
final line = entry.value;
|
||||
return ShipmentItem(
|
||||
id: _uuid.v4(),
|
||||
shipmentId: shipmentId,
|
||||
orderItemId: line.orderItemId,
|
||||
description: line.description,
|
||||
quantity: line.quantity,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
101
lib/services/shipping_label_service.dart
Normal file
101
lib/services/shipping_label_service.dart
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:pdf/pdf.dart';
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
|
||||
import '../models/shipment_models.dart';
|
||||
import 'company_repository.dart';
|
||||
|
||||
class ShippingLabelService {
|
||||
ShippingLabelService({CompanyRepository? companyRepository})
|
||||
: _companyRepository = companyRepository ?? CompanyRepository();
|
||||
|
||||
final CompanyRepository _companyRepository;
|
||||
|
||||
Future<String?> generateLabel(Shipment shipment) async {
|
||||
try {
|
||||
final company = await _companyRepository.getCompanyInfo();
|
||||
final fontData = await rootBundle.load('assets/fonts/ipaexg.ttf');
|
||||
final ipaex = pw.Font.ttf(fontData);
|
||||
final dateFormat = DateFormat('yyyy/MM/dd');
|
||||
|
||||
final pdf = pw.Document();
|
||||
pdf.addPage(
|
||||
pw.Page(
|
||||
pageFormat: PdfPageFormat.a5,
|
||||
margin: const pw.EdgeInsets.all(20),
|
||||
build: (context) {
|
||||
return pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text('出荷ラベル', style: pw.TextStyle(fontSize: 20, fontWeight: pw.FontWeight.bold, font: ipaex)),
|
||||
pw.SizedBox(height: 8),
|
||||
pw.Text('発行日: ${dateFormat.format(DateTime.now())}', style: pw.TextStyle(font: ipaex)),
|
||||
pw.Divider(),
|
||||
pw.Text('差出人', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, font: ipaex)),
|
||||
pw.Text(company.name, style: pw.TextStyle(font: ipaex)),
|
||||
if (company.address != null) pw.Text(company.address!, style: pw.TextStyle(font: ipaex)),
|
||||
if (company.tel != null) pw.Text('TEL: ${company.tel}', style: pw.TextStyle(font: ipaex)),
|
||||
pw.SizedBox(height: 12),
|
||||
pw.Text('宛先', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, font: ipaex)),
|
||||
pw.Text(shipment.customerNameSnapshot ?? '取引先未設定', style: pw.TextStyle(fontSize: 16, font: ipaex)),
|
||||
if (shipment.orderNumberSnapshot != null)
|
||||
pw.Text('受注番号: ${shipment.orderNumberSnapshot}', style: pw.TextStyle(font: ipaex)),
|
||||
pw.SizedBox(height: 12),
|
||||
pw.Text('配送情報', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, font: ipaex)),
|
||||
pw.Text('配送業者: ${shipment.carrierName ?? '-'}', style: pw.TextStyle(font: ipaex)),
|
||||
pw.Text('追跡番号: ${shipment.trackingNumber ?? '-'}', style: pw.TextStyle(font: ipaex)),
|
||||
if (shipment.trackingUrl != null && shipment.trackingUrl!.isNotEmpty)
|
||||
pw.Text('追跡URL: ${shipment.trackingUrl}', style: pw.TextStyle(font: ipaex)),
|
||||
pw.SizedBox(height: 12),
|
||||
pw.Text('出荷明細', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, font: ipaex)),
|
||||
pw.SizedBox(height: 6),
|
||||
if (shipment.items.isEmpty)
|
||||
pw.Text('明細登録なし', style: pw.TextStyle(font: ipaex))
|
||||
else
|
||||
pw.TableHelper.fromTextArray(
|
||||
headers: const ['品目', '数量'],
|
||||
data: shipment.items
|
||||
.map(
|
||||
(item) => [
|
||||
item.description,
|
||||
item.quantity.toString(),
|
||||
],
|
||||
)
|
||||
.toList(),
|
||||
headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold, font: ipaex),
|
||||
cellStyle: pw.TextStyle(font: ipaex),
|
||||
columnWidths: const {
|
||||
0: pw.FlexColumnWidth(3),
|
||||
1: pw.FlexColumnWidth(1),
|
||||
},
|
||||
),
|
||||
if (shipment.notes?.isNotEmpty == true) ...[
|
||||
pw.SizedBox(height: 12),
|
||||
pw.Text('備考', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, font: ipaex)),
|
||||
pw.Text(shipment.notes!, style: pw.TextStyle(font: ipaex)),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final directory = await getApplicationDocumentsDirectory();
|
||||
final timestamp = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now());
|
||||
final orderFragment = shipment.orderNumberSnapshot ?? shipment.id.substring(0, 6);
|
||||
final fileName = 'shipping_label_${orderFragment}_$timestamp.pdf';
|
||||
final file = File(p.join(directory.path, fileName));
|
||||
await file.writeAsBytes(await pdf.save());
|
||||
return file.path;
|
||||
} catch (e) {
|
||||
debugPrint('Shipping label generation failed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
34
lib/services/staff_repository.dart
Normal file
34
lib/services/staff_repository.dart
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
import '../models/staff_model.dart';
|
||||
import 'database_helper.dart';
|
||||
|
||||
class StaffRepository {
|
||||
StaffRepository();
|
||||
|
||||
final DatabaseHelper _dbHelper = DatabaseHelper();
|
||||
|
||||
Future<List<StaffMember>> fetchStaff({bool includeInactive = true}) async {
|
||||
final db = await _dbHelper.database;
|
||||
final rows = await db.query(
|
||||
'staff_members',
|
||||
where: includeInactive ? null : 'is_active = 1',
|
||||
orderBy: 'name COLLATE NOCASE ASC',
|
||||
);
|
||||
return rows.map(StaffMember.fromMap).toList();
|
||||
}
|
||||
|
||||
Future<void> saveStaff(StaffMember staff) async {
|
||||
final db = await _dbHelper.database;
|
||||
await db.insert(
|
||||
'staff_members',
|
||||
staff.toMap(),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteStaff(String staffId) async {
|
||||
final db = await _dbHelper.database;
|
||||
await db.delete('staff_members', where: 'id = ?', whereArgs: [staffId]);
|
||||
}
|
||||
}
|
||||
34
lib/services/supplier_repository.dart
Normal file
34
lib/services/supplier_repository.dart
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
import '../models/supplier_model.dart';
|
||||
import 'database_helper.dart';
|
||||
|
||||
class SupplierRepository {
|
||||
SupplierRepository();
|
||||
|
||||
final DatabaseHelper _dbHelper = DatabaseHelper();
|
||||
|
||||
Future<List<Supplier>> fetchSuppliers({bool includeHidden = false}) async {
|
||||
final db = await _dbHelper.database;
|
||||
final rows = await db.query(
|
||||
'suppliers',
|
||||
where: includeHidden ? null : 'is_hidden = 0',
|
||||
orderBy: 'name COLLATE NOCASE ASC',
|
||||
);
|
||||
return rows.map((row) => Supplier.fromMap(row)).toList();
|
||||
}
|
||||
|
||||
Future<void> saveSupplier(Supplier supplier) async {
|
||||
final db = await _dbHelper.database;
|
||||
await db.insert(
|
||||
'suppliers',
|
||||
supplier.toMap(),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteSupplier(String supplierId) async {
|
||||
final db = await _dbHelper.database;
|
||||
await db.delete('suppliers', where: 'id = ?', whereArgs: [supplierId]);
|
||||
}
|
||||
}
|
||||
27
lib/services/tax_setting_repository.dart
Normal file
27
lib/services/tax_setting_repository.dart
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
import '../models/tax_setting_model.dart';
|
||||
import 'database_helper.dart';
|
||||
|
||||
class TaxSettingRepository {
|
||||
TaxSettingRepository();
|
||||
|
||||
final DatabaseHelper _dbHelper = DatabaseHelper();
|
||||
|
||||
Future<TaxSetting> fetchCurrentSetting() async {
|
||||
final db = await _dbHelper.database;
|
||||
final rows = await db.query('tax_settings', orderBy: 'updated_at DESC', limit: 1);
|
||||
if (rows.isEmpty) {
|
||||
final now = DateTime.now();
|
||||
final defaultSetting = TaxSetting(id: 'default', rate: 0.1, roundingMode: 'round', updatedAt: now);
|
||||
await db.insert('tax_settings', defaultSetting.toMap());
|
||||
return defaultSetting;
|
||||
}
|
||||
return TaxSetting.fromMap(rows.first);
|
||||
}
|
||||
|
||||
Future<void> saveSetting(TaxSetting setting) async {
|
||||
final db = await _dbHelper.database;
|
||||
await db.insert('tax_settings', setting.toMap(), conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
}
|
||||
62
lib/widgets/analytics/analytics_summary_card.dart
Normal file
62
lib/widgets/analytics/analytics_summary_card.dart
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class AnalyticsSummaryCard extends StatelessWidget {
|
||||
const AnalyticsSummaryCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.value,
|
||||
this.subtitle,
|
||||
this.icon,
|
||||
this.color,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String value;
|
||||
final String? subtitle;
|
||||
final IconData? icon;
|
||||
final Color? color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final baseColor = color ?? theme.colorScheme.primary;
|
||||
final bgColor = baseColor.withValues(alpha: 0.1);
|
||||
final fgColor = baseColor;
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (icon != null)
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(color: fgColor, borderRadius: BorderRadius.circular(12)),
|
||||
child: Icon(icon, color: Colors.white),
|
||||
),
|
||||
if (icon != null) const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: theme.textTheme.labelLarge?.copyWith(color: fgColor)),
|
||||
const SizedBox(height: 4),
|
||||
Text(value, style: theme.textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold, color: fgColor)),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(subtitle!, style: theme.textTheme.bodySmall?.copyWith(color: fgColor.withValues(alpha: 0.8))),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
34
lib/widgets/analytics/empty_state_card.dart
Normal file
34
lib/widgets/analytics/empty_state_card.dart
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class EmptyStateCard extends StatelessWidget {
|
||||
const EmptyStateCard({super.key, required this.message, this.icon});
|
||||
|
||||
final String message;
|
||||
final IconData? icon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 32, horizontal: 24),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: theme.dividerColor.withValues(alpha: 0.2)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon ?? Icons.analytics_outlined, color: theme.colorScheme.onSurface.withValues(alpha: 0.4), size: 48),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurface.withValues(alpha: 0.7)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
35
lib/widgets/invoice_form/invoice_form_variant.dart
Normal file
35
lib/widgets/invoice_form/invoice_form_variant.dart
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/invoice_models.dart';
|
||||
|
||||
class InvoiceFormVariant {
|
||||
const InvoiceFormVariant({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.defaultDocumentType,
|
||||
this.heroDescription,
|
||||
this.heroIcon,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String title;
|
||||
final DocumentType defaultDocumentType;
|
||||
final String? heroDescription;
|
||||
final IconData? heroIcon;
|
||||
|
||||
static const InvoiceFormVariant billingDocs = InvoiceFormVariant(
|
||||
id: 'billing_docs',
|
||||
title: 'A1:伝票入力',
|
||||
defaultDocumentType: DocumentType.invoice,
|
||||
heroDescription: '既存のA1伝票(見積/納品/請求/領収)を作成できます',
|
||||
heroIcon: Icons.receipt_long,
|
||||
);
|
||||
|
||||
static const InvoiceFormVariant salesSlip = InvoiceFormVariant(
|
||||
id: 'sales_slip',
|
||||
title: '売上伝票入力',
|
||||
defaultDocumentType: DocumentType.invoice,
|
||||
heroDescription: '売上伝票として販売実績を登録します',
|
||||
heroIcon: Icons.point_of_sale,
|
||||
);
|
||||
}
|
||||
|
|
@ -8,6 +8,8 @@ class KeyboardInsetWrapper extends StatelessWidget {
|
|||
final double extraBottom;
|
||||
final Duration duration;
|
||||
final Curve curve;
|
||||
final bool safeAreaTop;
|
||||
final bool safeAreaBottom;
|
||||
|
||||
const KeyboardInsetWrapper({
|
||||
super.key,
|
||||
|
|
@ -16,16 +18,23 @@ class KeyboardInsetWrapper extends StatelessWidget {
|
|||
this.extraBottom = 0,
|
||||
this.duration = const Duration(milliseconds: 180),
|
||||
this.curve = Curves.easeOut,
|
||||
this.safeAreaTop = true,
|
||||
this.safeAreaBottom = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final bottomInset = mediaQuery.viewInsets.bottom;
|
||||
final padding = basePadding.add(EdgeInsets.only(bottom: bottomInset + extraBottom));
|
||||
final applyBottomSafeArea = safeAreaBottom && bottomInset == 0;
|
||||
return SafeArea(
|
||||
top: safeAreaTop,
|
||||
bottom: applyBottomSafeArea,
|
||||
child: AnimatedPadding(
|
||||
duration: duration,
|
||||
curve: curve,
|
||||
padding: basePadding.add(EdgeInsets.only(bottom: bottomInset + extraBottom)),
|
||||
padding: padding,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
|
|
|
|||
157
lib/widgets/line_item_editor.dart
Normal file
157
lib/widgets/line_item_editor.dart
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/product_model.dart';
|
||||
|
||||
/// 可変な明細行データを保持するフォームモデル。
|
||||
class LineItemFormData {
|
||||
LineItemFormData({
|
||||
this.id,
|
||||
this.productId,
|
||||
String? productName,
|
||||
int? quantity,
|
||||
int? unitPrice,
|
||||
this.taxRate,
|
||||
int? costAmount,
|
||||
bool? costIsProvisional,
|
||||
}) : descriptionController = TextEditingController(text: productName ?? ''),
|
||||
quantityController = TextEditingController(text: quantity?.toString() ?? ''),
|
||||
unitPriceController = TextEditingController(text: unitPrice?.toString() ?? ''),
|
||||
costAmount = costAmount ?? 0,
|
||||
costIsProvisional = costIsProvisional ?? true;
|
||||
|
||||
final String? id;
|
||||
String? productId;
|
||||
final TextEditingController descriptionController;
|
||||
final TextEditingController quantityController;
|
||||
final TextEditingController unitPriceController;
|
||||
double? taxRate;
|
||||
int costAmount;
|
||||
bool costIsProvisional;
|
||||
|
||||
bool get hasProduct => productId != null && productId!.isNotEmpty;
|
||||
String get description => descriptionController.text;
|
||||
int get quantityValue => int.tryParse(quantityController.text) ?? 0;
|
||||
int get unitPriceValue => int.tryParse(unitPriceController.text) ?? 0;
|
||||
|
||||
void applyProduct(Product product) {
|
||||
productId = product.id;
|
||||
descriptionController.text = product.name;
|
||||
if (quantityController.text.trim().isEmpty || quantityController.text.trim() == '0') {
|
||||
quantityController.text = '1';
|
||||
}
|
||||
unitPriceController.text = product.defaultUnitPrice.toString();
|
||||
costAmount = product.wholesalePrice;
|
||||
costIsProvisional = product.wholesalePrice <= 0;
|
||||
}
|
||||
|
||||
void registerChangeListener(VoidCallback listener) {
|
||||
descriptionController.addListener(listener);
|
||||
quantityController.addListener(listener);
|
||||
unitPriceController.addListener(listener);
|
||||
}
|
||||
|
||||
void removeChangeListener(VoidCallback listener) {
|
||||
descriptionController.removeListener(listener);
|
||||
quantityController.removeListener(listener);
|
||||
unitPriceController.removeListener(listener);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
descriptionController.dispose();
|
||||
quantityController.dispose();
|
||||
unitPriceController.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// 明細1行分を編集するカード。仕入/売上どちらの画面でも流用できるよう
|
||||
/// 追加のメタ情報やフッターを挿入できるようにしている。
|
||||
class LineItemCard extends StatelessWidget {
|
||||
const LineItemCard({
|
||||
super.key,
|
||||
required this.data,
|
||||
required this.onPickProduct,
|
||||
required this.onRemove,
|
||||
this.meta,
|
||||
this.footer,
|
||||
});
|
||||
|
||||
final LineItemFormData data;
|
||||
final VoidCallback onPickProduct;
|
||||
final VoidCallback onRemove;
|
||||
final Widget? meta;
|
||||
final Widget? footer;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
color: Colors.white,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
title: Text(
|
||||
data.descriptionController.text.isEmpty ? '商品を選択' : data.descriptionController.text,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
subtitle: data.hasProduct
|
||||
? null
|
||||
: const Text(
|
||||
'商品マスタから選択してください',
|
||||
style: TextStyle(color: Colors.redAccent),
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [meta, const Icon(Icons.chevron_right)]
|
||||
.whereType<Widget>()
|
||||
.toList(growable: false),
|
||||
),
|
||||
onTap: onPickProduct,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: data.quantityController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '数量',
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(vertical: 8, horizontal: 8),
|
||||
),
|
||||
scrollPadding: const EdgeInsets.only(bottom: 160),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: data.unitPriceController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '単価(税抜)',
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(vertical: 8, horizontal: 8),
|
||||
),
|
||||
scrollPadding: const EdgeInsets.only(bottom: 160),
|
||||
),
|
||||
),
|
||||
IconButton(onPressed: onRemove, icon: const Icon(Icons.close)),
|
||||
],
|
||||
),
|
||||
...[
|
||||
footer == null ? null : const SizedBox(height: 8),
|
||||
footer,
|
||||
].whereType<Widget>(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
20
lib/widgets/modal_utils.dart
Normal file
20
lib/widgets/modal_utils.dart
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Presents a rounded feature modal with consistent safe-area handling.
|
||||
Future<T?> showFeatureModalBottomSheet<T>({
|
||||
required BuildContext context,
|
||||
required WidgetBuilder builder,
|
||||
double heightFactor = 0.9,
|
||||
bool isScrollControlled = true,
|
||||
Color backgroundColor = Colors.transparent,
|
||||
}) {
|
||||
return showModalBottomSheet<T>(
|
||||
context: context,
|
||||
isScrollControlled: isScrollControlled,
|
||||
backgroundColor: backgroundColor,
|
||||
builder: (sheetContext) => FractionallySizedBox(
|
||||
heightFactor: heightFactor,
|
||||
child: builder(sheetContext),
|
||||
),
|
||||
);
|
||||
}
|
||||
45
lib/widgets/screen_id_title.dart
Normal file
45
lib/widgets/screen_id_title.dart
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
/// AppBar 用の画面ID表示ウィジェット。
|
||||
/// 必ず2桁の ScreenID と正式タイトルをセットで表示し、
|
||||
/// サポート時に下段へ `ScreenID: XX` を出す。
|
||||
class ScreenAppBarTitle extends StatelessWidget {
|
||||
const ScreenAppBarTitle({
|
||||
super.key,
|
||||
required this.screenId,
|
||||
required this.title,
|
||||
this.caption,
|
||||
});
|
||||
|
||||
final String screenId;
|
||||
final String title;
|
||||
final String? caption;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final primaryStyle = theme.appBarTheme.titleTextStyle ??
|
||||
theme.textTheme.titleMedium?.copyWith(color: Colors.white, fontWeight: FontWeight.w600);
|
||||
final secondaryStyle = theme.textTheme.bodySmall?.copyWith(color: Colors.white70, fontSize: 11);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'$screenId:$title',
|
||||
style: primaryStyle,
|
||||
),
|
||||
Text(
|
||||
'ScreenID: $screenId',
|
||||
style: secondaryStyle,
|
||||
),
|
||||
if (caption != null)
|
||||
Text(
|
||||
caption!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: Colors.white70),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import Foundation
|
|||
|
||||
import file_selector_macos
|
||||
import geolocator_apple
|
||||
import google_sign_in_ios
|
||||
import mobile_scanner
|
||||
import package_info_plus
|
||||
import printing
|
||||
|
|
@ -18,6 +19,7 @@ import url_launcher_macos
|
|||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
||||
FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin"))
|
||||
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin"))
|
||||
|
|
|
|||
68
pubspec.lock
68
pubspec.lock
|
|
@ -1,6 +1,14 @@
|
|||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
_discoveryapis_commons:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: _discoveryapis_commons
|
||||
sha256: "113c4100b90a5b70a983541782431b82168b3cae166ab130649c36eb3559d498"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.7"
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -74,7 +82,7 @@ packages:
|
|||
source: hosted
|
||||
version: "1.0.0"
|
||||
collection:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: collection
|
||||
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||
|
|
@ -296,6 +304,62 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
google_identity_services_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: google_identity_services_web
|
||||
sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.3+1"
|
||||
google_sign_in:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: google_sign_in
|
||||
sha256: d0a2c3bcb06e607bb11e4daca48bd4b6120f0bbc4015ccebbe757d24ea60ed2a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.0"
|
||||
google_sign_in_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: google_sign_in_android
|
||||
sha256: d5e23c56a4b84b6427552f1cf3f98f716db3b1d1a647f16b96dbb5b93afa2805
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.2.1"
|
||||
google_sign_in_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: google_sign_in_ios
|
||||
sha256: "102005f498ce18442e7158f6791033bbc15ad2dcc0afa4cf4752e2722a516c96"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.9.0"
|
||||
google_sign_in_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: google_sign_in_platform_interface
|
||||
sha256: "5f6f79cf139c197261adb6ac024577518ae48fdff8e53205c5373b5f6430a8aa"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.0"
|
||||
google_sign_in_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: google_sign_in_web
|
||||
sha256: "460547beb4962b7623ac0fb8122d6b8268c951cf0b646dd150d60498430e4ded"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.4+4"
|
||||
googleapis:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: googleapis
|
||||
sha256: "864f222aed3f2ff00b816c675edf00a39e2aaf373d728d8abec30b37bee1a81c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "13.2.0"
|
||||
gsettings:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -481,7 +545,7 @@ packages:
|
|||
source: hosted
|
||||
version: "0.13.0"
|
||||
meta:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: meta
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
|
|
|
|||
11
pubspec.yaml
11
pubspec.yaml
|
|
@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
|||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.5.09+154
|
||||
version: 1.5.10+155
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.7
|
||||
|
|
@ -57,6 +57,10 @@ dependencies:
|
|||
http: ^1.2.2
|
||||
shelf: ^1.4.1
|
||||
shelf_router: ^1.1.4
|
||||
collection: ^1.18.0
|
||||
meta: ^1.17.0
|
||||
google_sign_in: ^6.2.1
|
||||
googleapis: ^13.2.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
@ -80,9 +84,8 @@ flutter:
|
|||
uses-material-design: true
|
||||
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
# assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
# - images/a_dot_ham.jpeg
|
||||
assets:
|
||||
- assets/icon/app_icon.png
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/to/resolution-aware-images
|
||||
|
|
|
|||
|
|
@ -3,12 +3,22 @@ set -euo pipefail
|
|||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
BUILD_MODE="${1:-debug}"
|
||||
FLAVOR="${2:-client}"
|
||||
|
||||
case "${BUILD_MODE}" in
|
||||
debug|profile|release)
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 [debug|profile|release]" >&2
|
||||
echo "Usage: $0 [debug|profile|release] [client|mothership]" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
case "${FLAVOR}" in
|
||||
client|mothership)
|
||||
;;
|
||||
*)
|
||||
echo "Invalid flavor '${FLAVOR}'. Use 'client' or 'mothership'." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
|
@ -16,23 +26,30 @@ esac
|
|||
cd "${PROJECT_ROOT}"
|
||||
|
||||
timestamp="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
DART_DEFINE="APP_BUILD_TIMESTAMP=${timestamp}"
|
||||
DART_DEFINES=(
|
||||
"APP_BUILD_TIMESTAMP=${timestamp}"
|
||||
"ENABLE_DEBUG_FEATURES=true"
|
||||
)
|
||||
dart_define_args=()
|
||||
for define in "${DART_DEFINES[@]}"; do
|
||||
dart_define_args+=("--dart-define=${define}")
|
||||
done
|
||||
|
||||
echo "[build_with_expiry] Using timestamp: ${timestamp} (UTC)"
|
||||
echo "[build_with_expiry] Running flutter analyze..."
|
||||
flutter analyze
|
||||
|
||||
echo "[build_with_expiry] Building APK (${BUILD_MODE})..."
|
||||
echo "[build_with_expiry] Building APK (${BUILD_MODE}, flavor=${FLAVOR})..."
|
||||
case "${BUILD_MODE}" in
|
||||
debug)
|
||||
flutter build apk --debug --dart-define="${DART_DEFINE}"
|
||||
flutter build apk --debug --flavor "${FLAVOR}" "${dart_define_args[@]}"
|
||||
;;
|
||||
profile)
|
||||
flutter build apk --profile --dart-define="${DART_DEFINE}"
|
||||
flutter build apk --profile --flavor "${FLAVOR}" "${dart_define_args[@]}"
|
||||
;;
|
||||
release)
|
||||
flutter build apk --release --dart-define="${DART_DEFINE}"
|
||||
flutter build apk --release --flavor "${FLAVOR}" "${dart_define_args[@]}"
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "[build_with_expiry] Done. APK with 90-day lifespan generated."
|
||||
echo "[build_with_expiry] Done. APK with 90-day lifespan & full features generated."
|
||||
|
|
|
|||
Loading…
Reference in a new issue