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" } }