DemoSeedRunner.kt 10.8 KB
package org.vibeerp.demo

import org.slf4j.LoggerFactory
import org.springframework.boot.CommandLineRunner
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional
import org.vibeerp.pbc.catalog.application.CreateItemCommand
import org.vibeerp.pbc.catalog.application.ItemService
import org.vibeerp.pbc.catalog.domain.ItemType
import org.vibeerp.pbc.inventory.application.CreateLocationCommand
import org.vibeerp.pbc.inventory.application.LocationService
import org.vibeerp.pbc.inventory.application.StockBalanceService
import org.vibeerp.pbc.inventory.domain.LocationType
import org.vibeerp.pbc.orders.purchase.application.CreatePurchaseOrderCommand
import org.vibeerp.pbc.orders.purchase.application.PurchaseOrderLineCommand
import org.vibeerp.pbc.orders.purchase.application.PurchaseOrderService
import org.vibeerp.pbc.orders.sales.application.CreateSalesOrderCommand
import org.vibeerp.pbc.orders.sales.application.SalesOrderLineCommand
import org.vibeerp.pbc.orders.sales.application.SalesOrderService
import org.vibeerp.pbc.partners.application.CreatePartnerCommand
import org.vibeerp.pbc.partners.application.PartnerService
import org.vibeerp.pbc.partners.domain.PartnerType
import org.vibeerp.platform.persistence.security.PrincipalContext
import java.math.BigDecimal
import java.time.LocalDate

/**
 * One-shot demo data seeder, gated behind `vibeerp.demo.seed=true`.
 *
 * **Why this exists.** Out-of-the-box, vibe_erp boots against an
 * empty Postgres and the SPA dashboard shows zeros for every PBC.
 * Onboarding a new operator (or running a tomorrow-morning demo)
 * needs a couple of minutes of clicking to create items,
 * locations, partners, and a starting inventory before the
 * interesting screens become useful. This runner stages a tiny
 * but representative dataset on first boot so the moment the
 * bootstrap admin lands on `/`, every page already has rows.
 *
 * **Opt-in by property.** `@ConditionalOnProperty` keeps this
 * bean entirely absent from production deployments — only the
 * dev profile (`application-dev.yaml` sets `vibeerp.demo.seed:
 * true`) opts in. A future release can ship a `--demo` CLI flag
 * or a one-time admin "Load demo data" button that flips the
 * same property at runtime; for v1 the dev profile is enough.
 *
 * **Idempotent.** The runner checks for one of its own seeded
 * item codes and short-circuits if already present. Restarting
 * the dev server is a no-op; deleting the demo data has to
 * happen via SQL or by dropping the DB. Idempotency on the
 * sentinel item is intentional (vs. on every entity it creates):
 * a half-seeded DB from a crashed first run will *not* recover
 * cleanly, but that case is exotic and we can clear and retry
 * in dev.
 *
 * **All seeded data shares the `DEMO-` prefix.** Items, partners,
 * locations, and order codes all start with `DEMO-`. This makes
 * the seeded data trivially distinguishable from anything an
 * operator creates by hand later — and gives a future
 * "delete demo data" command an obvious filter.
 *
 * **System principal.** Audit columns need a non-blank
 * `created_by`; `PrincipalContext.runAs("__demo_seed__")` wraps
 * the entire seed so every row carries that sentinel. The
 * authorization aspect (`@RequirePermission`) lives on
 * controllers, not services — calling services directly bypasses
 * it cleanly, which is correct for system-level seeders.
 *
 * **Why CommandLineRunner.** Equivalent to `ApplicationRunner`
 * here — there are no command-line args this seeder cares about.
 * Spring runs every CommandLineRunner once, after the application
 * context is fully initialized but before serving traffic, which
 * is exactly the right window: services are wired, the schema is
 * applied, but the first HTTP request hasn't arrived yet.
 *
 * **Lives in distribution.** This is the only module that
 * already depends on every PBC, which is what the seeder needs
 * to compose. It's gated behind a property the production
 * application.yaml never sets, so its presence in the fat-jar
 * is dormant unless explicitly opted in.
 */
@Component
@ConditionalOnProperty(prefix = "vibeerp.demo", name = ["seed"], havingValue = "true")
class DemoSeedRunner(
    private val itemService: ItemService,
    private val locationService: LocationService,
    private val stockBalanceService: StockBalanceService,
    private val partnerService: PartnerService,
    private val salesOrderService: SalesOrderService,
    private val purchaseOrderService: PurchaseOrderService,
) : CommandLineRunner {

    private val log = LoggerFactory.getLogger(DemoSeedRunner::class.java)

    @Transactional
    override fun run(vararg args: String?) {
        if (itemService.findByCode(SENTINEL_ITEM_CODE) != null) {
            log.info("Demo seed: data already present (sentinel item {} found); skipping", SENTINEL_ITEM_CODE)
            return
        }

        log.info("Demo seed: populating starter dataset…")
        PrincipalContext.runAs("__demo_seed__") {
            seedItems()
            seedLocations()
            seedPartners()
            seedStock()
            seedSalesOrder()
            seedPurchaseOrder()
        }
        log.info("Demo seed: done")
    }

    // ─── Items ───────────────────────────────────────────────────────

    private fun seedItems() {
        item(SENTINEL_ITEM_CODE, "A4 paper, 80gsm, white", ItemType.GOOD, "sheet")
        item("DEMO-INK-CYAN", "Cyan offset ink", ItemType.GOOD, "kg")
        item("DEMO-INK-MAGENTA", "Magenta offset ink", ItemType.GOOD, "kg")
        item("DEMO-CARD-BIZ", "Business cards, 100/pack", ItemType.GOOD, "pack")
        item("DEMO-BROCHURE-A5", "Folded A5 brochure", ItemType.GOOD, "ea")
    }

    private fun item(code: String, name: String, type: ItemType, baseUomCode: String) {
        itemService.create(
            CreateItemCommand(
                code = code,
                name = name,
                description = null,
                itemType = type,
                baseUomCode = baseUomCode,
                active = true,
            ),
        )
    }

    // ─── Locations ───────────────────────────────────────────────────

    private fun seedLocations() {
        location("DEMO-WH-RAW", "Raw materials warehouse")
        location("DEMO-WH-FG", "Finished goods warehouse")
    }

    private fun location(code: String, name: String) {
        locationService.create(
            CreateLocationCommand(
                code = code,
                name = name,
                type = LocationType.WAREHOUSE,
                active = true,
            ),
        )
    }

    // ─── Partners ────────────────────────────────────────────────────

    private fun seedPartners() {
        partner("DEMO-CUST-ACME", "Acme Print Co.", PartnerType.CUSTOMER, "ap@acme.example")
        partner("DEMO-CUST-GLOBE", "Globe Marketing", PartnerType.CUSTOMER, "ops@globe.example")
        partner("DEMO-SUPP-PAPERWORLD", "Paper World Ltd.", PartnerType.SUPPLIER, "sales@paperworld.example")
        partner("DEMO-SUPP-INKCO", "InkCo Industries", PartnerType.SUPPLIER, "orders@inkco.example")
    }

    private fun partner(code: String, name: String, type: PartnerType, email: String) {
        partnerService.create(
            CreatePartnerCommand(
                code = code,
                name = name,
                type = type,
                email = email,
                active = true,
            ),
        )
    }

    // ─── Initial stock ───────────────────────────────────────────────

    private fun seedStock() {
        val rawWh = locationService.findByCode("DEMO-WH-RAW")!!
        val fgWh = locationService.findByCode("DEMO-WH-FG")!!

        stockBalanceService.adjust(SENTINEL_ITEM_CODE, rawWh.id, BigDecimal("5000"))
        stockBalanceService.adjust("DEMO-INK-CYAN", rawWh.id, BigDecimal("50"))
        stockBalanceService.adjust("DEMO-INK-MAGENTA", rawWh.id, BigDecimal("50"))

        stockBalanceService.adjust("DEMO-CARD-BIZ", fgWh.id, BigDecimal("200"))
        stockBalanceService.adjust("DEMO-BROCHURE-A5", fgWh.id, BigDecimal("100"))
    }

    // ─── Open sales order (DRAFT — ready to confirm + ship) ──────────

    private fun seedSalesOrder() {
        salesOrderService.create(
            CreateSalesOrderCommand(
                code = "DEMO-SO-0001",
                partnerCode = "DEMO-CUST-ACME",
                orderDate = LocalDate.now(),
                currencyCode = "USD",
                lines = listOf(
                    SalesOrderLineCommand(
                        lineNo = 1,
                        itemCode = "DEMO-CARD-BIZ",
                        quantity = BigDecimal("50"),
                        unitPrice = BigDecimal("12.50"),
                        currencyCode = "USD",
                    ),
                    SalesOrderLineCommand(
                        lineNo = 2,
                        itemCode = "DEMO-BROCHURE-A5",
                        quantity = BigDecimal("20"),
                        unitPrice = BigDecimal("4.75"),
                        currencyCode = "USD",
                    ),
                ),
            ),
        )
    }

    // ─── Open purchase order (DRAFT — ready to confirm + receive) ────

    private fun seedPurchaseOrder() {
        purchaseOrderService.create(
            CreatePurchaseOrderCommand(
                code = "DEMO-PO-0001",
                partnerCode = "DEMO-SUPP-PAPERWORLD",
                orderDate = LocalDate.now(),
                expectedDate = LocalDate.now().plusDays(7),
                currencyCode = "USD",
                lines = listOf(
                    PurchaseOrderLineCommand(
                        lineNo = 1,
                        itemCode = SENTINEL_ITEM_CODE,
                        quantity = BigDecimal("10000"),
                        unitPrice = BigDecimal("0.04"),
                        currencyCode = "USD",
                    ),
                ),
            ),
        )
    }

    companion object {
        /**
         * The seeder uses the presence of this item as the
         * idempotency marker — re-running the seeder against a
         * Postgres that already contains it short-circuits. The
         * choice of "the very first item the seeder creates" is
         * deliberate: if the seed transaction commits at all, this
         * row is in the DB; if it doesn't, nothing is.
         */
        const val SENTINEL_ITEM_CODE: String = "DEMO-PAPER-A4"
    }
}