Vibe coding全紀錄-3

 A:

 好的,那我們就開始來建立資料庫層,首先是記帳的部分,記帳的部分,在支出的部分要有:日期[年/月/日],主類別,子類別,金額,備註。在收入的部分要有:日期[年/月/日],主類別,子類別,金額,備註。金額的部分要有千位分隔符,主類別的名稱不能重複,子類別的名稱也不能重複,但是不同主類別的子類別的名稱可以重複。另外記帳的功能除了單筆的記帳,也要有定期固定收支的功能,除了單筆記帳的項目之外,還要加上一個定期固定收支頻率的設定,例如:每月5日,每周星期一,每年的3月3日。謝謝 

Q:

好的,收到!這些具體的規則非常重要,能幫助我們設計出更貼近你需求的資料庫結構。我們一步一步來分析並建構它。

首先,我要先說明一個非常重要的觀念:資料儲存 vs. 資料呈現

你提到的「金額要有千位分隔符」,這是一個 UI 呈現 的需求。在資料庫中,我們必須將金額儲存為純粹的數字格式(例如 DoubleBigDecimal),這樣才能進行加總、平均等數學運算。如果我們存成 "1,000" 這樣的文字,就無法計算了。

請放心,在 UI 層(Jetpack Compose),我們可以輕易地將數字格式化成你想要的樣子再顯示給使用者。所以,資料庫負責儲存原始、乾淨的數字,UI 負責美化它。

現在,讓我們根據你的新需求,重新設計我們的資料模型 (Entities)。


第三步:精煉與建構資料庫層 (記帳核心)

為了滿足主、子類別的唯一性規則以及它們之間的關聯,我們需要將「分類」拆分成獨立的資料表。這是一個更正規、更具擴充性的設計。

1. 資料模型 (Entity) 設計

我們需要以下四個資料表:

  1. MainCategory (主類別表): 儲存主類別,並確保名稱不重複。

  2. SubCategory (子類別表): 儲存子類別,並連結到一個主類別。

  3. Transaction (交易紀錄表): 儲存每一筆單次交易。

  4. RecurringTransaction (定期交易表): 儲存週期性的收支規則。

A. MainCategory (主類別)

Kotlin
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey

// @Entity 定義這是一個資料表
// `indices` 內的 @Index 設定 `name` 欄位為唯一,確保主類別名稱不重複
@Entity(tableName = "main_categories",
        indices = [Index(value = ["name"], unique = true)])
data class MainCategory(
    @PrimaryKey(autoGenerate = true)
    val id: Long = 0, // 唯一 ID,自動產生

    val name: String, // 主類別名稱,例如:「餐飲」、「交通」、「薪水」
    
    val type: TransactionType // 這個類別是屬於「收入」還是「支出」
)

B. SubCategory (子類別)

這裡我們會用 ForeignKey (外鍵) 來確保每個子類別都一定會關聯到一個存在的主類別。

Kotlin
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey

// `foreignKeys` 設定與主類別的關聯
// `indices` 設定在同一個 mainCategoryId 下,子類別的 name 必須是唯一的
@Entity(tableName = "sub_categories",
        foreignKeys = [ForeignKey(
            entity = MainCategory::class,
            parentColumns = ["id"],
            childColumns = ["mainCategoryId"],
            onDelete = ForeignKey.CASCADE // 當主類別被刪除時,其下的子類別也一併刪除
        )],
        indices = [Index(value = ["mainCategoryId", "name"], unique = true)])
data class SubCategory(
    @PrimaryKey(autoGenerate = true)
    val id: Long = 0, // 唯一 ID

    val mainCategoryId: Long, // 關聯到的主類別 ID

    val name: String // 子類別名稱,例如:「早餐」、「捷運」、「正職」
)

C. Transaction (交易紀錄)

現在交易紀錄不再直接儲存分類名稱,而是儲存分類的 ID。

Kotlin
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey

@Entity(tableName = "transactions",
        foreignKeys = [
            ForeignKey(
                entity = Account::class,
                parentColumns = ["id"],
                childColumns = ["accountId"],
                onDelete = ForeignKey.RESTRICT // 如果帳戶下有交易,限制該帳戶不能被輕易刪除
            ),
            ForeignKey(
                entity = SubCategory::class,
                parentColumns = ["id"],
                childColumns = ["subCategoryId"],
                onDelete = ForeignKey.RESTRICT // 同理,限制分類不能被輕易刪除
            )
        ])
data class Transaction(
    @PrimaryKey(autoGenerate = true)
    val id: Long = 0,

    val accountId: Long, // 關聯的帳戶 ID

    val subCategoryId: Long, // 關聯的子類別 ID

    val amount: Double, // 金額 (儲存純數字)

    val type: TransactionType, // 收入 或 支出

    val transactionDate: Long, // 交易日期 (儲存為 Unix 時間戳)

    val note: String? // 備註 (可為空)
)

D. RecurringTransaction (定期交易)

這是一個新的資料表,用來儲存「規則」。

Kotlin
import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "recurring_transactions")
data class RecurringTransaction(
    @PrimaryKey(autoGenerate = true)
    val id: Long = 0,

    // --- 交易基本資訊 ---
    val accountId: Long,
    val subCategoryId: Long,
    val amount: Double,
    val type: TransactionType,
    val note: String?,
    // --- 週期性規則 ---
    val frequency: FrequencyType, // 頻率類型:每週、每月、每年
    
    val dayOfWeek: Int?, // 如果是每週,這裡是星期幾 (1=週日, 2=週一...)
    
    val dayOfMonth: Int?, // 如果是每月,這裡是幾號
    
    val monthOfYear: Int?, // 如果是每年,這裡是幾月
    val dayOfYear: Int?,    // 如果是每年,這裡是幾號
    
    val startDate: Long, // 此規則的開始日期
    val endDate: Long?   // 此規則的結束日期 (可選)
)

enum class FrequencyType {
    WEEKLY,
    MONTHLY,
    YEARLY
}

2. DAO (Data Access Object) 設計

DAO 是我們操作這些資料表的介面。

CategoryDao.kt

Kotlin
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import kotlinx.coroutines.flow.Flow

@Dao
interface CategoryDao {
    @Insert
    suspend fun insertMainCategory(category: MainCategory)

    @Insert
    suspend fun insertSubCategory(category: SubCategory)

    // 取得所有支出類型的主類別
    @Query("SELECT * FROM main_categories WHERE type = 'EXPENSE'")
    fun getAllExpenseMainCategories(): Flow<List<MainCategory>>
    
    // 根據主類別ID,取得其下所有的子類別
    @Query("SELECT * FROM sub_categories WHERE mainCategoryId = :mainCategoryId")
    fun getSubCategoriesForMain(mainCategoryId: Long): Flow<List<SubCategory>>
}

TransactionDao.kt

Kotlin
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import kotlinx.coroutines.flow.Flow

@Dao
interface TransactionDao {
    @Insert
    suspend fun insert(transaction: Transaction)

    // 取得所有交易紀錄,並按日期排序
    @Query("SELECT * FROM transactions ORDER BY transactionDate DESC")
    fun getAllTransactions(): Flow<List<Transaction>>
}

RecurringTransactionDao.kt

Kotlin
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import kotlinx.coroutines.flow.Flow

@Dao
interface RecurringTransactionDao {
    @Insert
    suspend fun insert(recurringTransaction: RecurringTransaction)

    // 取得所有定期交易規則
    @Query("SELECT * FROM recurring_transactions")
    fun getAllRecurringTransactions(): Flow<List<RecurringTransaction>>
}

3. 定期記帳的執行邏輯

這裡要特別說明,資料庫只負責「儲存規則」。要讓 App 自動記帳,我們還需要一個「執行者」。

通常我們會使用 Android Jetpack 的 WorkManager 來建立一個背景任務。這個任務會每天執行一次,檢查 RecurringTransaction 表中的所有規則,看看今天是否滿足觸發條件(例如,今天是每月5日,或者今天是星期一)。如果滿足,它就會讀取該規則的資訊,建立一筆新的 Transaction 記錄,並將其寫入 transactions 資料表。


總結與下一步

我們已經成功地:

  1. 定義了更精細的資料庫結構,包含了主子類別、單筆交易和定期交易四個核心資料表。

  2. 確保了資料的完整性與唯一性,例如主類別名稱不能重複。

  3. 規劃了 DAO 介面,作為未來存取資料的入口。

  4. 釐清了定期記帳的實作思路(資料庫存規則 + WorkManager 執行)。

這個資料庫層的設計非常穩固,足以支撐我們後續所有功能的開發。

我們的下一步,就是將這些 EntityDAO 組合起來,建立最終的 AppDatabase 類別。

這個步驟相對簡單,主要是用 Room 的註解把所有東西宣告在一起。你準備好繼續了嗎?