Compare commits

...

4 commits

Author SHA1 Message Date
joe
3cfbe9dcb7 feature/売上管理:未コミットを保存 2026-03-05 23:10:57 +09:00
joe
a2f7013984 売上管理関連スクリーン画面の修正 2026-03-05 23:03:44 +09:00
joe
c98dd3cc72 仕入れ伝票実装 2026-03-05 09:33:38 +09:00
joe
632a95bd0e 売上伝票実装 2026-03-04 14:55:40 +09:00
98 changed files with 14904 additions and 796 deletions

125
README.md
View file

@ -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 の「将来像」節で随時アップデートします。現在は売上モジュールが最初の実装例です。
---

View file

@ -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.

View file

@ -35,7 +35,7 @@
</queries>
<application
android:label="h-1"
android:label="${appName}"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">販売アシスト1号</string>
</resources>

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

BIN
assets/icon/app_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View file

@ -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"]];

View file

@ -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;
}

View file

@ -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>(

View 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(),
};
}
}

View 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;
}

View 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,
);
}
}

View file

@ -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,
);
}

View 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,
);
}
}

View file

@ -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,

View 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,
);
}

View 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),
);
}
}

View 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?,
);
}
}

View 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);
}

View 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;
}
}
}

View 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(),
};
}
}

View 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(),
};
}
}

View 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(),
};
}
}

View 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)),
);
},
),
),
);
},
),
];
}

View 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;
}
}
}

View 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;
}
}

View 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()));
},
),
];
}

View 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()),
);
},
),
];
}

View 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()));
},
),
];
}

View file

@ -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),

View file

@ -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(

View file

@ -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: [

View file

@ -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: '再同期',

View file

@ -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: [

View file

@ -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,8 +223,8 @@ class _CustomerPickerModalState extends State<CustomerPickerModal> {
@override
Widget build(BuildContext context) {
return Material(
child: KeyboardInsetWrapper(
final body = KeyboardInsetWrapper(
safeAreaTop: false,
basePadding: const EdgeInsets.fromLTRB(0, 0, 0, 16),
extraBottom: 32,
child: CustomScrollView(
@ -235,14 +236,6 @@ class _CustomerPickerModalState extends State<CustomerPickerModal> {
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)),
],
),
const SizedBox(height: 12),
TextField(
decoration: InputDecoration(
hintText: "登録済み顧客を検索...",
@ -313,6 +306,23 @@ class _CustomerPickerModalState extends State<CustomerPickerModal> {
),
],
),
);
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,
),
),
);
}

View file

@ -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;
}

View 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),
],
),
);
}
}

View file

@ -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,
),

View file

@ -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)),

View file

@ -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(

View file

@ -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,12 +505,24 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
final docColor = _documentTypeColor(_documentType);
return Scaffold(
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: const BackButton(),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => _handleBackPressed(),
),
title: Text("A1:${_documentTypeLabel(_documentType)}"),
actions: [
if (_isDraft)
@ -448,7 +533,7 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
IconButton(
icon: const Icon(Icons.copy),
tooltip: "コピーして新規",
onPressed: _copyAsNew,
onPressed: () => _copyAsNew(),
),
if (_isLocked)
const Padding(
@ -527,6 +612,7 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
),
],
),
),
);
}

View file

@ -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 {

View file

@ -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)),
],
),
),

View file

@ -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),

View file

@ -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,29 +40,26 @@ 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(
children: [
IconButton(
icon: const Icon(Icons.arrow_back),
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),
),
const SizedBox(width: 4),
const Text("商品・サービス選択", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
],
title: const ScreenAppBarTitle(screenId: 'P6', title: '商品・サービス選択'),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: TextField(
body: KeyboardInsetWrapper(
safeAreaTop: false,
basePadding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
extraBottom: 24,
child: Column(
children: [
TextField(
controller: _searchController,
autofocus: true,
decoration: InputDecoration(
@ -67,16 +67,17 @@ class _ProductPickerModalState extends State<ProductPickerModal> {
prefixIcon: const Icon(Icons.search),
suffixIcon: IconButton(
icon: const Icon(Icons.clear),
onPressed: () { _searchController.clear(); _onSearch(""); },
onPressed: () {
_searchController.clear();
_onSearch("");
},
),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
contentPadding: const EdgeInsets.symmetric(vertical: 0),
),
onChanged: _onSearch,
),
),
const SizedBox(height: 8),
const Divider(),
const SizedBox(height: 12),
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
@ -105,6 +106,7 @@ class _ProductPickerModalState extends State<ProductPickerModal> {
title: Text(product.name),
subtitle: Text("${product.defaultUnitPrice} (在庫: ${product.stockQuantity})"),
onTap: () {
widget.onProductSelected?.call(product);
widget.onItemSelected(
InvoiceItem(
productId: product.id,
@ -167,6 +169,9 @@ class _ProductPickerModalState extends State<ProductPickerModal> {
),
],
),
),
),
),
);
}
}

View 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,
),
),
),
),
],
),
),
),
);
}
}

View 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),
);
},
),
),
],
),
),
);
}
}

View 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,
),
],
),
),
);
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View 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),
);
},
),
),
],
),
),
),
);
}
}

View file

@ -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
body: RefreshIndicator(
onRefresh: _loadData,
child: _isLoading
? const Center(child: CircularProgressIndicator())
: Column(
: _summary == null
? const Center(child: Text('データを取得できませんでした'))
: ListView(
padding: const EdgeInsets.all(16),
children: [
_buildYearSelector(),
_buildYearlySummary(fmt),
const Divider(height: 1),
Expanded(child: _buildMonthlyList(fmt)),
const SizedBox(height: 12),
_buildFilterRow(),
const SizedBox(height: 16),
_buildSummaryCards(),
const SizedBox(height: 16),
_buildTopCustomers(),
const SizedBox(height: 16),
_buildMonthlyList(),
],
),
),
);
}
Widget _buildYearSelector() {
return Container(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
decoration: BoxDecoration(
color: Colors.indigo.shade50,
padding: const EdgeInsets.symmetric(vertical: 8),
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: const Icon(Icons.chevron_left),
@ -86,43 +108,168 @@ 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,
),
child: Column(
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: [
const Text("年間売上合計 (請求確定分)", style: TextStyle(color: Colors.white70)),
Wrap(
spacing: 8,
runSpacing: 8,
children: chips,
),
const SizedBox(height: 8),
Text(
"${fmt.format(_yearlyTotal)}",
style: const TextStyle(color: Colors.white, fontSize: 32, fontWeight: FontWeight.bold),
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(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";
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 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.blueGrey.shade100,
child: Text("${index + 1}", style: const TextStyle(color: Colors.indigo)),
backgroundColor: Colors.indigo.withValues(alpha: 0.1),
foregroundColor: Colors.indigo,
child: Text(month.toString()),
),
title: Text("${index + 1}月の売上"),
subtitle: amount > 0 ? Text("シェア: $percentage%") : null,
title: Text('$month月の売上'),
subtitle: amount > 0 ? Text('シェア ${(share * 100).toStringAsFixed(1)}%') : const Text('データなし'),
trailing: Text(
"${fmt.format(amount)}",
'${fmt.format(amount)}',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
@ -130,7 +277,19 @@ class _SalesReportScreenState extends State<SalesReportScreen> {
),
),
);
},
}),
],
),
);
}
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),
);
}
}

View file

@ -1,14 +1,19 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:googleapis/calendar/v3.dart' as gcal;
import 'package:image_picker/image_picker.dart';
import '../services/app_settings_repository.dart';
import '../services/calendar_sync_diagnostics.dart';
import '../services/calendar_sync_service.dart';
import '../services/theme_controller.dart';
import 'company_info_screen.dart';
import 'email_settings_screen.dart';
import 'business_profile_screen.dart';
import 'chat_screen.dart';
import 'dashboard_screen.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@ -17,6 +22,14 @@ class SettingsScreen extends StatefulWidget {
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _CalendarOption {
const _CalendarOption({required this.id, required this.summary, this.detail});
final String id;
final String summary;
final String? detail;
}
//
const Map<String, IconData> kIconsMap = {
'list_alt': Icons.list_alt,
@ -33,6 +46,8 @@ const Map<String, IconData> kIconsMap = {
class _SettingsScreenState extends State<SettingsScreen> {
final _appSettingsRepo = AppSettingsRepository();
final _calendarSyncService = CalendarSyncService();
final _calendarDiagnostics = CalendarSyncDiagnostics();
// External sync ()
final _externalHostCtrl = TextEditingController();
@ -54,6 +69,25 @@ class _SettingsScreenState extends State<SettingsScreen> {
final _statusTextCtrl = TextEditingController(text: '工事中');
List<DashboardMenuItem> _menuItems = [];
bool _loadingAppSettings = true;
bool _forceDashboardOnExit = false;
// Google Calendar
bool _calendarEnabled = false;
bool _calendarBusy = false;
bool _loadingCalendars = false;
bool _calendarSyncing = false;
String? _googleAccountEmail;
String? _selectedCalendarId;
List<_CalendarOption> _availableCalendars = [];
String? _lastCalendarSyncStatus;
bool get _supportsCalendarSync => !kIsWeb && (Platform.isAndroid || Platform.isIOS);
// Gross profit / sales entry options
bool _grossProfitEnabled = true;
bool _grossProfitToggleVisible = true;
bool _grossProfitIncludeProvisional = false;
bool _salesEntryDefaultCashMode = false;
bool _salesEntryShowGross = true;
static const _kExternalHost = 'external_host';
static const _kExternalPass = 'external_pass';
@ -98,6 +132,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
_menuItems = menu;
_loadingAppSettings = false;
});
await _loadCalendarSettings();
await _loadGrossProfitSettings();
}
@override
@ -116,12 +153,29 @@ class _SettingsScreenState extends State<SettingsScreen> {
await _appSettingsRepo.setDashboardStatusText(_statusTextCtrl.text.trim().isEmpty ? '工事中' : _statusTextCtrl.text.trim());
await _appSettingsRepo.setDashboardMenu(_menuItems);
_showSnackbar('ホーム/ダッシュボード設定を保存しました');
setState(() {
_forceDashboardOnExit = _homeDashboard;
});
}
Future<void> _persistMenu() async {
await _appSettingsRepo.setDashboardMenu(_menuItems);
}
void _handleExit() {
if (!mounted) return;
if (_forceDashboardOnExit) {
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const DashboardScreen()),
(route) => false,
);
} else {
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
}
}
}
void _addMenuItem() async {
final titleCtrl = TextEditingController();
String route = 'invoice_history';
@ -129,7 +183,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
String? customIconPath;
await showDialog(
context: context,
builder: (ctx) => AlertDialog(
builder: (ctx) => AnimatedPadding(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
padding: EdgeInsets.only(bottom: MediaQuery.of(ctx).viewInsets.bottom),
child: AlertDialog(
title: const Text('メニューを追加'),
content: SingleChildScrollView(
child: Column(
@ -145,6 +203,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
DropdownMenuItem(value: 'customer_master', child: Text('C1:顧客マスター')),
DropdownMenuItem(value: 'product_master', child: Text('P1:商品マスター')),
DropdownMenuItem(value: 'master_hub', child: Text('M1:マスター管理')),
DropdownMenuItem(value: 'sales_operations', child: Text('B1:販売オペレーション')),
DropdownMenuItem(value: 'settings', child: Text('S1:設定')),
],
onChanged: (v) => route = v ?? 'invoice_history',
@ -196,6 +255,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
],
),
),
);
}
@ -227,6 +287,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
return 'P1:商品マスター';
case 'master_hub':
return 'M1:マスター管理';
case 'sales_operations':
return 'B1:販売オペレーション';
case 'settings':
return 'S1:設定';
default:
@ -258,6 +320,21 @@ class _SettingsScreenState extends State<SettingsScreen> {
void _pickBackupPath() => _showSnackbar('バックアップ先の選択は後で実装');
Future<void> _setGrossProfitEnabled(bool value) async {
setState(() => _grossProfitEnabled = value);
await _appSettingsRepo.setGrossProfitEnabled(value);
}
Future<void> _setGrossProfitToggleVisible(bool value) async {
setState(() => _grossProfitToggleVisible = value);
await _appSettingsRepo.setGrossProfitToggleVisible(value);
}
Future<void> _setGrossProfitIncludeProvisional(bool value) async {
setState(() => _grossProfitIncludeProvisional = value);
await _appSettingsRepo.setGrossProfitIncludeProvisional(value);
}
Future<void> _loadKanaMap() async {
final json = await _appSettingsRepo.getString('customKanaMap');
if (json != null && json.isNotEmpty) {
@ -275,15 +352,221 @@ class _SettingsScreenState extends State<SettingsScreen> {
_showSnackbar('かなインデックスを保存しました');
}
Future<void> _loadCalendarSettings() async {
final enabled = await _appSettingsRepo.getGoogleCalendarEnabled();
final calendarId = await _appSettingsRepo.getGoogleCalendarId();
final account = await _appSettingsRepo.getGoogleCalendarAccountEmail();
if (!mounted) return;
setState(() {
_calendarEnabled = enabled;
_selectedCalendarId = calendarId;
_googleAccountEmail = account;
});
}
Future<void> _loadGrossProfitSettings() async {
final enabled = await _appSettingsRepo.getGrossProfitEnabled();
final toggleVisible = await _appSettingsRepo.getGrossProfitToggleVisible();
final includeProvisional = await _appSettingsRepo.getGrossProfitIncludeProvisional();
final defaultCash = await _appSettingsRepo.getSalesEntryCashModeDefault();
final showGross = await _appSettingsRepo.getSalesEntryShowGross();
if (!mounted) return;
setState(() {
_grossProfitEnabled = enabled;
_grossProfitToggleVisible = toggleVisible;
_grossProfitIncludeProvisional = includeProvisional;
_salesEntryDefaultCashMode = defaultCash;
_salesEntryShowGross = showGross;
});
}
Future<void> _setSalesEntryDefaultCashMode(bool value) async {
setState(() => _salesEntryDefaultCashMode = value);
await _appSettingsRepo.setSalesEntryCashModeDefault(value);
}
Future<void> _setSalesEntryShowGross(bool value) async {
setState(() => _salesEntryShowGross = value);
await _appSettingsRepo.setSalesEntryShowGross(value);
}
Future<void> _handleCalendarEnabledChanged(bool enabled) async {
if (_calendarBusy) return;
setState(() => _calendarBusy = true);
try {
if (enabled) {
final success = await _calendarSyncService.ensureSignedIn();
if (!success) {
if (!mounted) return;
setState(() {
_calendarEnabled = false;
});
_showSnackbar('Googleサインインに失敗しました');
return;
}
await _appSettingsRepo.setGoogleCalendarEnabled(true);
final email = await _appSettingsRepo.getGoogleCalendarAccountEmail();
if (!mounted) return;
setState(() {
_calendarEnabled = true;
_googleAccountEmail = email;
});
await _refreshCalendarList();
_showSnackbar('Googleカレンダー連携を有効化しました');
} else {
await _calendarSyncService.signOut();
await _appSettingsRepo.setGoogleCalendarEnabled(false);
if (!mounted) return;
setState(() {
_calendarEnabled = false;
_googleAccountEmail = null;
_selectedCalendarId = null;
_availableCalendars = [];
});
_showSnackbar('Googleカレンダー連携を無効化しました');
}
} finally {
if (mounted) {
setState(() => _calendarBusy = false);
}
}
}
Future<void> _refreshCalendarList() async {
if (_loadingCalendars) return;
if (!_calendarEnabled) {
_showSnackbar('まずは連携スイッチをONにしてください');
return;
}
setState(() => _loadingCalendars = true);
try {
final ready = await _calendarSyncService.ensureSignedIn();
if (!ready) {
_showSnackbar('Googleアカウントの認証が必要です');
return;
}
final List<gcal.CalendarListEntry> calendars = await _calendarSyncService.fetchCalendars();
final options = calendars
.where((entry) => (entry.id ?? '').isNotEmpty)
.map((entry) => _CalendarOption(
id: entry.id!,
summary: entry.summary ?? entry.id!,
detail: entry.primary == true ? 'プライマリ' : entry.accessRole,
))
.toList();
final hasPrimary = options.any((o) => o.id == 'primary');
if (!hasPrimary) {
options.insert(0, const _CalendarOption(id: 'primary', summary: 'デフォルト(プライマリ)'));
}
if (!mounted) return;
setState(() {
_availableCalendars = options;
if (_selectedCalendarId == null || !_availableCalendars.any((o) => o.id == _selectedCalendarId)) {
_selectedCalendarId = options.isNotEmpty ? options.first.id : null;
}
});
if (_selectedCalendarId != null) {
await _appSettingsRepo.setGoogleCalendarId(_selectedCalendarId);
}
} catch (e) {
_showSnackbar('カレンダー一覧の取得に失敗しました');
} finally {
if (mounted) {
setState(() => _loadingCalendars = false);
}
}
}
Future<void> _handleCalendarSelection(String? calendarId) async {
if (calendarId == null) return;
setState(() => _selectedCalendarId = calendarId);
await _appSettingsRepo.setGoogleCalendarId(calendarId);
_showSnackbar('同期先カレンダーを保存しました');
}
Future<void> _reauthenticateCalendarAccount() async {
if (_calendarBusy) return;
setState(() => _calendarBusy = true);
try {
final success = await _calendarSyncService.ensureSignedIn();
if (!success) {
_showSnackbar('Googleサインインに失敗しました');
return;
}
final email = await _appSettingsRepo.getGoogleCalendarAccountEmail();
if (!mounted) return;
setState(() => _googleAccountEmail = email);
_showSnackbar('Googleアカウントを更新しました');
} finally {
if (mounted) setState(() => _calendarBusy = false);
}
}
Future<void> _runCalendarDiagnostics() async {
if (_calendarSyncing) return;
if (!_calendarEnabled) {
_showSnackbar('まずはカレンダー連携を有効にしてください');
return;
}
setState(() => _calendarSyncing = true);
try {
final result = await _calendarDiagnostics.runFullSync();
if (!mounted) return;
final now = DateTime.now();
final timestamp =
'${now.year}/${now.month.toString().padLeft(2, '0')}/${now.day.toString().padLeft(2, '0')} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}';
final summary = '出荷${result.shipmentsSynced}件 / 債権${result.receivablesSynced}';
setState(() {
_lastCalendarSyncStatus = '最終同期: $timestamp ($summary)';
});
if (result.hasErrors) {
_showSnackbar('同期は完了しましたが一部でエラーが発生しました');
await showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('同期時のエラー'),
content: Text(result.errors.join('\n')),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('OK')),
],
),
);
} else {
_showSnackbar('Googleカレンダー同期を実行しました ($summary)');
}
} catch (e) {
debugPrint('Manual calendar sync failed: $e');
if (mounted) {
_showSnackbar('Googleカレンダー同期に失敗しました: $e');
}
} finally {
if (mounted) {
setState(() => _calendarSyncing = false);
}
}
}
@override
Widget build(BuildContext context) {
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
final route = ModalRoute.of(context);
final bool isCurrentRoute = route?.isCurrent ?? true;
final bottomInset = isCurrentRoute ? MediaQuery.of(context).viewInsets.bottom : 0.0;
final listBottomPadding = 24 + bottomInset;
return Scaffold(
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) {
if (didPop) return;
_handleExit();
},
child: Scaffold(
resizeToAvoidBottomInset: false,
appBar: AppBar(
title: const Text('S1:設定'),
backgroundColor: Colors.indigo,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: _handleExit,
),
actions: [
IconButton(
icon: const Icon(Icons.info_outline),
@ -365,6 +648,173 @@ class _SettingsScreenState extends State<SettingsScreen> {
],
),
),
if (_supportsCalendarSync)
_section(
title: 'Googleカレンダー連携',
subtitle: '出荷追跡や集金・入金予定をGoogleカレンダーへ自動登録',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SwitchListTile(
title: const Text('Googleカレンダーと連携する'),
value: _calendarEnabled,
onChanged: _calendarBusy ? null : _handleCalendarEnabledChanged,
),
const SizedBox(height: 8),
if (_calendarEnabled)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('接続アカウント'),
subtitle: Text(_googleAccountEmail ?? '未サインイン'),
trailing: _calendarBusy
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2))
: TextButton.icon(
onPressed: () => _handleCalendarEnabledChanged(false),
icon: const Icon(Icons.logout),
label: const Text('切断'),
),
),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
icon: const Icon(Icons.refresh),
label: const Text('カレンダー一覧を取得'),
onPressed: _loadingCalendars ? null : _refreshCalendarList,
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
icon: const Icon(Icons.verified_user),
label: const Text('Googleを再認証'),
onPressed: _calendarBusy ? null : _reauthenticateCalendarAccount,
),
),
if (_loadingCalendars)
const Padding(
padding: EdgeInsets.only(left: 12),
child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)),
),
],
),
const SizedBox(height: 12),
if (_availableCalendars.isEmpty)
Text(
'まだ同期先カレンダーが選ばれていません。「カレンダー一覧を取得」を押して選択してください。',
style: Theme.of(context).textTheme.bodySmall,
)
else
DropdownButtonFormField<String>(
key: ValueKey(_selectedCalendarId ?? 'none'),
initialValue: _selectedCalendarId,
decoration: const InputDecoration(labelText: '同期先カレンダー'),
items: _availableCalendars
.map((option) => DropdownMenuItem<String>(
value: option.id,
child: Text(option.detail == null ? option.summary : '${option.summary} (${option.detail})'),
))
.toList(),
onChanged: _loadingCalendars ? null : _handleCalendarSelection,
),
const SizedBox(height: 12),
OutlinedButton.icon(
icon: _calendarSyncing
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.sync),
label: Text(_calendarSyncing ? '同期を実行中…' : '今すぐカレンダー同期を実行'),
onPressed: _calendarSyncing ? null : _runCalendarDiagnostics,
),
if (_lastCalendarSyncStatus != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
_lastCalendarSyncStatus!,
style: Theme.of(context).textTheme.bodySmall,
),
),
],
)
else
Text(
'カレンダー連携を有効化するとGoogleアカウント認証と同期先の選択が行えます。',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
)
else
_section(
title: 'Googleカレンダー連携',
subtitle: 'この設定はAndroid/iOS版のみ対応しています',
child: Text(
'デスクトップ版ではGoogleカレンダー連携を利用できません。モバイル端末から設定してください。',
style: Theme.of(context).textTheme.bodySmall,
),
),
_section(
title: '粗利表示 / 暫定粗利',
subtitle: '売上伝票で卸値にもとづく粗利を表示し、未入荷商品の扱いを制御します',
child: Column(
children: [
SwitchListTile.adaptive(
title: const Text('U2/A1で粗利を表示'),
subtitle: const Text('単価-仕入値を明細ごとに計算して表示します'),
value: _grossProfitEnabled,
onChanged: _setGrossProfitEnabled,
),
SwitchListTile.adaptive(
title: const Text('営業端末に粗利表示スイッチを表示'),
subtitle: const Text('現場ユーザーが粗利の表示/非表示を切り替えられるようにします'),
value: _grossProfitToggleVisible,
onChanged: _setGrossProfitToggleVisible,
),
SwitchListTile.adaptive(
title: const Text('暫定粗利(仕入未確定)を合計に含める'),
subtitle: const Text('仕入値が未登録の明細は粗利=0で仮計上し、合計に含めるかを制御します'),
value: _grossProfitIncludeProvisional,
onChanged: _setGrossProfitIncludeProvisional,
),
const SizedBox(height: 8),
Text(
'仕入値未登録の明細は暫定0円として扱い、仕入確定後に再計算できます。',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
_section(
title: 'U2エディタ表示モード',
subtitle: '長押しドロワーや初期表示状態をここで統一できます',
child: Column(
children: [
SwitchListTile.adaptive(
title: const Text('新規伝票を現金売上モードで開始'),
subtitle: const Text('顧客未選択で「現金売上」名義を自動入力します'),
value: _salesEntryDefaultCashMode,
onChanged: _setSalesEntryDefaultCashMode,
),
SwitchListTile.adaptive(
title: const Text('新規伝票で粗利を初期表示'),
subtitle: const Text('U2/A1を開いた直後から粗利メタ情報を表示します'),
value: _salesEntryShowGross,
onChanged: _setSalesEntryShowGross,
),
const SizedBox(height: 8),
Text(
'U2のタイトルを長押しすると現場向けのクイックドロワーが開き、これらの設定を一時的に切り替えられます。',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
_section(
title: '自社情報',
subtitle: '会社・担当者・振込口座・電話帳取り込み',
@ -563,6 +1013,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
],
),
),
),
);
}

View 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),
],
),
);
}
}

View 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),
],
),
);
}
}

View 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),
],
),
),
),
),
],
),
),
),
);
}
}

View 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('保存'),
),
),
],
),
),
);
}
}

View file

@ -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 {

View 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');
}
}
}

View 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,
);
}
}

View 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();
}
}

View file

@ -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

View file

@ -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)');
}
}

View 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);
}
}

View 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();
}
}
}
}

View 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]);
}
}

View 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;
}
}

View 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;
}
}
}

View file

@ -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;
}
}

View 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';
}
}
}

View 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();
}
}

View 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);
}
}

View 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]);
});
}
}

View 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;
}
}

View 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';
}
}
}

View 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();
}
}

View 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();
}
}

View 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;
}

View 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();
}
}

View 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;
}
}

View 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;
}
}

View 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();
}
}

View 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();
}
}

View 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;
}
}
}

View 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]);
}
}

View 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]);
}
}

View 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);
}
}

View 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))),
],
],
),
),
],
),
);
}
}

View 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)),
),
],
),
);
}
}

View 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,
);
}

View file

@ -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,
),
);

View 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>(),
],
),
),
);
}
}

View 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),
),
);
}

View 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),
),
],
);
}
}

View file

@ -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"))

View file

@ -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"

View file

@ -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

View file

@ -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."