Memakai Temporal Pattern Di Aplikasi Inventory

Pada artikel ini, saya melanjutkan pembahasan tentang kode program inventory yang ada di Belajar Menerapkan Domain Driven Design Pada Aplikasi Inventory. Salah satu kebutuhan proyek tersebut adalah pengguna harus bisa mengetahui riwayat perubahan stok untuk masing-masing produk yang ada (misalnya untuk keperluan kartu stok). Untuk itu, saya mengimplementasikan pola Temporal Pattern yang ada di http://martinfowler.com/eaaDev/timeNarrative.html. Pola ini dapat dipakai untuk rancangan yang berusaha memberikan informasi pada periode di masa lalu.

Langkah pertama yang saya lakukan adalah mendefinisikan sebuah class untuk mewakili periode. Untuk itu, saya akan menggunakan pola Range (http://martinfowler.com/eaaDev/Range.html). Saya menambahkan sedikit method sesuai kebutuhan saya, sehingga rancangan class Periode menjadi seperti berikut ini:

Class `Periode`

Class `Periode`

Berikutnya, saya akan mendefinisikan Temporal Pattern dalam bentuk abstract class sehingga bisa di-reuse nantinya. Hasilnya akan terlihat seperti pada gambar berikut ini:

Temporal patterns dalam bentuk abstract class

Temporal patterns dalam bentuk abstract class

Stok akan berkurang bila bagian gudang mengambil barang berdasarkan informasi dari penjualan (begitu juga sebaliknya). Bukan hanya itu, transaksi seperti retur, penyesuaian, mutasi, dan penukaran poin dengan barang juga bisa menyebabkan perubahan stok di gudang. Tentu saja, bila faktur dihapus, riwayat perubahan juga harus berubah. Melakukan pen-query-an untuk semua riwayat ini bisa membingungkan dan tidak pasti tergantung pada perubahan di masa depan! Oleh sebab itu, saya memutuskan untuk menerapkan pola Audit Log. Setiap perubahan stok akan menciptakan sebuah object ItemPeriodik baru. Walaupun pola Audit Log adalah yang paling mudah diterapkan, ia lebih susah dicari dan bisa mempengaruhi ukuran database secara signifikan (hal ini bisa diatasi dengan polyglot database dimana Audit Log disimpan pada database terpisah seperti MongoDB).

Pemisahan antara NilaiPeriodik dan ItemPeriodik dilakukan untuk mendukung lazy loading di Hibernate JPA. Pengguna umumnya tidak tertarik untuk melihat seluruh riwayat yang ada melainkan hanya periode tertentu saja. Dengan demikian, men-query seluruh ItemPeriodik sekaligus bukan saja membebani database tetapi juga tidak dibutuhkan. Pada rancangan ini, saya menganggap NilaiPeriodik berada dalam periode bulanan.

AggregatePeriodik menyediakanTemporal Property berupa saldoKumulatifSebelum() untuk mendapatkan jumlah (saldo) sebuah produk pada tanggal tertentu. Jumlah terakhir yang paling aktual sampai hari ini selalu tersedia sebagai property jumlah.

Agar sederhana, saya menerapkan Audit Log dengan rekaman yang hanya bersifat additive (penambahan). Penambahan ItemPeriodik (riwayat perubahan stok) selalu dilakukan pada akhir kartu stok. Pengguna boleh saja memproses transaksi untuk faktur di masa lampau, tetapi riwayat perubahannya akan selalu ditambahkan pada akhir kartu stok. Bila pengguna melakukan operasi yang mengurangi jumlah stok, maka sebuah ItemPeriodik dengan nilai negatif akan ditambahkan pada akhir katu stok. Seperti yang dituliskan oleh Fowler, mengizinkan hanya operasi additive membuat operasi menjadi sangat sederhana dan banyak perubahan di kasus nyata memang memiliki sifat additive.

Sekarang, saya siap untuk mengimplementasikan pola dalam bentuk abstract class tersebut ke dalam sebuah class yang konkrit, misalnya:

Penerapan pada stok produk

Penerapan pada stok produk

Pada rancangan di atas, StokProduk adalah implementasi dari AggregatePeriodik, PeriodeItemStok adalah implementasi dari NilaiPeriodik dan ItemStok adalah implementasi dari ItemPeriodik.

ItemStok memiliki referensi ke faktur yang berkaitan dengannya. Sebagai informasi, tanggal pada ItemStok selalu merujuk pada tanggal saat ItemStok tersebut dibuat, bukan tanggal yang berlaku di faktur. Sebagai contoh, anggap saja saya memiliki data seperti berikut ini:

tanggal        tanggal faktur      perubahan  saldo  keterangan
---------------------------------------------------------------------------------
10/01/2015     10/01/2015          100        100    Bertambah
20/01/2015     20/01/2015          -10         90    Berkurang
01/02/2015     20/01/2015           10        100    Hapus faktur tanggal 20/01

Bila saya memanggil stokProduk.saldoKumulatifSebelum(LocalDate.parse('2015-01-31')) pada bulan Januari, saya akan memperoleh nilai 90. Hal ini karena penghapusan faktur baru dilakukan pada bulan Februari. Bila saya memanggil method yang sama pada bulan Februari, saya akan akan memperoleh nilai 100 karena penghapusan faktur sudah dilakukan. Martin Fowler menyebut ini sebagai Dimensions of Time. Hal seperti ini penting untuk kebutuhan seperti laporan pajak dimana pengguna harus mengetahui sebuah nilai persis pada tanggal saat laporan lama dicetak namun sebelum perubahan di masa depan dilakukan.

Pola Temporal Pattern yang telah saya pakai disini tidak hanya bisa diterapkan untuk stok produk. Saya juga bisa menerapkannya pada entity lain, misalnya untuk mengisi kas. Untuk memakainya pada kas, saya hanya perlu menurunkan entity yang berkaitan dengan kas pada abstract class yang sudah saya buat sebelumnya, misalnya:

Penerapan pada kas

Penerapan pada kas

Pada rancangan di atas, Kas adalah sebuah AggregatePeriodik sehingga secara otomatis ia juga memiliki Temporal Property seperti saldoKumulatifSebelum() untuk mencari saldo kas pada posisi tanggal tertentu. Contoh ini juga memperlihatkan contoh penggunaan abstract class yang mempermudah penerapan design pattern.

Pada AggregatePeriodik, saya menambahkan method arsip() untuk menghapus daftar ItemPeriodik yang ada. Operasi ini tidak akan mempengaruhi jumlah terakhir karena mereka disimpan sebagai property di AggregatePeriodik. Mengapa arsip() perlu dilakukan? Hal ini karena semakin banyak transaksi yang ada maka jumlah ItemPeriodik akan semakin membengkak sehingga ruang kosong harddisk akan cepat habis. arsip() hanya akan menghapus ItemPeriodik tetapi tidak akan pernah menghapus NilaiPeriodik. Dengan asumsi program dipakai selama 100 tahun, maka NilaiPeriodik yang dibuat hanya berjumlah 12 * 100 = 120 record untuk masing-masing produk. Jumlah ini relatif sedikit dan aman untuk dipertahankan. Selain itu, karena NilaiPeriodik mengandung informasi jumlah dan saldo, pengguna tetap bisa melihat summary per bulan bahkan setelah arsip() dikerjakan.

Iklan

Merancang Satuan Dan Harga

Seorang teman beberapa kali ini menghubungi saya untuk berdiskusi mengenai masalah yang dihadapinya. Ia sedang merancang sistem retail dimana ia harus mengelola produk yang bisa memiliki harga yang berbeda di satuan yang berbeda. Apa yang harus saya lakukan? Tidak ada jawaban ‘terbaik’ karena seberapa efektif sebuah rancangan tidak hanya mengenai seberapa jelas dan mudah dipahami, tetapi juga kebutuhan bisnis, teknologi dan pengalaman programmer yang dimiliki. Seperti biasa, saya akan mencoba membuat rancangan yang diimplementasikan dengan Groovy, Griffon dan simple-jpa.

Saya akan mulai dengan merancangan satuan. Sebuah nilai dalam satuan tertentu harus bisa di-konversi ke nilai dalam satuan lainnya. Sebagai contoh, 1 m adalah 100 cm, 1.000 mm, dan 39,3701 inch. Contoh lainnya, 1 lusin terdiri atas 12 unit dan 1 gross terdiri atas 144 unit. Tidak semua satuan bisa saling dikonversi. Sebagai contoh, satuan seperti meter tidak boleh dikonversi menjadi lusin.

Rancangan awal saya yang saya buat terlihat seperti pada gambar berikut ini:

Kuantitas, Satuan dan Konversi

Kuantitas, Satuan dan Konversi

Pada rancangan di atas, saya berusaha memisahkan tanggung jawab dan peran sebisa mungkin menjadi sebuah class tersendiri. Tujuannya adalah agar suatu saat nanti setelah class semakin berkembang, tidak ada class ‘gendut’ yang sulit dimodifikasi. Yang dimaksud ‘sulit’ disini adalah penuh kekhawatiran akan dampak buruk bila di-‘senggol’ sedikit. Secara umum, rancangan yang baik menghasilkan class yang bebannya ditanggung secara bersama dan merata.

Selain itu, saya tidak lupa menambahkan perilaku (behavior /method). Sebuah rancangan yang hanya berisi class beserta attribut tanpa method adalah gejala rancangan yang tidak baik yang disebut sebagai anemic domain model. Mengapa anemic domain model adalah gejala rancangan yang buruk? Sebuah aplikasi PASTI memiliki operasi. Bila rancangan hanya berisi data dalam bentuk atribut, maka dimana letaknya operasi tersebut? Kenapa operasi tersebut tidak ada dalam diagram? Dimana letaknya kode program seperti if, for, while yang mengolah data? Mereka tentunya harus punya ‘tempat’ di class yang sedang dirancang.

Salah satu prinsip yang dapat dipakai untuk mengukur loose coupling dari rancangan adalah Law of Demeter (LoD). Pada LoD, sebuah class hanya boleh berkomunikasi dengan class yang berhubungan langsung dengannya. Sebagai contoh, class Kuantitas akan memanggil method milik class Satuan, tapi tidak boleh mengakses Konversi milik Satuan tersebut secara langsung. Oleh sebab itu, class Satuan menyediakan wrapper yang mewakili nilai milik Konversi. Dengan demikian, bila suatu saat nanti class Konversi perlu berubah drastis, maka dampak terbesarnya hanya pada class Satuan karena class lain tidak mengakses Konversi secara langsung.

Seperti apa contoh implementasinya di Groovy? Class SatuanTidakCocok adalah sebuah Exception yang mewakili kesalahan sehingga saya bisa membuatnya seperti berikut ini:

import ...

@Canonical
class SatuanTidakCocok extends RuntimeException {

    Satuan dari
    Satuan ke

    @Override
    String getMessage() {
        "Tidak dapat melakukan konversi dari $dari ke $ke"
    }
}

Class Konversi adalah sebuah value object sehingga pada JPA, ia perlu memakai annotation @Embeddable seperti yang terlihat berikut ini:

import ...

@Embeddable @Canonical
class Konversi {

    @NotNull
    BigDecimal faktorSatuanBaku

    @ManyToOne
    Satuan satuanBaku

}

Class Satuan adalah sebuan entity yang implementasinya bisa berupa:

import ...

@DomainClass @Entity @Canonical
class Satuan {

    @NotEmpty
    String nama

    @Embedded
    Konversi konversi

    BigDecimal faktor(Satuan satuanLain) {
        if (satuanLain.equals(this)) {
            return 1
        } else if (satuanLain.equals(satuanBaku())) {
            return konversi.faktorSatuanBaku
        } else if (satuanLain.satuanBaku().equals(satuanBaku())) {
            return faktor(satuanBaku()) / satuanLain.faktor(satuanBaku())
        } else {
            throw new SatuanTidakCocok(satuanLain, this)
        }
    }

    Satuan satuanBaku() {
        konversi? konversi.satuanBaku: this
    }

    boolean equals(o) {
        if (this.is(o)) return true
        if (getClass() != o.class) return false

        Satuan satuan = (Satuan) o

        if (id != satuan.id) return false
        if (nama != satuan.nama) return false

        return true
    }

    int hashCode() {
        return nama.hashCode() + id
    }

}

Karena Satuan memiliki operasi yang cukup rumit, maka saya akan membuat sebuah unit test untuk menguji class tersebut, misalnya:

import ...

class SatuanTests {

    @Test
    void testSatuanBaku() {
        Satuan satuanM = new Satuan('Meter')
        Satuan satuanCM = new Satuan('Centimeter', new Konversi(1/100, satuanM))
        Satuan satuanMM = new Satuan('Milimeter', new Konversi(1/1000, satuanM))
        Satuan satuanInch = new Satuan('Inch', new Konversi(1/39.3701, satuanM))

        Satuan satuanPcs = new Satuan('Pcs')
        Satuan satuanLusin = new Satuan('Lusin', new Konversi(12, satuanPcs))
        Satuan satuanGross = new Satuan('Gross', new Konversi(144, satuanPcs))

        assertEquals(satuanM, satuanM.satuanBaku())
        assertEquals(satuanM, satuanCM.satuanBaku())
        assertEquals(satuanM, satuanMM.satuanBaku())
        assertEquals(satuanM, satuanInch.satuanBaku())

        assertEquals(satuanPcs, satuanPcs.satuanBaku())
        assertEquals(satuanPcs, satuanLusin.satuanBaku())
        assertEquals(satuanPcs, satuanGross.satuanBaku())
    }

    @Test
    public void testFaktorUntukSatuanSama() {
        Satuan satuanM = new Satuan('Meter')
        Satuan satuanCM = new Satuan('Centimeter', new Konversi(1/100, satuanM))

        assertEquals(0, satuanM.faktor(satuanM).compareTo(1))
        assertEquals(0, satuanCM.faktor(satuanCM).compareTo(1))
    }

    @Test
    public void testFaktor() {
        Satuan satuanM = new Satuan('Meter')
        Satuan satuanCM = new Satuan('Centimeter', new Konversi(1/100, satuanM))
        Satuan satuanMM = new Satuan('Milimeter', new Konversi(1/1000, satuanM))
        Satuan satuanInch = new Satuan('Inch', new Konversi(1/39.3701, satuanM))

        assertEquals('100,000', String.format("%.3f",satuanM.faktor(satuanCM)))
        assertEquals('0,010', String.format("%.3f", satuanCM.faktor(satuanM)))
        assertEquals('2,540', String.format("%.3f", satuanInch.faktor(satuanCM)))
        assertEquals('0,039', String.format("%.3f", satuanMM.faktor(satuanInch)))

        Satuan satuanPcs = new Satuan('Pcs')
        Satuan satuanLusin = new Satuan('Lusin', new Konversi(12, satuanPcs))
        Satuan satuanGross = new Satuan('Gross', new Konversi(144, satuanPcs))

        assertEquals(12, satuanLusin.faktor(satuanPcs).intValue())
        assertEquals(144, satuanGross.faktor(satuanPcs).intValue())
    }

    @Test
    public void testSatuanTidakCocok() {
        Satuan satuanM = new Satuan('Meter')
        Satuan satuanPcs = new Satuan('Pcs')

        GroovyAssert.shouldFail(SatuanTidakCocok) {
            satuanM.faktor(satuanPcs)
        }
    }
}

Pada pengujian diatas, saya memakai salah satu fasilitas unik di Groovy yaitu GroovyAssert.shouldFail(). Method tersebut akan menggagalkan pengujian bila seandainya Exception yang diharapkan tidak terjadi.

Saya kemudian menjalankan pengujian dan memastikan bahwa semua method di atas lulus, seperti yang terlihat pada gambar berikut ini:

Hasil unit test yang sukses

Hasil unit test yang sukses

Berikutnya, saya akan membuat implementasi untuk class Kuantitas seperti berikut ini:

import ...

@Embeddable @Canonical
class Kuantitas {

    @NotNull
    BigDecimal jumlah

    @ManyToOne @NotNull
    Satuan satuan

    Kuantitas to(Satuan satuanLain) {
        new Kuantitas(jumlah * satuan.faktor(satuanLain), satuanLain)
    }

    Kuantitas plus(Kuantitas kuantitas) {
        new Kuantitas(jumlah + kuantitas.to(satuan).jumlah, satuan)
    }

    Kuantitas minus(Kuantitas kuantitas) {
        plus(new Kuantitas(-kuantitas.jumlah, kuantitas.satuan))
    }

}

Pada kode program di atas, method minus() akan memanggil method plus() dengan nilai negatif karena pada dasarnya a-b adalah a+(-b). Saya juga bisa melakukannya untuk pembagian dimana a/b adalah a*(1/b).

Berikutnya, saya membuat unit test baru untuk menguji class Kuantitas seperti berikut ini:

import ...

class KuantitasTests {

    @Test
    void testTo() {
        Satuan satuanM = new Satuan('Meter')
        Satuan satuanCM = new Satuan('Centimeter', new Konversi(1/100, satuanM))
        Satuan satuanMM = new Satuan('Milimeter', new Konversi(1/1000, satuanM))
        Satuan satuanInch = new Satuan('Inch', new Konversi(1/39.3701, satuanM))

        Kuantitas nilai1 = new Kuantitas(10, satuanM)
        assertEquals(1000, nilai1.to(satuanCM).jumlah.intValue())
        assertEquals(10000, nilai1.to(satuanMM).jumlah.intValue())
        assertEquals('393,701', String.format('%.3f', nilai1.to(satuanInch).jumlah))

        Satuan satuanPcs = new Satuan('Pcs')
        Satuan satuanLusin = new Satuan('Lusin', new Konversi(12, satuanPcs))
        Satuan satuanGross = new Satuan('Gross', new Konversi(144, satuanPcs))

        Kuantitas nilai2 = new Kuantitas(15, satuanLusin)
        assertEquals(180, nilai2.to(satuanPcs).jumlah.intValue())
        assertEquals('1,250', String.format('%.3f', nilai2.to(satuanGross).jumlah))

        Kuantitas nilai3 = new Kuantitas(2, satuanGross)
        assertEquals(288, nilai3.to(satuanPcs).jumlah.intValue())
        assertEquals(24, nilai3.to(satuanLusin).jumlah.intValue())
    }

    @Test
    void testPlus() {
        Satuan satuanM = new Satuan('Meter')
        Satuan satuanCM = new Satuan('Centimeter', new Konversi(1/100, satuanM))
        Satuan satuanInch = new Satuan('Inch', new Konversi(1/39.3701, satuanM))

        Kuantitas nilai1 = new Kuantitas(5, satuanM)
        Kuantitas nilai2 = new Kuantitas(10, satuanCM)
        Kuantitas nilai3 = new Kuantitas(2, satuanInch)
        Kuantitas hasil = nilai1 + nilai2 + nilai3

        assertEquals('5,1508', String.format('%.4f', hasil.jumlah))
        assertEquals(satuanM, hasil.satuan)

        hasil += nilai1
        assertEquals('10,1508', String.format('%.4f', hasil.jumlah))
        assertEquals(satuanM, hasil.satuan)
    }

    @Test
    void testMinus() {
        Satuan satuanPcs = new Satuan('Pcs')
        Satuan satuanLusin = new Satuan('Lusin', new Konversi(12, satuanPcs))
        Satuan satuanGross = new Satuan('Gross', new Konversi(144, satuanPcs))

        Kuantitas nilai1 = new Kuantitas(2, satuanGross)
        nilai1 -= new Kuantitas(3, satuanLusin)
        nilai1 -= new Kuantitas(10, satuanPcs)

        assertEquals(242, nilai1.to(satuanPcs).jumlah.intValue())
        assertEquals(satuanGross, nilai1.satuan)
    }

    @Test
    void testSatuanTidakSama() {
        Satuan satuanPcs = new Satuan('Pcs')
        Satuan satuanM = new Satuan('Meter')

        GroovyAssert.shouldFail(SatuanTidakCocok) {
            new Kuantitas(2,satuanPcs) + new Kuantitas(3,satuanM)
        }
    }

}

Kode program pada unit test akan mewakili seperti apa class saat dipakai (misalnya saat dipanggil di controller atau dipanggil dari class lain). Pada contoh ini, saya memanfaatkan fasilitas khusus dari Groovy untuk melakukan operator overloading. Method dengan nama plus() milik Kuantitas akan dikerjakan bila terdapat ekspresi dengan tanda tambah seperti kuantitas1 + kuantitas2. Begitu juga dengan method minus() yang akan dikerjakan bila terdapat ekspresi seperti kuantitas1 - kuantitas2. Ini salah satu contoh dimana operator overloading sangat berguna: ia membuat class Kuantitas lebih mudah dipakai dan dipahami.

Berikutnya, saya perlu merancang class yang mewakili produk dan harganya. Setiap produk memiliki jumlah dalam satuan tertentu. Mereka juga bisa dijual dalam bentuk satuan yang berbeda dengan harga yang berbeda. Saya tidak tahu seperti apa pricing model yang dipakai karena saya tidak memiliki akses ke domain expert. Oleh sebab itu saya mencari informasi di buku atau web mengenai pricing model. Agar sederhana, saya akan menganggap klien ingin memakai model all-units discounts dimana bila pembelian mencapai kuantitas tertentu (atau pada satuan tertentu), maka harga seluruh unit akan mendapatkan diskon yang sama. Sebagai contoh, harga sebuah produk Snack A bila dibeli secara eceran adalah Rp 3.000,-/pcs. Bila dibeli secara lusin, maka harga Snack A menjadi Rp. 2.800,-/pcs sehingga harga 1 lusin adalah Rp 33.600,-/lusin (dimana tanpa diskon adalah Rp 36.000,-/lusin). Bila dibeli secara gross, maka harga Snack A menjadi Rp 2.600,-/pcs sehingga harga 1 gross adalah Rp Rp 374.400,-/gross (dimana tanpa diskon adalah Rp. 432.000,-/gross). Dengan kata lain, saya dapat menyimpulkan bahwa harga Snack A mendapat diskon Rp 200,- dari harga eceran bila dibeli per lusin dan mendapat diskon Rp 400,- dari harga eceran bila dibeli per gross.

Saya kemudian mengubah rancangan sehingga menjadi seperti berikut ini:

Hasil akhir setelah penambahan Produk, Diskon dan Harga

Hasil akhir setelah penambahan Produk, Diskon dan Harga

Pada rancangan di atas, saya membuat sebuah value object baru yang diwakili class Harga. Pada dasarnya Harga dan Kuantitas hampir sama; perbedaannya adalah mereka saling terbalik dalam melakukan konversi. Untuk Kuantitas, 2 lusin adalah 2 x 12= 24 pieces. Untuk Harga, Rp 12.000,-/lusin adalah 12.000 / 12 = Rp 1.000,-/pieces. Karena kesamaannya, hubungan antara Kuantitas dan Harga dapat berupa spesialisasi/inheritance. Akan tetapi, karena saya memakai Hibernate, saya menjumpai keterbatasan teknologi dimana sebuah class @Embeddable tidak dapat diturunkan dari class lain. Keputusan seperti ini perlu diperhatikan sehingga tidak menghasilkan rancangan yang tidak dapat diimplementasikan pada teknologi yang dipakai.

Saya memakai qualifier Satuan pada hubungan antara Produk dan Diskon untuk memperlihatkan bahwa setiap Produk memiliki Diskon per Satuan. Saya meniru teknik ini setelah membacanya di buku Domain Driven Design (DDD). Implementasi untuk rancangan tersebut dilakukan dengan menggunakan Map yang memiliki asosiasi keyvalue.

Mengapa ada method addDiskonByHargaSatuan(Harga) di Produk? Teman saya menjelaskan bahwa terkadang pengguna hanya ingin memasukkan harga per satuan seperti Rp 33.600,- per lusin (dimana harga per piece adalah Rp 3.000,-). Padahal, sistem harus menyimpannya dengan nilai berupa diskon Rp 200,- per piece. Ia kemudian mengemukakan idenya bahwa sebaiknya pengguna menghitung nilai diskon secara manual melalui kalkulator. Atau alternatif lainnya, biarkan programmer menghitung sendiri nilai ini di view. Tapi, sebuah solusi yang lebih baik adalah memasukkan proses perhitungan ini pada rancangan dalam bentuk sebuah method karena perhitungan ini adalah bagian dari business logic.

Contoh implementasi yang mungkin untuk class Diskon adalah:

import ...

@Embeddable @Canonical
class Diskon {

    BigDecimal potonganLangsung

    BigDecimal potonganPersen

    BigDecimal hasilDiskon(BigDecimal nilai) {
        nilai - jumlahDiskon(nilai)
    }

    BigDecimal jumlahDiskon(BigDecimal nilai) {
        (potonganPersen?:0) / 100 * nilai + (potonganLangsung?:0)
    }
}

Untuk menguji class ini, saya membuat unit test seperti:

import ...

class DiskonTests {

    @Test
    void testHasilDiskon() {
        Diskon diskon = new Diskon(1000)
        assertEquals(49000, diskon.hasilDiskon(50000).intValue())

        diskon = new Diskon(potonganPersen: 5)
        assertEquals(47500, diskon.hasilDiskon(50000).intValue())

        diskon = new Diskon(1000, 5)
        assertEquals(46500, diskon.hasilDiskon(50000).intValue())
    }

    @Test
    void testJumlahDiskon() {
        Diskon diskon = new Diskon(1000)
        assertEquals(1000, diskon.jumlahDiskon(50000).intValue())

        diskon = new Diskon(potonganPersen: 5)
        assertEquals(2500, diskon.jumlahDiskon(50000).intValue())

        diskon = new Diskon(1000, 5)
        assertEquals(3500, diskon.jumlahDiskon(50000).intValue())
    }
}

Setelah memastikan bahwa Diskon sudah bisa bekerja baik dan benar, saya lanjut membuat implementasi untuk Harga seperti yang terlihat berikut ini:

import ...

@Embeddable @Canonical
class Harga {

    @NotNull
    BigDecimal jumlah

    @NotNull @ManyToOne
    Satuan satuan

    public Harga to(Satuan satuanLain) {
        new Harga(jumlah / satuan.faktor(satuanLain), satuanLain)
    }

    public Harga plus(Harga hargaLain) {
        if (!hargaLain.satuan.equals(satuan)) {
            throw new SatuanTidakCocok(hargaLain.satuan, satuan)
        }
        new Harga(jumlah + hargaLain.to(satuan).jumlah, satuan)
    }

    public Harga minus(Harga hargaLain) {
        plus(new Harga(-hargaLain.jumlah, hargaLain.satuan))
    }

    public Harga multiply(Number angka) {
        new Harga(angka * jumlah, satuan)
    }

    public Harga multiply(Kuantitas kuantitas) {
        if (!kuantitas.satuan.equals(satuan)) {
            throw new SatuanTidakCocok(kuantitas.satuan, satuan)
        }
        multiply(kuantitas.jumlah)
    }

    public Harga diskon(Diskon diskon) {
        new Harga(diskon.hasilDiskon(jumlah), satuan)
    }

}

Class Harga memiliki cukup banyak operasi, sehingga saya perlu lebih rajin membuat unit test untuk menguji kebenarannya:

import ...

class HargaTests {

    @Test
    void testTo() {
        Satuan satuanM = new Satuan('Meter')
        Satuan satuanCM = new Satuan('Centimeter', new Konversi(1/100, satuanM))
        Satuan satuanMM = new Satuan('Milimeter', new Konversi(1/1000, satuanM))

        Harga harga = new Harga(1000, satuanM)
        assertEquals(1000, harga.to(satuanM).jumlah.intValue())
        assertEquals(10, harga.to(satuanCM).jumlah.intValue())
        assertEquals(1, harga.to(satuanMM).jumlah.intValue())

        harga = new Harga(2000, satuanCM)
        assertEquals(200000, harga.to(satuanM).jumlah.intValue())
        assertEquals(2000, harga.to(satuanCM).jumlah.intValue())
        assertEquals(200, harga.to(satuanMM).jumlah.intValue())
    }

    @Test
    void testPlus() {
        Satuan satuanM = new Satuan('Meter')
        Satuan satuanCM = new Satuan('Centimeter', new Konversi(1/100, satuanM))

        Harga harga1 = new Harga(1000, satuanM)
        Harga harga2 = new Harga(1100, satuanM)
        Harga harga3 = new Harga(2100, satuanM)
        assertEquals(harga3, harga1+harga2)

        GroovyAssert.shouldFail(SatuanTidakCocok) {
            harga1 + new Harga(1100, satuanCM)
        }
    }

    @Test
    void testMinus() {
        Satuan satuanM = new Satuan('Meter')
        Satuan satuanCM = new Satuan('Centimeter', new Konversi(1/100, satuanM))

        Harga harga1 = new Harga(1100, satuanM)
        Harga harga2 = new Harga(1000, satuanM)
        Harga harga3 = new Harga(100, satuanM)
        assertEquals(harga3, harga1-harga2)

        GroovyAssert.shouldFail(SatuanTidakCocok) {
            harga1 - new Harga(1100, satuanCM)
        }
    }

    @Test
    void testMultiplyNumber() {
        Satuan satuanM = new Satuan('Meter')

        Harga harga1 = new Harga(1000, satuanM)
        Harga harga2 = new Harga(5000, satuanM)
        assertEquals(harga2, harga1 * 5)

        harga2 = new Harga(3300, satuanM)
        assertEquals(harga2, harga1 * 3.3)
    }

    @Test
    void testMultiplyKuantitas() {
        Satuan satuanM = new Satuan('Meter')
        Satuan satuanCM = new Satuan('Centimeter', new Konversi(1/100, satuanM))

        Harga harga = new Harga(1000, satuanM)
        Kuantitas kuantitas = new Kuantitas(5, satuanM)
        Harga hasil = new Harga(5000, satuanM)
        assertEquals(hasil, harga * kuantitas)

        GroovyAssert.shouldFail(SatuanTidakCocok) {
            kuantitas = new Kuantitas(5, satuanCM)
            harga * kuantitas
        }
    }

    @Test
    void testDiskon() {
        Satuan satuanM = new Satuan('Meter')
        Harga harga = new Harga(1000, satuanM)

        Diskon diskon = new Diskon(100)
        Harga hasil = new Harga(900, satuanM)
        assertEquals(hasil, harga.diskon(diskon))

        diskon = new Diskon(potonganPersen: 1.5)
        hasil = new Harga(985, satuanM)
        assertEquals(hasil, harga.diskon(diskon))

        diskon = new Diskon(100, 3)
        hasil = new Harga(870, satuanM)
        assertEquals(hasil, harga.diskon(diskon))
    }
}

Berikutnya, saya membuat implementasi untuk class Produk yang terlihat seperti berikut ini:

import ...

@DomainClass @Entity @Canonical(excludes='diskon')
class Produk {

    @NotBlank
    String kode

    @NotBlank
    String nama

    @NotNull @Embedded
    Kuantitas jumlah

    @NotNull @Embedded
    Harga harga

    @ElementCollection(fetch=FecthType.EAGER)
    Map<Long, Diskon> diskon = [:]

    void setDiskon(Satuan satuan, Diskon jumlahDiskon) {
        diskon[satuan.id] = jumlahDiskon
    }

    Diskon getDiskon(Satuan satuan) {
        diskon[satuan.id]
    }

    void addDiskonByHargaSatuan(Harga hargaSatuan) {
        Satuan satuanBaku = harga.satuan.satuanBaku()
        Harga selisih = harga.to(satuanBaku) - hargaSatuan.to(satuanBaku)
        diskon[hargaSatuan.satuan.id] = new Diskon(selisih.jumlah)
    }

    Harga harga(Satuan satuan) {
        Satuan satuanBaku = harga.satuan.satuanBaku()
        Harga hargaSatuan = harga.to(satuanBaku)
        if (diskon[satuan.id]) {
            hargaSatuan = hargaSatuan.diskon(diskon[satuan.id])
        }
        hargaSatuan.to(satuan)
    }

}

Salah satu keterbatasan pada @Embeddable Map di JPA Hibernate saat ini adalah bila key berupa entity maka eager loading tidak dapat dilakukan! Ini adalah sebuah bug yang masih belum diselesaikan hingga saat ini. Karena saya ingin fasilitas eager loading, saya tidak bisa mendefinisikan Map. Sebagai gantinya, saya memakai Map dimana Long adalah id dari Satuan yang hendak dijadikan sebagai key. Untuk mempermudah mengakses map tersebut, saya menyediakan setDiskon(Satuan, Diskon) dan Diskon getDiskon(Satuan).

Hal lain yang harus diperhatikan bila memakai Hibernate dalam penggunaan @Embeddable adalah kemungkinan dua atribut di @Embeddable berbeda dengan nama yang sama. Hibernate akan gagal membuat tabel bila terdapat kolom dengan nama yang sama, seperti kolom satuan di Kuantitas dan kolom satuan di Harga dimana baik Kuantitas dan Harga dipakai oleh Produk. Untuk menghindari hal ini, saya menambahkan baris berikut ini pada persistence.xml:

...
<property name="hibernate.ejb.naming_strategy" value="org.hibernate.cfg.DefaultComponentSafeNamingStrategy" />
...

Memakai Hibernate rasanya ribet sekali, bukan? Tapi sejujurnya kompleksitas ini terlahir dari ketidaksesuaian antara OOP dan tabel relasional. Bila seandainya saya harus membuat sebuah framework ORM dari awal, saya bisa menciptakan sesuatu yang sangat sederhana untuk operasi CRUD. Namun seiring berbagai permasalahan yang muncul, pada akhirnya mungkin akan kompleks seperti Hibernate. Hal ini akibat masalah-masalah relasional yang memang tidak dapat dihindari. Jadi, untuk saat ini, lebih baik memakai sesuatu yang sudah matang seperti Hibernate.

Saya kemudian membuat unit test untuk menguji kebenaran class Produk:

import ...

class ProdukTests {

    @Test
    void testSetDiskonByHargaSatuan() {
        Satuan satuanPcs = new Satuan(id: 1, nama: 'Pcs')
        Satuan satuanLusin = new Satuan(id: 2, nama: 'Lusin', konversi: new Konversi(12, satuanPcs))
        Satuan satuanGross = new Satuan(id: 3, nama: 'Gross', konversi: new Konversi(144, satuanPcs))

        Produk produk = new Produk('SA', 'Snack A')
        produk.harga = new Harga(3000, satuanPcs)

        produk.addDiskonByHargaSatuan(new Harga(3000, satuanPcs))
        produk.addDiskonByHargaSatuan(new Harga(33600, satuanLusin))
        produk.addDiskonByHargaSatuan(new Harga(374400, satuanGross))

        assertEquals(0, produk.getDiskon(satuanPcs).potonganLangsung.intValue())
        assertEquals(200, produk.getDiskon(satuanLusin).potonganLangsung.intValue())
        assertEquals(400, produk.getDiskon(satuanGross).potonganLangsung.intValue())
    }

    @Test
    void testHarga() {
        Satuan satuanPcs = new Satuan(id: 1, nama: 'Pcs')
        Satuan satuanLusin = new Satuan(id: 2, nama: 'Lusin', konversi: new Konversi(12, satuanPcs))
        Satuan satuanGross = new Satuan(id: 3, nama: 'Gross', konversi: new Konversi(144, satuanPcs))

        Produk produk = new Produk('SA', 'Snack A')
        produk.harga = new Harga(3000, satuanPcs)
        produk.setDiskon(satuanLusin, new Diskon(200))
        produk.setDiskon(satuanGross, new Diskon(400))
        assertEquals(3000, produk.harga(satuanPcs).jumlah.intValue())
        assertEquals(satuanPcs, produk.harga(satuanPcs).satuan)
        assertEquals(33600, produk.harga(satuanLusin).jumlah.intValue())
        assertEquals(satuanLusin, produk.harga(satuanLusin).satuan)
        assertEquals(374400, produk.harga(satuanGross).jumlah.intValue())
        assertEquals(satuanGross, produk.harga(satuanGross).satuan)

        produk = new Produk('SB', 'Snack B')
        produk.harga = new Harga(150000, satuanLusin)
        produk.setDiskon(satuanGross, new Diskon(0, 0.5))
        assertEquals(12500, produk.harga(satuanPcs).jumlah.intValue())
        assertEquals(satuanPcs, produk.harga(satuanPcs).satuan)
        assertEquals(150000, produk.harga(satuanLusin).jumlah.intValue())
        assertEquals(satuanLusin, produk.harga(satuanLusin).satuan)
        assertEquals(1791000, produk.harga(satuanGross).jumlah.intValue())
        assertEquals(satuanGross, produk.harga(satuanGross).satuan)
    }
}

Salah satu konsekuensi dari perancangan class Produk seperti ini adalah ia terus mengingat diskon per satuan dasar walaupun harga dimodifikasi ke satuan lain. Sebagai contoh:

Produk produk = new Produk('SA', 'Snack A')
produk.harga = new Harga(3000, satuanPcs)
produk.addDiskonByHargaSatuan(new Harga(33600, satuanLusin))
assertEquals(33600, produk.harga(satuanLusin).jumlah.intValue())

// Perubahan harga dasar
produk.harga = new Harga(39000, satuanLusin)
assertEquals(3250, produk.harga(satuanPcs).jumlah.intValue())
assertEquals(36600, produk.harga(satuanLusin).jumlah.intValue())

Perilaku ini mungkin diharapkan dan mungkin tidak diharapkan tergantung pada kebutuhan pengguna. Bila diskon tidak perlu di-ingat, maka setiap kali harga baru di-set, nilai dari diskon perlu dikosongkan, seperti:

class Produk {

  ...

  void setHarga(Harga hargaBaru) {
     diskon.clear()
     this.harga = hargaBaru
  }

  ...

}

Lalu bagaimana dengan class ProdukRepository dan SatuanRepository? Mereka adalah repository yang bertugas menyimpan dan mencari entity Produk dan Satuan. Plugin simple-jpa telah menyediakan fasilitas tersebut sehingga saya tidak perlu membuat kode program yang mengerjakan SQL lagi. Contoh implementasinya terlihat seperti:

import ...

@Transaction
class ProdukRepository {

    public void create(Produk produk) {
        persist(produk)
    }

}
import ...

@Transaction
class SatuanRepository {

    public void create(Satuan satuan) {
        persist(satuan)
    }

}

Bagaimana cara menguji repository? Karena mereka mengambil dan menyimpan object dari dan ke database, maka pengujian sebaiknya dilakukan pada integration test. Perbedaan integration test dan unit test adalah unit test hanya melibatkan object yang terisolasi di memori (tanpa hubungan ke dunia luar seperti database, internet, dsb). Berbeda dengan unit test, pada integration test, saya harus mendefinisikan object secara lengkap karena kali ini validasi akan dilakukan pada saat menyimpan object ke database. Integration test juga lebih lambat dikerjakan bila dibandingkan dengan unit test.

Contoh integration test yang saya buat untuk menguji penyimpanan di database adalah:

import ...

class ProdukTest {

    @Test
    public void testCreate() {
        SatuanRepository satuanRepository = new SatuanRepository()
        Satuan satuanPcs = new Satuan('Pcs')
        Satuan satuanLusin = new Satuan('Lusin', new Konversi(12, satuanPcs))
        Satuan satuanGross = new Satuan('Gross', new Konversi(144, satuanPcs))
        satuanRepository.create(satuanPcs)
        satuanRepository.create(satuanLusin)
        satuanRepository.create(satuanGross)

        ProdukRepository produkRepository = new ProdukRepository()
        Produk produkA = new Produk(kode: 'SA', nama: 'Snack A', harga: new Harga(3000, satuanPcs), jumlah: new Kuantitas(10, satuanPcs))
        Produk produkB = new Produk(kode: 'SB', nama: 'Snack B', harga: new Harga(2000, satuanPcs), jumlah: new Kuantitas(10, satuanPcs))
        produkB.setDiskon(satuanLusin, new Diskon(200))
        produkRepository.create(produkA)
        produkRepository.create(produkB)

        Produk p = produkRepository.findProdukByNama('Snack A')
        assertEquals(produkA, p)
        assertEquals(36000, p.harga(satuanLusin).jumlah.intValue())

        p = produkRepository.findProdukByNama('Snack B')
        assertEquals(produkB, p)
        assertEquals(21600, p.harga(satuanLusin).jumlah.intValue())

        assertEquals(2, produkRepository.findAllProdukByNamaLike('Snack%').size())
    }
}

Bila saya menjalankan integration test di atas, Hibernate akan membuat struktur tabel yang terlihat seperti berikut ini:

Struktur tabel yang dihasilkan Hibernate

Struktur tabel yang dihasilkan Hibernate

Mereka yang terbiasa mengakses tabel secara langsung melalui SQL biasanya sering kali menganggap bahwa sebuah class adalah padanan dari sebuah tabel di database. Tapi tidak selalu demikian. Sebagai contoh, pada rancangan saya terdapat 9 class tetapi tabel yang dibutuhkan hanya 3 tabel (plus 1 tabel khusus untuk keperluan hibernate). Tidak semua dari class tersebut perlu disimpan nilainya dalam tabel. Beberapa bahkan bisa digabung, seperti class yang mengandung inheritance dan embeddable. Selain itu, perbedaan antara ERD dan class diagram terlihat secara jelas: tidak ada operasi di ERD sehingga saya tidak tahu bagaimana proses dan interaksi yang ada dalam program bila hanya melihat ERD!

Pada integration test di atas, tidak ada query SQL yang dilakukan karena repository memakai simple-jpa yang menyediakan dynamic finders. Lalu seperti ada SQL yang dihasilkan di balik layar? Berikut adalah cuplikan log yang saya peroleh:

insert into hibernate_sequences(sequence_name, sequence_next_hi_value) values('Satuan', ?)

update hibernate_sequences set sequence_next_hi_value = ? where sequence_next_hi_value = ? and sequence_name = 'Satuan'

insert into Satuan (createddate, deleted, konversi_faktorsatuanbaku, konversi_satuanbaku_id, modifieddate, nama, id) values (?, ?, ?, ?, ?, ?, ?)

insert into Satuan (createddate, deleted, konversi_faktorsatuanbaku, konversi_satuanbaku_id, modifieddate, nama, id) values (?, ?, ?, ?, ?, ?, ?)

insert into Satuan (createddate, deleted, konversi_faktorsatuanbaku, konversi_satuanbaku_id, modifieddate, nama, id) values (?, ?, ?, ?, ?, ?, ?)

select sequence_next_hi_value from hibernate_sequences where sequence_name = 'Produk' for update

insert into hibernate_sequences(sequence_name, sequence_next_hi_value) values('Produk', ?)

update hibernate_sequences set sequence_next_hi_value = ? where sequence_next_hi_value = ? and sequence_name = 'Produk'

insert into Produk (createddate, deleted, harga_jumlah, harga_satuan_id, jumlah_jumlah, jumlah_satuan_id, kode, modifieddate, nama, id) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)

insert into Produk (createddate, deleted, harga_jumlah, harga_satuan_id, jumlah_jumlah, jumlah_satuan_id, kode, modifieddate, nama, id) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)

insert into Produk_diskon (Produk_id, diskon_KEY, diskon_value_potonganlangsung, diskon_value_potonganpersen) values (?, ?, ?, ?)

select distinct produk0_.id as id1_4_, produk0_.createddate as createdd2_4_, produk0_.deleted as deleted3_4_, produk0_.harga_jumlah as harga_ju4_4_, produk0_.harga_satuan_id as harga_sa9_4_, produk0_.jumlah_jumlah as jumlah_j5_4_, produk0_.jumlah_satuan_id as jumlah_10_4_, produk0_.kode as kode6_4_, produk0_.modifieddate as modified7_4_, produk0_.nama as nama8_4_ from Produk produk0_ where produk0_.nama=?

select satuan0_.id as id1_6_0_, satuan0_.createddate as createdd2_6_0_, satuan0_.deleted as deleted3_6_0_, satuan0_.konversi_faktorsatuanbaku as konversi4_6_0_, satuan0_.konversi_satuanbaku_id as konversi7_6_0_, satuan0_.modifieddate as modified5_6_0_, satuan0_.nama as nama6_6_0_, satuan1_.id as id1_6_1_, satuan1_.createddate as createdd2_6_1_, satuan1_.deleted as deleted3_6_1_, satuan1_.konversi_faktorsatuanbaku as konversi4_6_1_, satuan1_.konversi_satuanbaku_id as konversi7_6_1_, satuan1_.modifieddate as modified5_6_1_, satuan1_.nama as nama6_6_1_ from Satuan satuan0_ left outer join Satuan satuan1_ on satuan0_.konversi_satuanbaku_id=satuan1_.id where satuan0_.id=?

select diskon0_.Produk_id as Produk_i1_4_0_, diskon0_.diskon_value_potonganlangsung as diskon_v2_5_0_, diskon0_.diskon_value_potonganpersen as diskon_v3_5_0_, diskon0_.diskon_KEY as diskon_K4_0_ from Produk_diskon diskon0_ where diskon0_.Produk_id=?

select distinct produk0_.id as id1_4_, produk0_.createddate as createdd2_4_, produk0_.deleted as deleted3_4_, produk0_.harga_jumlah as harga_ju4_4_, produk0_.harga_satuan_id as harga_sa9_4_, produk0_.jumlah_jumlah as jumlah_j5_4_, produk0_.jumlah_satuan_id as jumlah_10_4_, produk0_.kode as kode6_4_, produk0_.modifieddate as modified7_4_, produk0_.nama as nama8_4_ from Produk produk0_ where produk0_.nama=?

select satuan0_.id as id1_6_0_, satuan0_.createddate as createdd2_6_0_, satuan0_.deleted as deleted3_6_0_, satuan0_.konversi_faktorsatuanbaku as konversi4_6_0_, satuan0_.konversi_satuanbaku_id as konversi7_6_0_, satuan0_.modifieddate as modified5_6_0_, satuan0_.nama as nama6_6_0_, satuan1_.id as id1_6_1_, satuan1_.createddate as createdd2_6_1_, satuan1_.deleted as deleted3_6_1_, satuan1_.konversi_faktorsatuanbaku as konversi4_6_1_, satuan1_.konversi_satuanbaku_id as konversi7_6_1_, satuan1_.modifieddate as modified5_6_1_, satuan1_.nama as nama6_6_1_ from Satuan satuan0_ left outer join Satuan satuan1_ on satuan0_.konversi_satuanbaku_id=satuan1_.id where satuan0_.id=?

select diskon0_.Produk_id as Produk_i1_4_0_, diskon0_.diskon_value_potonganlangsung as diskon_v2_5_0_, diskon0_.diskon_value_potonganpersen as diskon_v3_5_0_, diskon0_.diskon_KEY as diskon_K4_0_ from Produk_diskon diskon0_ where diskon0_.Produk_id=?

select distinct produk0_.id as id1_4_, produk0_.createddate as createdd2_4_, produk0_.deleted as deleted3_4_, produk0_.harga_jumlah as harga_ju4_4_, produk0_.harga_satuan_id as harga_sa9_4_, produk0_.jumlah_jumlah as jumlah_j5_4_, produk0_.jumlah_satuan_id as jumlah_10_4_, produk0_.kode as kode6_4_, produk0_.modifieddate as modified7_4_, produk0_.nama as nama8_4_ from Produk produk0_ where produk0_.nama like ?

select satuan0_.id as id1_6_0_, satuan0_.createddate as createdd2_6_0_, satuan0_.deleted as deleted3_6_0_, satuan0_.konversi_faktorsatuanbaku as konversi4_6_0_, satuan0_.konversi_satuanbaku_id as konversi7_6_0_, satuan0_.modifieddate as modified5_6_0_, satuan0_.nama as nama6_6_0_, satuan1_.id as id1_6_1_, satuan1_.createddate as createdd2_6_1_, satuan1_.deleted as deleted3_6_1_, satuan1_.konversi_faktorsatuanbaku as konversi4_6_1_, satuan1_.konversi_satuanbaku_id as konversi7_6_1_, satuan1_.modifieddate as modified5_6_1_, satuan1_.nama as nama6_6_1_ from Satuan satuan0_ left outer join Satuan satuan1_ on satuan0_.konversi_satuanbaku_id=satuan1_.id where satuan0_.id=?

select diskon0_.Produk_id as Produk_i1_4_0_, diskon0_.diskon_value_potonganlangsung as diskon_v2_5_0_, diskon0_.diskon_value_potonganpersen as diskon_v3_5_0_, diskon0_.diskon_KEY as diskon_K4_0_ from Produk_diskon diskon0_ where diskon0_.Produk_id=?

select diskon0_.Produk_id as Produk_i1_4_0_, diskon0_.diskon_value_potonganlangsung as diskon_v2_5_0_, diskon0_.diskon_value_potonganpersen as diskon_v3_5_0_, diskon0_.diskon_KEY as diskon_K4_0_ from Produk_diskon diskon0_ where diskon0_.Produk_id=?

Terlihat bahwa terdapat permasalahan N+1 query dimana setelah mengerjakan query SELECT untuk memperoleh sebuah Produk dari tabel, Hibernate akan mengerjakan query terpisah untuk membaca Produk.Kuantitas.satuan dan masing-masing Produk.Diskon. Perilaku ini bisa dioptimalkan lagi dengan menggunakan left join atau dengan memberikan query secara manual, tapi selama tidak ada penalti yang siginifikan pada kinerja (seperti pada contoh di atas), ini adalah sesuatu yang bisa diterima.

Langkah berikutnya adalah membuat presentation untuk rancangan. Designer yang baik biasanya tidak merancang berdasarkan patokan presentation (tampilan). Oleh sebab itu, saya tidak membatasi jenis presentation yang bisa dipakai untuk rancangan ini. Selama merancang, saya hanya memikirkan bagaimana programmer nantinya memakai object yang ada. Bisa saja presentation berupa komponen Swing untuk aplikasi desktop atau tampilan HTML berbasis web. Atau bisa saja tidak ada presentation sama sekali, misalnya terdapat services layer yang mempublikasikan domain model dalam bentuk web services. Apapun jenis presentation yang dipakai, cara kerjanya tetap sama, controller di presentation akan memanggil repository untuk mengambil dan membuat root aggregate (seperti Produk dan Satuan), lalu mengerjakan business logic yang ada di domain object yang diperolehnya (seperti to(), addDiskon(), harga() dan sebagainya).

Using Accounting Analysis Pattern For Inventory

In the previous post,  Implementing the Accounting Analysis Pattern, I’ve used accounting analysis pattern to create a simple work orders processing software for a hypothetical motorcycle service station.   Most work orders processing will require motorcycle spare part’s replacements.   That is why service stations often have some spare part (replacement part) available in their warehouse.   I need to improve the design to track spare part inventory.   This is the requirement:

“The new design should records incoming spare part and its sales.   User should be able to display gross profit for specified spare part.   For the sake of simplicity, I will not introduce period in the new design (real life design usually allow daily, monthly, or yearly aggregation).   Sometimes user makes mistakes so the new design should allow user to revoke any recorded events while still display the correct value for inventory and gross profit.   The system should not forget any event’s adjustments.”

To handle incoming spare parts, I create a new subclass of AccountingEvent called IncomingSparepartEvent.   This event will be triggered by a purchase order.   Class IncomingSparepartInvoice represents the incoming invoice.  Not all incoming spare part events should be associated with an invoice, for example, stock adjustment will generate such event.

The IncomingSparepartEvent

The IncomingSparepartEvent

The source code for IncomingSparepartEvent.groovy is:

package domain

import ...

@DomainModel @Entity @Canonical
class IncomingSparepartEvent extends AccountingEvent {

    @NotNull @OneToOne(cascade=CascadeType.ALL)
    Sparepart sparepart

    @ManyToOne(cascade=CascadeType.MERGE)
    IncomingSparepartInvoice invoice

    @NotNull @Min(0l)
    Integer quantity

    @NotNull @Min(0l)
    BigDecimal unitPrice

    BigDecimal getTotal() {
        quantity * unitPrice
    }

    @Override
    void process() {
        InventoryEntry entry = new InventoryEntry(account: account, date: this.whenOccured, entryType: EntryType.SPAREPART,
            event: this, amount: total, quantity: quantity)
        account.entries << entry
        resultingEntries << entry
        account.events << this
    }

    List<AccountingEvent> adjust(Integer newQuantity, BigDecimal newUnitPrice) {
        List<AccountingEvent> results = []
        results << new RevokedEvent(this)
        results << new IncomingSparepartEvent(account: account, eventType: eventType, resultingEntries: [],
            whenNoticed: DateTime.now(), whenOccured: whenOccured, invoice: invoice, quantity: newQuantity,
            unitPrice: newUnitPrice, sparepart: sparepart)
        results*.process()
        results
    }
}

The source code for IncomingSparepartInvoice.groovy is:

package domain

import ...

@DomainModel @Entity @Canonical(excludes = "events")
class IncomingSparepartInvoice{

    @NotEmpty @Size(min=8, max=8)
    String invoiceNumber

    @NotNull @Type(type="org.jadira.usertype.dateandtime.joda.PersistentLocalDate")
    LocalDate date

    @NotEmpty @Size(min=3, max=50)
    String supplierName

    @NotEmpty @OneToMany(cascade=CascadeType.ALL, orphanRemoval=true, mappedBy="invoice")
    List<IncomingSparepartEvent> events = []

    BigDecimal total() {
        events.sum {
            if (it instanceof IncomingSparepartEvent && it.deleted=='N') {
                return it.total
            }
            0
        } ?: 0
    }

}
Add New Incoming Spare Part

Add New Incoming Sparepart

In the previous post, I created RevokeEvent class to represent ‘deleted’ event. It can be used to implement the reversal adjustment pattern for spare part related events. For example, if I change IncomingSparepartEvent‘s quantity from 100 to 10 an shown in the picture below:

Adjusting Incoming Sparepart Event

Adjusting Incoming Sparepart Event

It will generate two events: one with quantity = -100 (the RevokeEvent), and the other with quantity = 10 (a new IncomingSparepartEvent), as shown below:

Incoming Spare Part Event After Adjusment

Incoming Spare Part Event After Adjusment

I will use the following JUnit’s test case in order to test the adjustment without actually connect to a database:

   void testRevokeEvent() {
        Sparepart sparepart = new Sparepart("SP001", "Sparepart 1", new BigDecimal("25000"))

        IncomingSparepartInvoice invoice = new IncomingSparepartInvoice(invoiceNumber: "INVOICE1",
                date: LocalDate.now(), supplierName: "SUPPLIER1")

        IncomingSparepartEvent event1 = new IncomingSparepartEvent(sparepart: sparepart, invoice: invoice,
                quantity: 10, unitPrice: new BigDecimal("23000"), account: sparepart.inventory,
                eventType: AccountingEventType.SPAREPART_INCOMING, whenNoticed: DateTime.now(), whenOccured: DateTime.now())
        event1.process()

        IncomingSparepartEvent event2 = new IncomingSparepartEvent(sparepart: sparepart, invoice: invoice,
                quantity: 30, unitPrice: new BigDecimal("22500"), account: sparepart.inventory,
                eventType: AccountingEventType.SPAREPART_INCOMING, whenNoticed: DateTime.now(), whenOccured: DateTime.now())
        event2.process()

        IncomingSparepartEvent event3 = new IncomingSparepartEvent(sparepart: sparepart, invoice: invoice,
                quantity: 5, unitPrice: new BigDecimal("23500"), account: sparepart.inventory,
                eventType: AccountingEventType.SPAREPART_INCOMING, whenNoticed: DateTime.now(), whenOccured: DateTime.now())
        event3.process()

        assertEquals(new BigDecimal("1022500"), sparepart.inventory.balance())
        assertEquals(45, sparepart.inventory.quantity())
        assertEquals(new BigDecimal("22722.2222222222"), sparepart.inventory.averageCost())

        // Delete event2
        RevokedEvent revokedEvent = new RevokedEvent(event2)
        revokedEvent.process()

        assertEquals(new BigDecimal("347500"), sparepart.inventory.balance())
        assertEquals(15, sparepart.inventory.quantity())
        assertEquals(new BigDecimal("23166.6666666667"), sparepart.inventory.averageCost())
    }

    void testAdjust() {
        Sparepart sparepart = new Sparepart("SP001", "Sparepart 1", new BigDecimal("25000"))

        IncomingSparepartInvoice invoice = new IncomingSparepartInvoice(invoiceNumber: "INVOICE1",
                date: LocalDate.now(), supplierName: "SUPPLIER1")

        IncomingSparepartEvent event1 = new IncomingSparepartEvent(sparepart: sparepart, invoice: invoice,
                quantity: 10, unitPrice: new BigDecimal("23000"), account: sparepart.inventory,
                eventType: AccountingEventType.SPAREPART_INCOMING, whenNoticed: DateTime.now(), whenOccured: DateTime.now())
        event1.process()

        IncomingSparepartEvent event2 = new IncomingSparepartEvent(sparepart: sparepart, invoice: invoice,
                quantity: 30, unitPrice: new BigDecimal("22500"), account: sparepart.inventory,
                eventType: AccountingEventType.SPAREPART_INCOMING, whenNoticed: DateTime.now(), whenOccured: DateTime.now())
        event2.process()

        IncomingSparepartEvent event3 = new IncomingSparepartEvent(sparepart: sparepart, invoice: invoice,
                quantity: 5, unitPrice: new BigDecimal("23500"), account: sparepart.inventory,
                eventType: AccountingEventType.SPAREPART_INCOMING, whenNoticed: DateTime.now(), whenOccured: DateTime.now())
        event3.process()

        assertEquals(new BigDecimal("1022500"), sparepart.inventory.balance())
        assertEquals(45, sparepart.inventory.quantity())
        assertEquals(new BigDecimal("22722.2222222222"), sparepart.inventory.averageCost())

        // Adjust event2
        event2.adjust(3, new BigDecimal("22000"))

        assertEquals(new BigDecimal("413500"), sparepart.inventory.balance())
        assertEquals(18, sparepart.inventory.quantity())
        assertEquals(new BigDecimal("22972.2222222222"), sparepart.inventory.averageCost())
    }

When user creates a new incoming spare part event that contains non existing spare part, program will create a new Sparepart object and its accounts. This is the structure of Sparepart class:

The Sparepart

The Sparepart

This is the source code for Sparepart.groovy:

package domain

import ...

@DomainModel @Entity @Canonical
class Sparepart {

    Sparepart() {}

    Sparepart(String partNumber, String name, BigDecimal price) {
        this.partNumber = partNumber
        this.name = name
        this.price = price
        setInventory(new SparepartInventory())
        setExpense(new SparepartExpense())
        setSales(new SparepartSales())
    }

    @NotEmpty @Size(min=5, max=5)
    String partNumber

    @NotEmpty @Size(min=3, max=50)
    String name

    @NotNull @Min(1l)
    BigDecimal price

    @NotNull @OneToOne(cascade=CascadeType.ALL, orphanRemoval=true, mappedBy="sparepart")
    SparepartInventory inventory

    @NotNull @OneToOne(cascade=CascadeType.ALL, orphanRemoval=true, mappedBy="sparepart")
    SparepartExpense expense

    @NotNull @OneToOne(cascade=CascadeType.ALL, orphanRemoval=true, mappedBy="sparepart")
    SparepartSales sales

    void setInventory(SparepartInventory inventory) {
        this.inventory = inventory
        inventory.sparepart = this
    }

    void setExpense(SparepartExpense expense) {
        this.expense = expense
        expense.sparepart = this
    }

    void setSales(SparepartSales sales) {
        this.sales = sales
        sales.sparepart = this
    }

}

This is the source code for SparepartInventory.groovy:

package domain

import ...

@DomainModel @Entity @Canonical(excludes = "sparepart")
class SparepartInventory extends Account {

    @OneToOne
    Sparepart sparepart

    BigDecimal averageCost() {
        balance() / quantity()
    }

    @Override
    BigDecimal balance() {
        entries.sum { it.amount ?: 0 } ?: 0
    }

    Integer quantity() {
        entries.sum { Entry entry ->
            if (entry instanceof InventoryEntry) {
                return entry.quantity ?: 0
            } else if (entry instanceof AdjusmentEntry) {
                return (-entry.adjustedEntry.quantity) ?: 0
            }
            0
        } ?: 0
    }

}

This is the source code for SparepartExpense.groovy:


package domain

import ...

@DomainModel @Entity @Canonical(excludes = "sparepart")
class SparepartExpense extends Account {

    @OneToOne
    Sparepart sparepart

    @Override
    BigDecimal balance() {
        entries.sum { it.amount ?: 0 } ?: 0
    }

}

This is the source code for SparepartSales.groovy:

package domain

import ...

@DomainModel @Entity @Canonical(excludes = "sparepart")
class SparepartSales extends Account {

    @OneToOne
    Sparepart sparepart

    @Override
    BigDecimal balance() {
        entries.sum { Entry entry ->
            entry.amount ?: 0
        } ?: 0
    }

    BigDecimal grossProfit() {
        balance() - sparepart.expense.balance()
    }

}

To track inventory, I will use perpetual inventory system (from traditional accounting).   Unlike periodic inventory system, perpetual inventory provides real time data.   It doesn’t require closing entry, but it use three accounts: the inventory (SparepartInventory class), the sales (SparepartSales class), and cost of goods sold (SparepartExpense class).

An incoming spare part event will increase inventory account by its total purchase price.   A spare part sales event will increase sales account by its selling price.   Spare part sales event will also increase cost of goods sold account and decrease inventory account by the same amount.   In order to calculate the cost of goods sold, I use average cost (AVCO) method.   It is easier to implement AVCO than the other methods: First in First out (FIFO) and Last in First out (LIFO).  Finally, gross profit can be calculated by subtracting the balance of sales account with the balance of cost of goods sold account.

This is the test case for testing the average cost (AVCO) calculation method:

    void testGetAverageCost() {
        Sparepart sparepart = new Sparepart("SP001", "Sparepart 1", new BigDecimal("25000"))

        IncomingSparepartInvoice invoice = new IncomingSparepartInvoice(invoiceNumber: "INVOICE1",
                date: LocalDate.now(), supplierName: "SUPPLIER1")

        IncomingSparepartEvent event1 = new IncomingSparepartEvent(sparepart: sparepart, invoice: invoice,
                quantity: 10, unitPrice: new BigDecimal("23000"), account: sparepart.inventory,
                eventType: AccountingEventType.SPAREPART_INCOMING, whenNoticed: DateTime.now(), whenOccured: DateTime.now())
        event1.process()

        IncomingSparepartEvent event2 = new IncomingSparepartEvent(sparepart: sparepart, invoice: invoice,
                quantity: 30, unitPrice: new BigDecimal("22500"), account: sparepart.inventory,
                eventType: AccountingEventType.SPAREPART_INCOMING, whenNoticed: DateTime.now(), whenOccured: DateTime.now())
        event2.process()

        IncomingSparepartEvent event3 = new IncomingSparepartEvent(sparepart: sparepart, invoice: invoice,
                quantity: 5, unitPrice: new BigDecimal("23500"), account: sparepart.inventory,
                eventType: AccountingEventType.SPAREPART_INCOMING, whenNoticed: DateTime.now(), whenOccured: DateTime.now())
        event3.process()

        assertEquals(new BigDecimal("1022500"), sparepart.inventory.balance())
        assertEquals(45, sparepart.inventory.quantity())
        assertEquals(new BigDecimal("22722.2222222222"), sparepart.inventory.averageCost())
    }

The following picture shows how user can display accounting information on a spare part (better implementation should allow user to select period and to filter entries):

Displaying Inventory For Spare Part

Displaying Inventory For Spare Part

Displaying Sales For Spare Part

Displaying Sales For Spare Part

Spare part sales event (SparepartSalesEvent) is a work order event (WorkOrderEvent) because it will be generated during motor cycle repair. In a real case, customer can buy a spare part without ordering a motor cycle repair. The picture below shows the structure of SparepartSalesEvent:

The SparepartSalesEvent

The SparepartSalesEvent

This is the source code for SparepartSalesEvent.groovy:

package domain

import ...

@DomainModel @Entity @Canonical
class SparepartSalesEvent extends WorkOrderEvent {

    @NotNull @ManyToOne(cascade=CascadeType.MERGE)
    Sparepart sparepart

    @NotNull @Min(1l)
    Integer quantity

    @NotNull @Min(1l)
    BigDecimal amount

    @Override
    AccountingEventType getEventType() {
        AccountingEventType.SPAREPART_SALES
    }

    @Override
    void process() {

        BigDecimal costOfGoods = sparepart.inventory.averageCost().multiply(quantity)
        BigDecimal total = amount * quantity

        InventoryEntry inventoryEntry = new InventoryEntry(account: sparepart.inventory, date: whenOccured,
            entryType: EntryType.SPAREPART, event: this, amount: -costOfGoods, quantity: -quantity)
        sparepart.inventory.entries << inventoryEntry
        resultingEntries << inventoryEntry

        TransactionEntry expenseEntry = new TransactionEntry(account: sparepart.expense, date: whenOccured,
            entryType: EntryType.SPAREPART, event: this, amount: costOfGoods)
        sparepart.expense.entries << expenseEntry
        resultingEntries << expenseEntry

        TransactionEntry salesEntry = new TransactionEntry(account: sparepart.sales, date: whenOccured,
            entryType: EntryType.SPAREPART, event: this, amount: total)
        sparepart.sales.entries << salesEntry
        resultingEntries << salesEntry

        WorkOrderEntry workOrderEntry = new WorkOrderEntry(account: account, date: whenOccured,
            entryType: EntryType.SPAREPART, event: this, amount: total)
        account.entries << workOrderEntry
        resultingEntries << workOrderEntry

        super.process()
    }
}

Because SparepartSalesEvent is a WorkOrderEvent, it can be treated just like any other WorkOrderEvent, as shown in the following test case:

void testProcess() {
        // Sparepart Incoming
        Sparepart sparepart = new Sparepart("SP001", "Sparepart 1", new BigDecimal("25000"))

        IncomingSparepartInvoice invoice = new IncomingSparepartInvoice(invoiceNumber: "INVOICE1",
                date: LocalDate.now(), supplierName: "SUPPLIER1")

        IncomingSparepartEvent event1 = new IncomingSparepartEvent(sparepart: sparepart, invoice: invoice,
                quantity: 10, unitPrice: new BigDecimal("23000"), account: sparepart.inventory,
                eventType: AccountingEventType.SPAREPART_INCOMING, whenNoticed: DateTime.now(), whenOccured: DateTime.now())
        event1.process()

        IncomingSparepartEvent event2 = new IncomingSparepartEvent(sparepart: sparepart, invoice: invoice,
                quantity: 30, unitPrice: new BigDecimal("22500"), account: sparepart.inventory,
                eventType: AccountingEventType.SPAREPART_INCOMING, whenNoticed: DateTime.now(), whenOccured: DateTime.now())
        event2.process()

        IncomingSparepartEvent event3 = new IncomingSparepartEvent(sparepart: sparepart, invoice: invoice,
                quantity: 5, unitPrice: new BigDecimal("23500"), account: sparepart.inventory,
                eventType: AccountingEventType.SPAREPART_INCOMING, whenNoticed: DateTime.now(), whenOccured: DateTime.now())
        event3.process()

        assertEquals(new BigDecimal("1022500"), sparepart.inventory.balance())
        assertEquals(45, sparepart.inventory.quantity())
        assertEquals(new BigDecimal("22722.2222222222"), sparepart.inventory.averageCost())

        // Work Order
        Customer customer = new Customer("Solid Snake", "X 123 YZ")

        WorkType quickService = new WorkType("QS", "Quick Service")

        WorkTypeCostPR normalPrice = new WorkTypeCostPR("Normal Price", EntryType.SERVICE, AccountingEventType.PAYMENT, [:])
        normalPrice.priceList[quickService] = new BigDecimal("12000")

        Pricing normalPricing = new Pricing(LocalDate.now().minusMonths(1), true, "Normal Pricing",
            [normalPrice])

        WorkOrder workOrder = new WorkOrder(orderNumber: "W001", customer: customer, pricing: normalPricing, workType: quickService)

        // Test Step Register
        WorkOrderEvent registerEvent = new WorkOrderEvent(account: workOrder, eventType: AccountingEventType.REGISTER,
            whenNoticed: DateTime.now(), whenOccured: DateTime.now())
        registerEvent.process()
        assertEquals(0, registerEvent.resultingEntries.size())
        assertEquals(0, workOrder.entries.size())
        assertEquals(1, workOrder.events.size())
        assertEquals(registerEvent, workOrder.events[0])

        // Test Step Working
        WorkOrderEvent workingEvent = new WorkOrderEvent(account: workOrder, eventType: AccountingEventType.WORKING,
            whenNoticed: DateTime.now(), whenOccured: DateTime.now())
        workingEvent.process()
        assertEquals(0, workingEvent.resultingEntries.size())
        assertEquals(0, workOrder.entries.size())
        assertEquals(2, workOrder.events.size())
        assertEquals(registerEvent, workOrder.events[0])
        assertEquals(workingEvent, workOrder.events[1])

        // Test Step Sparepart Sales
        SparepartSalesEvent sparepartSalesEvent= new SparepartSalesEvent(account: workOrder, whenNoticed: DateTime.now(),
            whenOccured: DateTime.now(), sparepart: sparepart, amount: new BigDecimal("27000"), quantity: 2)
        sparepartSalesEvent.process()

        assertEquals(4, sparepartSalesEvent.resultingEntries.size())

        assertEquals(43 , sparepart.inventory.quantity())
        assertEquals(new BigDecimal("977055.5555555556"), sparepart.inventory.balance())
        assertEquals(new BigDecimal("45444.4444444444"), sparepart.expense.balance())
        assertEquals(new BigDecimal("54000"), sparepart.sales.balance())

        assertEquals(1, workOrder.entries.size())
        assertEquals(new BigDecimal("54000"), workOrder.entries[0].amount)

        assertEquals(3, workOrder.events.size())
        assertEquals(registerEvent, workOrder.events[0])
        assertEquals(workingEvent, workOrder.events[1])
        assertEquals(sparepartSalesEvent, workOrder.events[2])

        // Test Step Finish
        WorkOrderEvent finishEvent = new WorkOrderEvent(account: workOrder, eventType: AccountingEventType.FINISH,
            whenNoticed: DateTime.now(), whenOccured: DateTime.now())
        finishEvent.process()
        assertEquals(0, finishEvent .resultingEntries.size())
        assertEquals(1, workOrder.entries.size())
        assertEquals(4, workOrder.events.size())
        assertEquals(registerEvent, workOrder.events[0])
        assertEquals(workingEvent, workOrder.events[1])
        assertEquals(sparepartSalesEvent, workOrder.events[2])
        assertEquals(finishEvent, workOrder.events[3])

        // Test Payment
        WorkOrderEvent paymentEvent = new WorkOrderEvent(account: workOrder, eventType: AccountingEventType.PAYMENT,
                whenNoticed: DateTime.now(), whenOccured: DateTime.now())
        paymentEvent.process()
        assertEquals(1, paymentEvent.resultingEntries.size())
        assertEquals(new BigDecimal("12000"), paymentEvent.resultingEntries[0].amount)

        assertEquals(2, workOrder.entries.size())
        assertEquals(new BigDecimal("54000"), workOrder.entries[0].amount)
        assertEquals(new BigDecimal("12000"), workOrder.entries[1].amount)
        assertEquals(new BigDecimal("66000"), workOrder.balance())

        assertEquals(5, workOrder.events.size())
        assertEquals(registerEvent, workOrder.events[0])
        assertEquals(workingEvent, workOrder.events[1])
        assertEquals(sparepartSalesEvent, workOrder.events[2])
        assertEquals(finishEvent, workOrder.events[3])
        assertEquals(paymentEvent, workOrder.events[4])
    }

Once I make sure all tests passed, I can start developing the MVC (presentation layer) as shown in the following picture:

Add A Sparepart Sale In Work Order

Add A Sparepart Sale In Work Order

Sparepart Sales Event Will Be Displayed in The Event Lists

Sparepart Sales Event Will Be Displayed in The Event Lists

Spare Part Sales Amount Will Be Added To Work Order Balance

Spare Part Sales Amount Will Be Added To Work Order Balance

The complete source code can be found at the following link: https://docs.google.com/file/d/0B-_rVDnaVRCbaWx0aGRkamJWQm8/edit?usp=sharing.  It requires Griffon 1.2 and simple-jpa 0.4.1 plugin.

Implementing the Accounting Analysis Pattern

In this post, I will implement accounting analysis pattern.  While this pattern is called “accounting”, it isn’t strictly limited to traditional accounting systems, but can also be used in inventory, utility billing, payroll, and others.   The basic idea of accounting pattern is events will be translated by posting rules into entries which are stored in an account.   More information about accounting analysis pattern can be found at http://martinfowler.com/apsupp/accounting.pdf.

I will use a hypothetical motorcycle service station (in Indonesia, this is called “bengkel motor”) work orders processing as an example.   Work order is created when customer requests his motorcycle to be repaired.   This work order will then join a queue, waiting to be processed by a free mechanic.   While repairing the motorcycle, mechanic sometimes need to request replacement for broken spare part.   Those requests should be added to the work order.

Accounting pattern is quite abstract, so implementation in this post is based on my perception and fine-tuned for the case above.  I see a work order as an account.   Its entries consist of service cost, spare part price, amount of discount, registration fee and others.  For the sake of simplicity, I will exclude spare part replacements in this implementation.   The balance of the work order account is the amount that should be paid by customer.   Entries are created based on work order’s events.   Examples of such events are repair request, spare part replacement request, reparation complete, and payment.   These events usually don’t have pricing information, so posting rules will translate these events into entries which have an amount.

I’m using Griffon 1.2 and simple-jpa 0.4.1 for the implementation. The complete source code for this post can be found at https://docs.google.com/file/d/0B-_rVDnaVRCbb2pKRmk4djctMTQ/edit?usp=sharing.

I will start by defining abstract classes that represent the fundamental classes in account pattern, as shown below:

Abstract Classes In Accounting Pattern

Abstract Classes In Accounting Pattern

To keep it simple, I represent AccountEventType and EntryType as an enumeration; by the way, simple-jpa 0.4.1 scaffolding will generate the enumeration as a combo box.   In a real world application, they are often represented by entities.

Here is the source code of Account.groovy:

package domain

import ...

@DomainModel
@Entity
@Canonical
abstract class Account {

    @OneToMany(cascade=CascadeType.ALL, orphanRemoval=true, mappedBy="account")
    List<Entry> entries = []

    @OneToMany(cascade=CascadeType.ALL, orphanRemoval=true, mappedBy="account")
    List<AccountingEvent> events = []

    abstract Number balance()

}

I will start from Account implementation as shown below:

The Account

The Account

Every WorkOrder has a WorkType, Customer and Pricing. WorkType and Customer are obvious, but why there is a Pricing class? The purpose of Pricing is to store price-related information including promotions and discounts. They can change from time to time, so it is better if I have the history records.
This is the source code of WorkType.groovy:

package domain

import ...

@DomainModel
@Entity
@Canonical
class WorkType {

    @NotEmpty @Size(min=2, max=2)
    String codeName

    @NotEmpty @Size(min=3, max=50)
    String name

    String toString() {
        "$codeName - $name"
    }

}

The picture below is the basic CRUD screen for WorkType class:

WorkType screen

WorkType screen

This is the source code of Customer.groovy:

package domain

import ...

@DomainModel @Entity @Canonical
class Customer {

    @NotEmpty @Size(min=3, max=50)
    String name

    @NotEmpty @Size(min=6, max=15)
    String plateNumber

}

Customer class in real world application usually has more attributes.   The screen for Customer is a simple CRUD, but with one additional button that allow user to create a new WorkOrderEvent per customer.

Customer Screen

Customer Screen

The last domain class referenced by WorkOrder is Pricing which is a container for one or more PostingRule instances.  In accounting pattern, PostingRule will create Entry instances based on an AccountingEvent.  This is the source code of PostingRule.groovy:

package domain

import ...

@DomainModel @Entity @Canonical
abstract class PostingRule {

    @NotBlank @Size(min=3, max=50)
    String name

    @NotNull @Enumerated
    EntryType type

    @NotNull @Enumerated
    AccountingEventType targetEventType

    abstract void process(AccountingEvent event)

}

AccountingEvent will invoke PostingRule’s process() method to generate one or more instances of Entry.  User can configure event type (AccountingEventType) that will trigger the execution of PostingRule.  For example, PostingRule for calculating down payment should be executed on AccountingEventType.REGISTRATION. PostingRule for calculating discount should be executed on the final event (AccountingEventType.PAYMENT), otherwise the discount won’t be calculated based on total amount.

I will create two subclasses from PostingRule as shown in the picture below:

The PostingRule

The PostingRule

This is the source code of WorkTypeCostPR.groovy:

package domain

import ...

@DomainModel @Entity @Canonical
class WorkTypeCostPR extends PostingRule {

    @NotNull @ElementCollection
    Map<WorkType, BigDecimal> priceList = [:]

    WorkTypeCostPR() {}

    WorkTypeCostPR(String name, EntryType type, 
             AccountingEventType targetEventType, 
             Map<WorkType, BigDecimal> priceList) {
        super(name, type, targetEventType)
        this.priceList = priceList
    }

    @Override
    void process(AccountingEvent event) {
        if (event instanceof WorkOrderEvent) {
            WorkOrderEntry entry = new WorkOrderEntry(date: event.whenOccured, postingRule: this,
                amount: priceList[event.account.workType], entryType: type, event: event, account: event.account)
            event.account.entries << entry
            event.resultingEntries << entry
        }
    }
}

WorkTypeCostPR has a Map which stores price for each work type. When an instance of WorkTypeCostPR is invoked by AccountingEvent, it will generate an Entry instance which contains amount (price) based on its priceList Map’s value. The picture below shows an example configuration for an instance of WorkTypeCostPR:

WorkTypeCostPR View

WorkTypeCostPR View

The next subclass is FormulaPR.groovy:

package domain

import ...

@DomainModel @Entity @Canonical
class FormulaPR extends PostingRule {

    @NotEmpty
    String formula

    FormulaPR() {}

    FormulaPR(String name, EntryType type, 
           AccountingEventType targetEventType, String formula) {
        super(name, type, targetEventType)
        this.formula = formula
    }

    public void process(AccountingEvent event) {
        if (event instanceof WorkOrderEvent) {
            def amount = Eval.x(event.account, formula)
            WorkOrderEntry entry = new WorkOrderEntry(date: event.whenOccured, postingRule: this,
                amount: amount, entryType: type, event: event, account: event.account)
            event.account.entries << entry
            event.resultingEntries << entry
        }
    }

}

FormulaPR is a PostingRule that has a Groovy expression, as shown in the picture below:

FormulaPR View

FormulaPR View

I’m using Eval from Groovy to execute the expression.  Variable x in the expression will be substituted by the affected Account (in this case, the Account is an instance of WorkOrder).  The expression should return a Number, which will be stored as an Entry of the WorkOrder.  Groovy also has nice features that make creating Domain Specific Language (DSL) painless.  Developing a DSL for posting rule will take more time, but it is easy to use for non-technical user who don’t understand (or hate) programming.

Now I can add these rules in Pricing, as shown in the picture below:

Pricing View

Pricing View

While I can have more than one Pricing objects, only one Pricing object should be marked as active at a time.  All new Entry objects for WorkOrder will be calculated based on the active Pricing.

It is time to create WorkOrder.groovy.  This is the source code:

package domain

import ...

@DomainModel @Entity @Canonical(excludes = "events, entries")
class WorkOrder extends Account {

    @NotEmpty @Size(min=5, max=5)
    String orderNumber

    @NotNull @ManyToOne
    Customer customer

    @NotNull @ManyToOne
    Pricing pricing

    @NotNull @ManyToOne
    WorkType workType

    BigDecimal balance() {
        entries.sum { Entry entry ->
            entry.getAmount()
        } ?: 0
    }

    Collection<WorkOrderEvent> getEvent(AccountingEventType eventType) {
        events.findAll { AccountingEvent event ->
            event.eventType == eventType && event.deleted != 'Y'
        }
    }


}

WorkOrder is a subclass of Account which has one or more Entry objects in List (order is important).  In the case of WorkOrder, its Entry objects are instance of WorkOrderEntry.

The Entry

The Entry

This is the source code for Entry.groovy:

package domain

import ...

@DomainModel
@Entity
@Canonical
abstract class Entry {

    @NotNull @Type(type="org.jadira.usertype.dateandtime.joda.PersistentDateTime")
    DateTime date

    @NotNull @ManyToOne
    AccountingEvent event

    @NotNull @ManyToOne
    Account account

    @NotNull @OneToOne
    PostingRule postingRule

    @NotNull @Enumerated
    EntryType entryType

    abstract Number getAmount()

}

And this is the source code for WorkOrderEntry.groovy:

package domain

import ...

@DomainModel @Entity @Canonical
class WorkOrderEntry extends Entry {

    @NotNull
    BigDecimal amount

}

WorkOrderEntry contains only a BigDecimal attribute that store an amount.  User can’t create or edit WorkOrderEntry;  it will be created automatically from a WorkOrderEvent based on active Pricing.  Once created, it can’t be changed.

Next, I will create a subclass of AccountingEvent as shown in the picture below:

The AccountingEvent

The AccountingEvent

This is the source code of AccountingEvent.groovy:

package domain

import ...

@DomainModel @Entity @Canonical
abstract class AccountingEvent {

    @NotNull @Enumerated
    AccountingEventType eventType

    @NotNull @Past @Type(type="org.jadira.usertype.dateandtime.joda.PersistentDateTime")
    DateTime whenNoticed

    @NotNull @Past @Type(type="org.jadira.usertype.dateandtime.joda.PersistentDateTime")
    DateTime whenOccured

    @NotNull @ManyToOne
    Account account

    @OneToMany(cascade=CascadeType.ALL, orphanRemoval=true, mappedBy="event")
    Set<Entry> resultingEntries = new HashSet<>()

    abstract void process()

}

Attribute whenNoticed is the time when user entered the event, while whenOccured is the actual time when it happens. This allow user to enter an event that occurred in the past.

This is the source code of WorkOrderEvent.groovy:

package domain

import ...

@DomainModel @Entity @Canonical
class WorkOrderEvent extends AccountingEvent {

    @Override
    void process() {
        account.pricing.postingRules.each { PostingRule pr ->
            if (pr.targetEventType == eventType) {
                pr.process(this)
            }
        }
        account.events << this
    }

}

WorkOrderEvent in this simple implementation doesn’t have additional attributes; what it does is only overrides process() from its parent class.

To create a new WorkOrderEvent, user will select a Customer and click on “New Work Order” button as show in the picture below:

Creating a New WorkOrder

Creating a New WorkOrder

This will create a new WorkOrderEvent whose type is AccountingEventType.REGISTER.   The event will be associated with a new WorkOrder.   User can find more information in WorkOrder’s view:

WorkOrder view

WorkOrder view

To create additional events, user can click on one of “Order in Progress”, “Order Has Done”, and “Payment”.

Trigger an Event

Trigger an Event

In real world application, these buttons often have their own view.   AccountingEvent is not as simple as in this implementation; it will have more subclass in real application.   For example, “Working” event should have included a mechanic (person who handles the work order).   “Payment” event should have included cashier’s name and payment information.

To display list of events for selected WorkOrder, user can click on “Show Events” button:

Display Events For a WorkOrder

Display Events For a WorkOrder

To display list of entries for selected WorkOrder, user can click on “Show Entries” button:

Display Entries For a WorkOrder

Display Entries For a WorkOrder

Events and entries are immutable (can’t be modified).   Even when user changes the value of active pricing and posting rules, it will not affect saved entries.   This is always the desirable behavior; rule changes (including prices and discounts) should only affect new entries and leave the old entries intact.
What if user made a mistake when entering an event?   I will use reversal adjustment pattern to correct the event and its entries.  To do that, I need to add a new AccountingEvent subclass:

The RevokedEvent

The RevokedEvent

This is the source code for RevokedEvent.groovy:

package domain

import ...

@DomainModel
@Entity
@Canonical
class RevokedEvent extends AccountingEvent{

    @NotNull @OneToOne
    AccountingEvent originalEvent

    public RevokedEvent() {}

    public RevokedEvent(AccountingEvent originalEvent) {
        this.account = originalEvent.account
        this.eventType = originalEvent.eventType
        this.whenNoticed = DateTime.now()
        this.whenOccured = originalEvent.whenOccured
        this.originalEvent = originalEvent
    }

    @Override
    void process() {
        originalEvent.resultingEntries.each { entry ->
            AdjusmentEntry adjusmentEntry = new AdjusmentEntry(entry)
            originalEvent.account.entries << adjusmentEntry
            resultingEntries << adjusmentEntry
        }
        originalEvent.deleted = 'Y'
    }
}

RevokedEvent has a reference to the revoked (or deleted) event. The RevokedEvent will cause WorkOrder (our Account) to have AdjusmentEntry which zeroes the revoked event’s Entry:

The AdjusmentEntry

The AdjusmentEntry

This is the source code for AdjusmentEntry.groovy:


package domain

import ...

@DomainModel
@Entity
@Canonical
class AdjusmentEntry extends Entry {

    public AdjusmentEntry() {}

    public AdjusmentEntry(Entry adjustedEntry) {
        this.account = adjustedEntry.account
        this.date = adjustedEntry.date
        this.entryType = adjustedEntry.entryType
        this.event = adjustedEntry.event
        this.postingRule = adjustedEntry.postingRule
        this.adjustedEntry = adjustedEntry
    }

    @NotNull @OneToOne
    Entry adjustedEntry

    @Override
    Number getAmount() {
        -adjustedEntry.amount
    }
}

The picture below show the result of correcting an event:

Corrected Event

Corrected Event

Corrected Entries

Corrected Entries

A better implementation will allow user to filter out the revoked and adjustment entries.  To provide more verbose auditing information, the system should also record the user who performs correction.
Up to this point, I’ve implemented a working application based on Martin Fowler’s accounting analysis pattern.  This is the complete class diagram for my implementation:

The Class Diagram

The Class Diagram

An Example of Analysis Patterns: Observation

In this post, I will create an application that implements Martin Fowler’s observation analysis pattern as described in his book, Analysis Patterns: Reusable Object Models.   The book is a great practical book that reflects its author experience in domain modeling.   The only problem of this book is it uses its own custom diagram rather than UML diagram (by that time, UML hasn’t been standardized yet).   It will be great if there is a second edition of the book which uses UML class diagram or object diagram to explain domain models.   I will definitely read it 😉

What is analysis pattern?   The book describes analysis patterns as “groups of concepts that represent a common construction in business modeling”.   It is something that helps me to understand business world from software engineering perspective.   Why do I care about analysis pattern?  In small team,  software developers are not only creating source code;  sometimes,  they need to be (or work closely with) domain experts.

For example, I will need to implement this simple requirement:

“The program should records motorcycle’s measurements: mileage (the distance travelled in kilometers), oil level (over-full, ok, or low), etc. Program will also store the date of measurement. Not all measurements will be performed on the same day. User should be able to add new measurement types later (for example, he may want to measure motorcycle’s emission in the future).”

The requirement above can be solved by using observation pattern.   In observation analysis pattern, a quantitative value (such as mileage) is a ‘Measurement’ and a qualitative value (such as oil level) is a ‘Category Observation’.   Both of them have an association with a ‘Phenomenon Type’.

Here is the class diagram that I’m going to implement:

Domain Classes

Domain Classes

Below is a UML object diagram that depicts an example run time state of the class diagram:

Object diagrams

Object diagrams

I will use Griffon and simple-jpa to quickly create an implementation for the design above.

This is the content of Motorcycle.groovy:

package domain

import ...

@DomainModel @Entity @Canonical
class Motorcycle {

    @Size(min=6, max=10)
    String plateNumber

    @OneToMany(cascade=CascadeType.ALL, orphanRemoval=true)
    List<Observation> observations = []

}

Observation class has a DateTime attribute to store the date and time when the observation is performed. If the method by which the observation was performed is required, then a protocol attribute can be added to this class. The following is the content of Observation.groovy:

package domain

import ...

@DomainModel @Entity @Canonical
abstract class Observation {

    @NotNull @Type(type="org.jadira.usertype.dateandtime.joda.PersistentDateTime")
    DateTime dateTime

}

The following is the content of Measurement.groovy:


package domain

import ...

@DomainModel @Entity @Canonical
class Measurement extends Observation {

    @NotNull
    Double quantity

    @NotNull @ManyToOne
    PhenomenonType type

    @Override
    String toString() {
        "${type.name}: ${NumberFormat.getNumberInstance().format(quantity)}"
    }
}

This is the content of CategoryObservation.groovy:

package domain

import ...

@DomainModel @Entity @Canonical
class CategoryObservation extends Observation {

    @NotNull @ManyToOne
    Phenomenon phenomenon

    @Override
    String toString() {
        "${phenomenon.type.name} - ${phenomenon.name}"
    }
}

This is the content of PhenomenonType.groovy:

package domain

import ...

@DomainModel @Entity @Canonical
class PhenomenonType {

    @NotEmpty @Size(min=2, max=50)
    String name

}

The following is the content of Phenomenon.groovy:

package domain

import ...

@DomainModel @Entity @Canonical
class Phenomenon {

    @NotEmpty @Size(min=2, max=50)
    String name

    @NotNull @ManyToOne
    PhenomenonType type

}

The next step is generating MVC based on those domain classes.   simple-jpa will create individual menu per domain class, but user will not need to access all of them.   There should be only menus for Motorcycle, Phenomenon, and PhenomenonType.   I will need to remove unneeded menus from MainGroupView.groovy; their corresponding MVCGroups can also be safely deleted.

Main Menu

Main Menu

Apart from template renderer configuration, “Phenomenon Type” screen is already working.   User can add, edit, or delete a PhenomenonType from this screen.

PhenomenonType Screen

PhenomenonType Screen

“Phenomenon” screen can be considered done too. User can add, edit, or delete a Phenomenon from this screen. Whenever they create a new Phenomenon, they should select one of the PhenomenonType objects.

Phenomenon Screen

Phenomenon Screen

Now, let see “Motorcycle” screen:

Motorcycle Screen

Motorcycle Screen

This screen consists of two MVCGroups: Motorcycle and ObservationAsChild.   Motorcycle is the one shown in the picture above.   ObservationAsChild will be displayed when the user click on the ‘Observations’ button.   Observation is an abstract class whose children are Measurement and CategoryObservation, so user will intuitively expect a form to input both of kinds in ObservationAsChild.   Unfortunately, simple-jpa is not smart enough to create MVCGroup that will allow entering both Measurement and CategoryObservation.   I will need to manually change the code for ObservationAsChild MVCGroup.

The following is the new content of ObservationAsChildView.groovy after modification:

package project

import ...

application(title: 'Observation',
        preferredSize: [520, 340],
        pack: true,
        locationByPlatform: true,
        iconImage: imageIcon('/griffon-icon-48x48.png').image,
        iconImages: [imageIcon('/griffon-icon-48x48.png').image,
                imageIcon('/griffon-icon-32x32.png').image,
                imageIcon('/griffon-icon-16x16.png').image]) {

    panel(id: 'mainPanel') {
        borderLayout()

        panel(constraints: PAGE_START) {
            flowLayout(alignment: FlowLayout.LEADING)
            label("Type: ")
            JComboBox cboTypeFilter = comboBox(model: model.typeFilter, templateRenderer: '${(value instanceof String)?value:value.name}')
            cboTypeFilter.addItemListener(model.observationTypeMatcherEditor)
        }

        panel(constraints: CENTER) {
            borderLayout()
            panel(constraints: PAGE_START, layout: new FlowLayout(FlowLayout.LEADING)) {
                label(text: bind('searchMessage', source: model))
            }
            scrollPane(constraints: CENTER) {
                table(rowSelectionAllowed: true, id: 'table') {
                    eventTableModel(list: model.observationFilterList,
                        columnNames: ['Date Time', 'Description'],
                        columnValues: ["\${value.dateTime.toString('dd-MM-yyyy hh:mm')}",'${value}'])
                    table.selectionModel = model.observationSelection
                }
            }
        }

        panel(id: "form", layout: new MigLayout('hidemode 2', '[right][left][left,grow]',''), constraints: PAGE_END, focusCycleRoot: true) {

            group = buttonGroup()
            label('Type:')
            radioButton(text: "Measurement", buttonGroup: group, constraints: 'split 2',
                selected: bind('measurementType', target: model, mutual: true))
            radioButton(text: "Category Observation", buttonGroup: group, constraints: 'skip, wrap',
                selected: bind('categoryObservationType', target: model, mutual: true))

            label('Date Time:')
	    dateTimePicker(id: 'dateTime', dateTime: bind('dateTime', target: model, mutual: true), errorPath: 'dateTime')
	    errorLabel(path: 'dateTime', constraints: 'wrap')

            label('Quantity:', visible: bind('measurementType', source: model))
            numberTextField(id: 'quantity', columns: 20, bindTo: 'quantity', errorPath: 'quantity',
                visible: bind('measurementType', source: model))
            errorLabel(path: 'quantity', constraints: 'wrap', visible: bind('measurementType', source: model))

            label('Type:', visible: bind('measurementType', source: model))
            comboBox(model: model.type, templateRenderer: '${value.name}', errorPath: 'type',
                visible: bind('measurementType', source: model))
            errorLabel(path: 'type', constraints: 'wrap', visible: bind('measurementType', source: model))

            label('Phenomenon:', visible: bind('categoryObservationType', source: model))
            comboBox(model: model.phenomenon, templateRenderer: '${value.type.name} - ${value.name}', errorPath: 'phenomenon',
                visible: bind('categoryObservationType', source: model))
            errorLabel(path: 'phenomenon', constraints: 'wrap',
                visible: bind('categoryObservationType', source: model))

            panel(constraints: 'span, growx, wrap') {
                flowLayout(alignment: FlowLayout.LEADING)
                button(app.getMessage("simplejpa.dialog.update.button"), actionPerformed: {
                    if (!model.observationSelection.selectionEmpty) {
                        if (JOptionPane.showConfirmDialog(mainPanel, app.getMessage("simplejpa.dialog.update.message"),
                            app.getMessage("simplejpa.dialog.update.title"), JOptionPane.YES_NO_OPTION, JOptionPane.WARNING_MESSAGE) != JOptionPane.YES_OPTION) {
                                return
                        }
                    }
                    controller.save()
                    form.getFocusTraversalPolicy().getFirstComponent(form).requestFocusInWindow()
                })
                button(app.getMessage("simplejpa.dialog.cancel.button"), visible: bind (source: model.observationSelection,
                    sourceEvent: 'valueChanged', sourceValue: {!model.observationSelection.selectionEmpty}),actionPerformed: model.clear)
                button(app.getMessage("simplejpa.dialog.delete.button"), visible: bind (source: model.observationSelection,
                    sourceEvent: 'valueChanged', sourceValue: {!model.observationSelection.selectionEmpty}), actionPerformed: {
                        if (JOptionPane.showConfirmDialog(mainPanel, app.getMessage("simplejpa.dialog.delete.message"),
                            app.getMessage("simplejpa.dialog.delete.title"), JOptionPane.YES_NO_OPTION, JOptionPane.WARNING_MESSAGE) == JOptionPane.YES_OPTION) {
                                controller.delete()
                        }
                })
                button(app.getMessage("simplejpa.dialog.close.button"), actionPerformed: {
                    SwingUtilities.getWindowAncestor(mainPanel)?.dispose()
                })
            }
        }
    }
}

The following is the new content of ObservationAsChildController.groovy after modification:

package project

import ...

@SimpleJpaTransaction
class ObservationAsChildController {

    def model
    def view

    void mvcGroupInit(Map args) {
        args.'parentList'.each { model.observationList << it }
        listAll()
    }

    void mvcGroupDestroy() {
        destroyEntityManager()
    }

    def listAll = {
        execInsideUIAsync {
            model.typeList.clear()
            model.typeFilterList.clear()
            model.phenomenonList.clear()
        }

        List typeResult = findAllPhenomenonType()
        List phenomenonResult = findAllPhenomenon()

        execInsideUIAsync {
            model.phenomenonList.addAll(phenomenonResult)
            model.typeList.addAll(typeResult)
            model.typeFilterList.add("All")
            model.typeFilterList.addAll(typeResult)
            model.typeFilter.setSelectedItem("All")
        }
    }

    def save = {
        Observation observation
        if (model.measurementType) {
            observation = new Measurement(dateTime: model.dateTime, quantity: model.quantity, type: model.type.selectedItem)
        } else if (model.categoryObservationType) {
            observation = new CategoryObservation(dateTime: model.dateTime, phenomenon: model.phenomenon.selectedItem)
        } else {
            throw new Exception("Unknown observation!")
        }
        if (!validate(observation)) return_failed()

        if (model.observationSelection.selectionEmpty) {
            // Insert operation
            execInsideUIAsync { model.observationList << observation }
        } else {
            // Update operation
            execInsideUISync { model.observationList.set(model.observationSelection.minSelectionIndex, observation) }
        }
        execInsideUIAsync { model.clear() }
    }

    def delete = {
        Observation observation = model.observationSelection.selected[0]
        execInsideUIAsync { model.observationList.remove(observation) }
    }

} 

The following is the new content of ObservationAsChildModel.groovy after modification:

package project

import ...

class ObservationAsChildModel {

    def view

    @Bindable Long id
    @Bindable DateTime dateTime
    @Bindable boolean measurementType
    @Bindable Double quantity
    @Bindable boolean categoryObservationType

    TypeMatcher observationTypeMatcher = new TypeMatcher()
    MatcherEditor observationTypeMatcherEditor = new TypeMatcherEditor()

    BasicEventList<Observation> observationList = new BasicEventList<>()
    FilterList<Observation> observationFilterList = new FilterList<>(observationList, observationTypeMatcherEditor)
    DefaultEventSelectionModel<Observation> observationSelection =
        GlazedListsSwing.eventSelectionModelWithThreadProxyList(observationFilterList)

    BasicEventList<PhenomenonType> typeList = new BasicEventList<>()
    @Bindable DefaultEventComboBoxModel<PhenomenonType> type =
        GlazedListsSwing.eventComboBoxModelWithThreadProxyList(typeList)

    BasicEventList<PhenomenonType> typeFilterList = new BasicEventList<>()
    @Bindable DefaultEventComboBoxModel<PhenomenonType> typeFilter =
        GlazedListsSwing.eventComboBoxModelWithThreadProxyList(typeFilterList)


    BasicEventList<Phenomenon> phenomenonList = new BasicEventList<>()
    @Bindable DefaultEventComboBoxModel<Phenomenon> phenomenon =
        GlazedListsSwing.eventComboBoxModelWithThreadProxyList(phenomenonList)

    public ObservationAsChildModel() {

        observationSelection.valueChanged = { ListSelectionEvent event ->
            if (observationSelection.isSelectionEmpty()) {
                clear()
            } else {

                Observation selected = observationSelection.selected[0]
                errors.clear()
                id = selected.id
		dateTime = selected.dateTime

                if (selected instanceof Measurement) {
                    measurementType = true
                    quantity = selected.quantity
                    type.selectedItem = selected.type
                } else if (selected instanceof CategoryObservation) {
                    categoryObservationType = true
                    phenomenon.selectedItem = selected.phenomenon
                }

            }
        }
    }

    def clear = {
        id = null
        dateTime = null

        errors.clear()
        quantity = null
        type.selectedItem = null
        phenomenon.selectedItem = null
        observationSelection.clearSelection()
        measurementType = false
        categoryObservationType = false
        view.group.clearSelection()
    }

    class TypeMatcher implements Matcher {

        @Override
        boolean matches(Object o) {
            if (typeFilter.selectedItem == "All") return true
            if (!o) return false
            if (observationList.isEmpty()) return true
            if (!typeFilter.selectedItem) return true

            if (o instanceof Measurement) {
                return ((Measurement) o).type == typeFilter.selectedItem
            } else if (o instanceof CategoryObservation) {
                return ((CategoryObservation) o).phenomenon.type == typeFilter.selectedItem
            }
        }

    }

    class TypeMatcherEditor extends AbstractMatcherEditor implements ItemListener {

        @Override
        void itemStateChanged(ItemEvent e) {
            if (e.stateChange==ItemEvent.SELECTED) fireChanged(observationTypeMatcher)
        }

    }

}

ObservationAsChildView now has JRadioButton to indicate what kind of observation that will be saved.  If user clicks on “Measurement” JRadioButton, he will be able to add a quantity and select a PhenomenonType.   If user clicks on “Category Observation” JRadioButton, he will be able to select a Phenomenon (qualitative value).

Add Observations With JRadioButton

Add Observations With JRadioButton

Clicking a JRadioButton will display correspondings components

Clicking a JRadioButton will display correspondings components

In a real world application, observations will often be performed periodically, so there will be a lot of observations for a Motorcycle.   On some occasions, user may want to quickly display certain PhenomenonType for a Motorcycle.   That’s the reason why I use FilterList (from GlazedLists) for JTable in ObservationAsChildView.   By using FilterList, I can filter table’s contents based on a Matcher.

PhenomenonType filter

PhenomenonType filter

Selecting "Mileage" will only display mileage's observation

Selecting “Mileage” will only display mileage’s observation

Selecting "Oil Level" will only display oil level's observations

Selecting “Oil Level” will only display oil level’s observations

Phenomenon and PhenomenonType are knowledge level objects which are used by the others (operational level objects).   This design will allow user to easily add new measurement types later.   For example, I will add a new PhenomenonType called emission as shown in the picture below:

Add new PhenomenonType

Add new PhenomenonType

This implementation of observation analysis pattern allows me to add the new emission measurement to all motorcycles without changing any source code:

New PhenomenonType is ready to use

New PhenomenonType is ready to use