DemoSeedRunner.kt
10.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
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"
}
}