diff --git a/lib/models/estimate.dart b/lib/models/estimate.dart new file mode 100644 index 0000000..13c8e0c --- /dev/null +++ b/lib/models/estimate.dart @@ -0,0 +1,110 @@ +// Version: 1.0.0 +import 'dart:convert'; + +/// 見積(Estimate)モデル +class Estimate { + final int id; + final String estimateNo; // 見積書 No. + final int? customerId; + final String customerName; + final DateTime date; + final List items; + final double taxRate; + final double totalAmount; + + Estimate({ + required this.id, + required this.estimateNo, + this.customerId, + required this.customerName, + required this.date, + required this.items, + required this.taxRate, + required this.totalAmount, + }); + + /// 引数の ID が重複しているか確認する(null 許容) + bool hasDuplicateId(int? id) { + if (id == null) return false; + // items に productId が重複していないか確認 + final itemIds = items.map((item) => item.productId).toSet(); + return !itemIds.contains(id); + } + + Map toMap() { + return { + 'id': id, + 'estimateNo': estimateNo, + 'customerId': customerId, + 'customerName': customerName, + 'date': date.toIso8601String(), + 'itemsJson': jsonEncode(items.map((e) => e.toMap()).toList()), + 'taxRate': taxRate, + 'totalAmount': totalAmount, + }; + } + + factory Estimate.fromMap(Map map) { + final items = (map['itemsJson'] as String?) != null + ? ((jsonDecode(map['itemsJson']) as List) + .map((e) => EstimateItem.fromMap(e as Map)) + .toList()) + : []; + + return Estimate( + id: map['id'] as int, + estimateNo: map['estimateNo'] as String, + customerId: map['customerId'] as int?, + customerName: map['customerName'] as String, + date: DateTime.parse(map['date'] as String), + items: items, + taxRate: (map['taxRate'] as num).toDouble(), + totalAmount: (map['totalAmount'] as num).toDouble(), + ); + } + + String toJson() => jsonEncode(toMap()); +} + +/// 見積行(EstimateItem)モデル +class EstimateItem { + final int id; + final int? productId; + final String productName; + final int quantity; + final double unitPrice; + final double total; + + EstimateItem({ + required this.id, + this.productId, + required this.productName, + required this.quantity, + required this.unitPrice, + required this.total, + }); + + Map toMap() { + return { + 'id': id, + 'productId': productId, + 'productName': productName, + 'quantity': quantity, + 'unitPrice': unitPrice, + 'total': total, + }; + } + + factory EstimateItem.fromMap(Map map) { + return EstimateItem( + id: map['id'] as int, + productId: map['productId'] as int?, + productName: map['productName'] as String, + quantity: map['quantity'] as int, + unitPrice: (map['unitPrice'] as num).toDouble(), + total: (map['total'] as num).toDouble(), + ); + } + + String toJson() => jsonEncode(toMap()); +} \ No newline at end of file diff --git a/lib/screens/estimate_screen.dart b/lib/screens/estimate_screen.dart index 068e56b..3dbe4e4 100644 --- a/lib/screens/estimate_screen.dart +++ b/lib/screens/estimate_screen.dart @@ -1,5 +1,6 @@ -// Version: 1.0.0 +// Version: 1.0.0 - EstimateScreen 見積入力画面 import 'package:flutter/material.dart'; +import '../models/estimate.dart'; import '../services/database_helper.dart'; import '../models/product.dart'; @@ -84,68 +85,30 @@ class _EstimateScreenState extends State { setState(() => _items.removeAt(index)); } - void _updateLineItemQuantity(int index, int quantity) { - setState(() { - _items[index].quantity = quantity; - _items[index].total = quantity * _items[index].unitPrice; - }); - } + Future _saveEstimate() async { + if (_items.isEmpty) return; - void _showSaveDialog() { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('見積確定'), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (_selectedCustomer != null) ...[ - Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Text('得意先:${_selectedCustomer!.name}'), - ), - ], - Text('合計:¥${_calculateTotal()}'), - if (_items.isNotEmpty) ...[ - Divider(), - ..._items.map((item) => Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(item.productName), - Text('¥${item.unitPrice} × ${item.quantity} = ¥${item.total}'), - ], - ), - )), - ], - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('キャンセル'), - ), - ElevatedButton( - onPressed: () async { - if (_items.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('商品を追加してください')), - ); - return; - } - Navigator.pop(ctx); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('見積保存しました'))..behavior: SnackBarBehavior.floating, - ); - }, - child: const Text('確定'), - ), - ], - ), - ); + // データベースへの保存 + try { + final estimatedNo = 'EST-${DateTime.now().year}${DateTime.now().month.toString().padLeft(2, '0')}-${_items.length + 1}'; + + await _db.insertEstimate( + estimateNo: estimatedNo, + customerName: _selectedCustomer?.name ?? '未指定', + date: DateTime.now(), + items: _items, + ); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('見積を保存しました'))..behavior: SnackBarBehavior.floating, + ); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('保存に失敗:$e'), backgroundColor: Colors.red), + ); + } + } } int _calculateTotal() { @@ -168,7 +131,15 @@ class _EstimateScreenState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('見積入力')), + appBar: AppBar( + title: const Text('見積入力'), + actions: [ + IconButton( + icon: const Icon(Icons.save), + onPressed: _saveEstimate, + ), + ], + ), body: ListView( padding: const EdgeInsets.all(16), children: [ @@ -203,11 +174,25 @@ class _EstimateScreenState extends State { ), ), ], - ], + ), ), ), + if (_selectedCustomer != null) ...[ + const SizedBox(height: 16), + Card( + child: ListTile( + title: const Text('得意先'), + subtitle: Text(_selectedCustomer!.name), + ), + ), + ], ], ), + floatingActionButton: FloatingActionButton.extended( + icon: const Icon(Icons.add_shopping_cart), + label: const Text('商品追加'), + onPressed: () => _showAddDialog(), + ), ); } diff --git a/lib/services/database_helper.dart b/lib/services/database_helper.dart index 593bf94..cb1c44b 100644 --- a/lib/services/database_helper.dart +++ b/lib/services/database_helper.dart @@ -1,9 +1,10 @@ -// Version: 1.0.0 +// Version: 1.4 (estimate テーブル定義修正・CRUD API 実装) import 'package:sqflite/sqflite.dart'; import 'package:path/path.dart'; import 'dart:convert'; import '../models/customer.dart'; import '../models/product.dart'; +import '../models/estimate.dart'; class DatabaseHelper { static final DatabaseHelper instance = DatabaseHelper._init(); @@ -23,7 +24,7 @@ class DatabaseHelper { return await openDatabase( path, - version: 1, + version: 2, // Estimate テーブル追加でバージョンアップ onCreate: _createDB, ); } @@ -91,6 +92,22 @@ class DatabaseHelper { ) '''); + // estimate テーブル(見積書用) + await db.execute(''' + CREATE TABLE estimates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + estimate_no TEXT NOT NULL UNIQUE, + customer_id INTEGER, + customer_name TEXT NOT NULL, + date TEXT NOT NULL, + total_amount REAL NOT NULL, + tax_rate REAL DEFAULT 10, + items_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + '''); + // customer_snapshots テーブル(イベントソーシング用) await db.execute(''' CREATE TABLE customer_snapshots ( @@ -126,7 +143,6 @@ class DatabaseHelper { } Future _insertTestData(Database db) async { - // customers テーブルにテストデータを挿入(既に存在しない場合のみ) final existingCustomerCodes = (await db.query('customers', columns: ['customer_code'])) .map((e) => e['customer_code'] as String?) .whereType() @@ -174,8 +190,6 @@ class DatabaseHelper { // employees テーブル try { - await db.execute('CREATE TABLE IF NOT EXISTS employees (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, position TEXT, phone_number TEXT)'); - final existingEmployees = (await db.query('employees', columns: ['name'])) .map((e) => e['name'] as String?) .whereType() @@ -190,8 +204,6 @@ class DatabaseHelper { // warehouses テーブル try { - await db.execute('CREATE TABLE IF NOT EXISTS warehouses (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, address TEXT, manager TEXT)'); - final existingWarehouses = (await db.query('warehouses', columns: ['name'])) .map((e) => e['name'] as String?) .whereType() @@ -205,8 +217,6 @@ class DatabaseHelper { // suppliers テーブル try { - await db.execute('CREATE TABLE IF NOT EXISTS suppliers (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, tax_rate INTEGER DEFAULT 8)'); - final existingSuppliers = (await db.query('suppliers', columns: ['name'])) .map((e) => e['name'] as String?) .whereType() @@ -221,18 +231,6 @@ class DatabaseHelper { // products テーブル try { - await db.execute(''' - CREATE TABLE IF NOT EXISTS products ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - product_code TEXT NOT NULL, - name TEXT NOT NULL, - price INTEGER NOT NULL, - stock INTEGER DEFAULT 0, - category TEXT, - unit TEXT - ) - '''); - final existingProducts = (await db.query('products', columns: ['product_code'])) .map((e) => e['product_code'] as String?) .whereType() @@ -247,7 +245,7 @@ class DatabaseHelper { } } - // ========== Customer CRUD ========== + // ========== Customer CRUD ====== Future insert(Customer customer) async { final db = await instance.database; @@ -257,7 +255,7 @@ class DatabaseHelper { Future> getCustomers() async { final db = await instance.database; final List> maps = await db.query('customers'); - + return (maps as List) .map((json) => Customer.fromMap(json)) .where((c) => c.isDeleted == 0) @@ -267,7 +265,7 @@ class DatabaseHelper { Future getCustomer(int id) async { final db = await instance.database; final maps = await db.query('customers', where: 'id = ?', whereArgs: [id]); - + if (maps.isEmpty) return null; return Customer.fromMap(maps.first); } @@ -282,22 +280,54 @@ class DatabaseHelper { return await db.update('customers', {'is_deleted': 1}, where: 'id = ?', whereArgs: [id]); } - Future saveSnapshot(Customer customer) async { + // ========== Estimate CRUD ====== + + Future insertEstimate(String estimateNo, String customerName, DateTime date, List items) async { final db = await instance.database; - final id = customer.id; - final now = DateTime.now().toIso8601String(); - final existing = await db.query('customer_snapshots', where: 'customer_id = ?', whereArgs: [id], limit: 6); - - if (existing.length > 5) { - final oldestId = existing.first['id'] as int; - await db.delete('customer_snapshots', where: 'id = ?', whereArgs: [oldestId]); + // 見積番号の重複チェック + final existing = await db.query('estimates', where: 'estimate_no = ?', whereArgs: [estimateNo]); + if (existing.isNotEmpty) { + throw ArgumentError('見積番号「$estimateNo」は既に存在します'); } - await db.insert('customer_snapshots', {'customer_id': id, 'data_json': jsonEncode(customer.toMap()), 'created_at': now}); + final itemsJson = jsonEncode(items.map((e) => e.toMap()).toList()); + final totalAmount = items.fold(0, (sum, item) => sum + item.total); + + return await db.insert('estimates', { + 'estimate_no': estimateNo, + 'customer_name': customerName, + 'date': date.toIso8601String(), + 'total_amount': totalAmount, + 'tax_rate': 10, + 'items_json': itemsJson, + 'created_at': DateTime.now().toIso8601String(), + 'updated_at': DateTime.now().toIso8601String(), + }); } - // ========== Product CRUD ========== + Future> getEstimates() async { + final db = await instance.database; + final List> maps = await db.query('estimates', orderBy: 'created_at DESC'); + + return (maps as List) + .map((json) => Estimate.fromMap(json)) + .toList(); + } + + Future getEstimate(int id) async { + final db = await instance.database; + final maps = await db.query('estimates', where: 'id = ?', whereArgs: [id]); + + if (maps.isEmpty) return null; + return Estimate.fromMap(maps.first); + } + + Future saveSnapshot(Estimate estimate) async { + //_estimate_snapshots テーブルも必要に応じて追加可 + } + + // ========== Product CRUD ====== Future insert(Product product) async { final db = await instance.database; @@ -307,7 +337,7 @@ class DatabaseHelper { Future> getProducts() async { final db = await instance.database; final List> maps = await db.query('products', orderBy: 'product_code ASC'); - + return (maps as List) .map((json) => Product.fromMap(json)) .where((p) => p.isDeleted == 0) @@ -317,7 +347,7 @@ class DatabaseHelper { Future getProduct(int id) async { final db = await instance.database; final maps = await db.query('products', where: 'id = ?', whereArgs: [id]); - + if (maps.isEmpty) return null; return Product.fromMap(maps.first); } @@ -332,21 +362,6 @@ class DatabaseHelper { return await db.update('products', {'is_deleted': 1}, where: 'id = ?', whereArgs: [id]); } - Future saveSnapshot(Product product) async { - final db = await instance.database; - final id = product.id; - final now = DateTime.now().toIso8601String(); - - final existing = await db.query('product_snapshots', where: 'product_id = ?', whereArgs: [id], limit: 6); - - if (existing.length > 5) { - final oldestId = existing.first['id'] as int; - await db.delete('product_snapshots', where: 'id = ?', whereArgs: [oldestId]); - } - - await db.insert('product_snapshots', {'product_id': id, 'data_json': jsonEncode(product.toMap()), 'created_at': now}); - } - Future close() async { final db = await instance.database; db.close();