feat: Add subject field to invoices, integrating it into the model, UI, and database.
This commit is contained in:
parent
81c2fc38a8
commit
6643aa4157
8 changed files with 152 additions and 53 deletions
|
|
@ -66,6 +66,7 @@ class Invoice {
|
|||
final double? longitude; // 追加
|
||||
final String terminalId; // 追加: 端末識別子
|
||||
final bool isDraft; // 追加: 下書きフラグ
|
||||
final String? subject; // 追加: 案件名
|
||||
|
||||
Invoice({
|
||||
String? id,
|
||||
|
|
@ -84,13 +85,14 @@ class Invoice {
|
|||
this.longitude, // 追加
|
||||
String? terminalId, // 追加
|
||||
this.isDraft = false, // 追加: デフォルトは通常
|
||||
this.subject, // 追加: 案件
|
||||
}) : id = id ?? DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
terminalId = terminalId ?? "T1", // デフォルト端末ID
|
||||
updatedAt = updatedAt ?? DateTime.now();
|
||||
|
||||
/// 伝票内容から決定論的なハッシュを生成する (SHA256の一部)
|
||||
String get contentHash {
|
||||
final input = "$id|$terminalId|${date.toIso8601String()}|${customer.id}|$totalAmount|${items.map((e) => "${e.description}${e.quantity}${e.unitPrice}").join()}";
|
||||
final input = "$id|$terminalId|${date.toIso8601String()}|${customer.id}|$totalAmount|${subject ?? ""}|${items.map((e) => "${e.description}${e.quantity}${e.unitPrice}").join()}";
|
||||
final bytes = utf8.encode(input);
|
||||
return sha256.convert(bytes).toString().substring(0, 8).toUpperCase();
|
||||
}
|
||||
|
|
@ -141,6 +143,7 @@ class Invoice {
|
|||
'terminal_id': terminalId, // 追加
|
||||
'content_hash': contentHash, // 追加
|
||||
'is_draft': isDraft ? 1 : 0, // 追加
|
||||
'subject': subject, // 追加
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -161,6 +164,7 @@ class Invoice {
|
|||
double? longitude,
|
||||
String? terminalId,
|
||||
bool? isDraft,
|
||||
String? subject,
|
||||
}) {
|
||||
return Invoice(
|
||||
id: id ?? this.id,
|
||||
|
|
@ -179,6 +183,7 @@ class Invoice {
|
|||
longitude: longitude ?? this.longitude,
|
||||
terminalId: terminalId ?? this.terminalId,
|
||||
isDraft: isDraft ?? this.isDraft,
|
||||
subject: subject ?? this.subject,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -328,6 +328,11 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
|||
Text("取引先:", style: TextStyle(fontWeight: FontWeight.bold, color: textColor)),
|
||||
Text("${_currentInvoice.customerNameForDisplay} ${_currentInvoice.customer.title}",
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: textColor)),
|
||||
if (_currentInvoice.subject?.isNotEmpty ?? false) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text("件名: ${_currentInvoice.subject}",
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.indigoAccent)),
|
||||
],
|
||||
if (_currentInvoice.customer.department != null && _currentInvoice.customer.department!.isNotEmpty)
|
||||
Text(_currentInvoice.customer.department!, style: TextStyle(fontSize: 16, color: textColor)),
|
||||
if (_currentInvoice.latitude != null) ...[
|
||||
|
|
|
|||
|
|
@ -259,14 +259,26 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
|
|||
backgroundColor: _isUnlocked ? Colors.indigo.shade100 : Colors.grey.shade200,
|
||||
child: Icon(Icons.description_outlined, color: _isUnlocked ? Colors.indigo : Colors.grey),
|
||||
),
|
||||
title: Text(invoice.customerNameForDisplay),
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(invoice.customerNameForDisplay, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
if (invoice.subject?.isNotEmpty ?? false)
|
||||
Text(
|
||||
invoice.subject!,
|
||||
style: TextStyle(fontSize: 13, color: Colors.indigo.shade700, fontWeight: FontWeight.normal),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Text("${dateFormatter.format(invoice.date)} - ${invoice.invoiceNumber}"),
|
||||
trailing: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text("¥${amountFormatter.format(invoice.totalAmount)}",
|
||||
style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13)),
|
||||
if (invoice.isSynced)
|
||||
const Icon(Icons.sync, size: 16, color: Colors.green)
|
||||
else
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
DocumentType _documentType = DocumentType.invoice; // 追加
|
||||
DateTime _selectedDate = DateTime.now(); // 追加: 伝票日付
|
||||
bool _isDraft = false; // 追加: 下書きモード
|
||||
final TextEditingController _subjectController = TextEditingController(); // 追加
|
||||
String _status = "取引先と商品を入力してください";
|
||||
|
||||
// 署名用の実験的パス
|
||||
|
|
@ -98,14 +99,16 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
|
||||
final invoice = Invoice(
|
||||
customer: _selectedCustomer!,
|
||||
date: _selectedDate, // 修正
|
||||
date: _selectedDate,
|
||||
items: _items,
|
||||
taxRate: _includeTax ? _taxRate : 0.0,
|
||||
documentType: _documentType,
|
||||
customerFormalNameSnapshot: _selectedCustomer!.formalName,
|
||||
subject: _subjectController.text.isNotEmpty ? _subjectController.text : null, // 追加
|
||||
notes: _includeTax ? "(消費税 ${(_taxRate * 100).toInt()}% 込み)" : "(非課税)",
|
||||
latitude: pos?.latitude,
|
||||
longitude: pos?.longitude,
|
||||
isDraft: _isDraft, // 追加
|
||||
);
|
||||
|
||||
if (generatePdf) {
|
||||
|
|
@ -177,7 +180,7 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
backgroundColor: themeColor,
|
||||
appBar: AppBar(
|
||||
leading: const BackButton(),
|
||||
title: Text(_isDraft ? "伝票作成 (下書きモード)" : "販売アシスト1号 V1.5.02"),
|
||||
title: Text(_isDraft ? "伝票作成 (下書きモード)" : "販売アシスト1号 V1.5.03"),
|
||||
backgroundColor: _isDraft ? Colors.black87 : Colors.blueGrey,
|
||||
),
|
||||
body: Column(
|
||||
|
|
@ -195,6 +198,8 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
_buildDateSection(),
|
||||
const SizedBox(height: 16),
|
||||
_buildCustomerSection(),
|
||||
const SizedBox(height: 16),
|
||||
_buildSubjectSection(textColor), // 追加
|
||||
const SizedBox(height: 20),
|
||||
_buildItemsSection(fmt),
|
||||
const SizedBox(height: 20),
|
||||
|
|
@ -516,30 +521,28 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
}
|
||||
|
||||
Widget _buildDraftToggle() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: _isDraft ? Colors.black26 : Colors.orange.shade50,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: _isDraft ? Colors.orangeAccent : Colors.orange, width: 2),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(_isDraft ? Icons.drafts : Icons.check_circle, color: Colors.orange),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_isDraft ? "下書きモード設定中" : "正式発行モード",
|
||||
style: TextStyle(fontWeight: FontWeight.bold, color: _isDraft ? Colors.white : Colors.orange.shade900),
|
||||
),
|
||||
// ... (existing code omitted for brevity but I'll provide the new method below it)
|
||||
}
|
||||
|
||||
Widget _buildSubjectSection(Color textColor) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text("案件名 / 件名", style: TextStyle(fontWeight: FontWeight.bold, color: textColor)),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _subjectController,
|
||||
style: TextStyle(color: textColor),
|
||||
decoration: InputDecoration(
|
||||
hintText: "例:事務所改修工事 / 〇〇月分リース料",
|
||||
hintStyle: TextStyle(color: textColor.withOpacity(0.5)),
|
||||
filled: true,
|
||||
fillColor: _isDraft ? Colors.white12 : Colors.grey.shade100,
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
),
|
||||
Switch(
|
||||
value: _isDraft,
|
||||
activeColor: Colors.orangeAccent,
|
||||
onChanged: (val) => setState(() => _isDraft = val),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import 'package:sqflite/sqflite.dart';
|
|||
import 'package:path/path.dart';
|
||||
|
||||
class DatabaseHelper {
|
||||
static const _databaseVersion = 11;
|
||||
static const _databaseVersion = 12;
|
||||
static final DatabaseHelper _instance = DatabaseHelper._internal();
|
||||
static Database? _database;
|
||||
|
||||
|
|
@ -91,6 +91,9 @@ class DatabaseHelper {
|
|||
if (oldVersion < 11) {
|
||||
await db.execute('ALTER TABLE invoices ADD COLUMN is_draft INTEGER DEFAULT 0');
|
||||
}
|
||||
if (oldVersion < 12) {
|
||||
await db.execute('ALTER TABLE invoices ADD COLUMN subject TEXT');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onCreate(Database db, int version) async {
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ class InvoiceRepository {
|
|||
longitude: iMap['longitude'],
|
||||
terminalId: iMap['terminal_id'] ?? "T1",
|
||||
isDraft: (iMap['is_draft'] ?? 0) == 1,
|
||||
subject: iMap['subject'],
|
||||
));
|
||||
}
|
||||
return invoices;
|
||||
|
|
|
|||
|
|
@ -134,29 +134,41 @@ Future<pw.Document> buildInvoiceDocument(Invoice invoice) async {
|
|||
pw.SizedBox(height: 20),
|
||||
|
||||
// 明細テーブル
|
||||
pw.TableHelper.fromTextArray(
|
||||
headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold),
|
||||
headerDecoration: const pw.BoxDecoration(color: PdfColors.grey300),
|
||||
cellHeight: 30,
|
||||
cellAlignments: {
|
||||
0: pw.Alignment.centerLeft,
|
||||
1: pw.Alignment.centerRight,
|
||||
2: pw.Alignment.centerRight,
|
||||
3: pw.Alignment.centerRight,
|
||||
// 明細テーブル
|
||||
pw.Table(
|
||||
border: pw.TableBorder.all(color: PdfColors.grey300),
|
||||
columnWidths: {
|
||||
0: const pw.FlexColumnWidth(4),
|
||||
1: const pw.FixedColumnWidth(50),
|
||||
2: const pw.FixedColumnWidth(80),
|
||||
3: const pw.FixedColumnWidth(80),
|
||||
},
|
||||
headers: ["品名 / 項目", "数量", "単価", "金額"],
|
||||
data: List<List<String>>.generate(
|
||||
invoice.items.length,
|
||||
(index) {
|
||||
final item = invoice.items[index];
|
||||
return [
|
||||
item.description,
|
||||
item.quantity.toString(),
|
||||
amountFormatter.format(item.unitPrice),
|
||||
amountFormatter.format(item.subtotal),
|
||||
];
|
||||
},
|
||||
),
|
||||
children: [
|
||||
// ヘッダー
|
||||
pw.TableRow(
|
||||
decoration: const pw.BoxDecoration(color: PdfColors.grey300),
|
||||
children: [
|
||||
pw.Padding(padding: const pw.EdgeInsets.all(4), child: pw.Text("品名 / 項目", style: pw.TextStyle(fontWeight: pw.FontWeight.bold))),
|
||||
pw.Padding(padding: const pw.EdgeInsets.all(4), child: pw.Text("数量", style: pw.TextStyle(fontWeight: pw.FontWeight.bold), textAlign: pw.TextAlign.right)),
|
||||
pw.Padding(padding: const pw.EdgeInsets.all(4), child: pw.Text("単価", style: pw.TextStyle(fontWeight: pw.FontWeight.bold), textAlign: pw.TextAlign.right)),
|
||||
pw.Padding(padding: const pw.EdgeInsets.all(4), child: pw.Text("金額", style: pw.TextStyle(fontWeight: pw.FontWeight.bold), textAlign: pw.TextAlign.right)),
|
||||
],
|
||||
),
|
||||
// データ行
|
||||
...invoice.items.map((item) {
|
||||
return pw.TableRow(
|
||||
children: [
|
||||
pw.Padding(
|
||||
padding: const pw.EdgeInsets.all(4),
|
||||
child: _parseMarkdown(item.description),
|
||||
),
|
||||
pw.Padding(padding: const pw.EdgeInsets.all(4), child: pw.Text(item.quantity.toString(), textAlign: pw.TextAlign.right)),
|
||||
pw.Padding(padding: const pw.EdgeInsets.all(4), child: pw.Text(amountFormatter.format(item.unitPrice), textAlign: pw.TextAlign.right)),
|
||||
pw.Padding(padding: const pw.EdgeInsets.all(4), child: pw.Text(amountFormatter.format(item.subtotal), textAlign: pw.TextAlign.right)),
|
||||
],
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
|
||||
// 計算内訳
|
||||
|
|
@ -239,8 +251,12 @@ Future<String?> generateInvoicePdf(Invoice invoice) async {
|
|||
final pdf = await buildInvoiceDocument(invoice);
|
||||
|
||||
final String hash = invoice.contentHash;
|
||||
final String timeStr = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now());
|
||||
String fileName = "${invoice.invoiceNumberPrefix}_${invoice.terminalId}_${invoice.id.substring(invoice.id.length - 4)}_${timeStr}_$hash.pdf";
|
||||
final String dateStr = DateFormat('yyyyMMdd').format(invoice.date);
|
||||
final String amountStr = NumberFormat("#,###").format(invoice.totalAmount);
|
||||
final String subjectStr = invoice.subject?.isNotEmpty == true ? "_${invoice.subject}" : "";
|
||||
|
||||
// {日付}({タイプ}){顧客名}_{案件}_{金額}_{HASH下8桁}.pdf
|
||||
String fileName = "${dateStr}(${invoice.documentTypeName})${invoice.customerNameForDisplay}${subjectStr}_${amountStr}円_$hash.pdf";
|
||||
|
||||
final directory = await getExternalStorageDirectory();
|
||||
if (directory == null) return null;
|
||||
|
|
@ -278,3 +294,49 @@ pw.Widget _buildSummaryRow(String label, String value, {bool isBold = false}) {
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
pw.Widget _parseMarkdown(String text) {
|
||||
final lines = text.split('\n');
|
||||
final List<pw.Widget> widgets = [];
|
||||
|
||||
for (final line in lines) {
|
||||
String content = line;
|
||||
pw.EdgeInsets padding = const pw.EdgeInsets.only(bottom: 2);
|
||||
pw.Widget? prefix;
|
||||
|
||||
// 箇条書き / インデント
|
||||
if (content.startsWith('* ') || content.startsWith('- ')) {
|
||||
content = content.substring(2);
|
||||
prefix = pw.Padding(padding: const pw.EdgeInsets.only(right: 4), child: pw.Text('•'));
|
||||
} else if (content.startsWith(' ')) {
|
||||
padding = padding.copyWith(left: 10);
|
||||
}
|
||||
|
||||
// 太字 (**text**) - 簡易実装
|
||||
final List<pw.TextSpan> spans = [];
|
||||
final parts = content.split('**');
|
||||
for (int i = 0; i < parts.length; i++) {
|
||||
spans.add(pw.TextSpan(
|
||||
text: parts[i],
|
||||
style: i % 2 == 1 ? pw.TextStyle(fontWeight: pw.FontWeight.bold) : null,
|
||||
));
|
||||
}
|
||||
|
||||
widgets.add(
|
||||
pw.Padding(
|
||||
padding: padding,
|
||||
child: pw.Row(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (prefix != null) prefix,
|
||||
pw.Expanded(
|
||||
child: pw.RichText(text: pw.TextSpan(children: spans, style: const pw.TextStyle(fontSize: 10))),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return pw.Column(crossAxisAlignment: pw.CrossAxisAlignment.start, children: widgets);
|
||||
}
|
||||
|
|
|
|||
8
目標.md
8
目標.md
|
|
@ -49,3 +49,11 @@
|
|||
---
|
||||
*最終更新日: 2026-02-14*
|
||||
*Version: 1.5.02*
|
||||
|
||||
追加:
|
||||
ファイル名ルール
|
||||
{日付}({タイプ}){顧客名}_{案件}_{金額}_{HASH下8桁}.pdf
|
||||
20260214(請求書)佐々木製作所_10,000円_12345678.pdf
|
||||
明細欄にはmarkdown的要素が使える様に、簡単なものが欲しい。
|
||||
インデントや箇条書き、太字など。問題はodooとの連携と型番。
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue