Commit 8b9e0ab202612fc4e7a7d05f38e96d47f1de5546
1 parent
da7ebd3c
feat(demo): comprehensive seed data showcasing all v0.34 features
Add DemoSeedRunner that populates the database on first boot with demo data spanning all implemented PBCs and platform capabilities: 5 catalog items, 4 partners, 3 warehouse locations, opening stock balances with movement ledger entries, 2 sales orders, 1 purchase order, 1 work order with BOM inputs, and 4 user-created metadata rows (custom field, form, rule, list view) demonstrating Tier 1 no-code extensibility. Also adds the missing RuleEvaluator class that RuleEngine references for condition evaluation in the rules engine (P3.5).
Showing
3 changed files
with
671 additions
and
0 deletions
distribution/src/main/kotlin/org/vibeerp/demo/DemoSeedRunner.kt
0 → 100644
| 1 | +package org.vibeerp.demo | |
| 2 | + | |
| 3 | +import org.slf4j.LoggerFactory | |
| 4 | +import org.springframework.boot.ApplicationArguments | |
| 5 | +import org.springframework.boot.ApplicationRunner | |
| 6 | +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty | |
| 7 | +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource | |
| 8 | +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate | |
| 9 | +import org.springframework.stereotype.Component | |
| 10 | +import java.math.BigDecimal | |
| 11 | +import java.sql.Timestamp | |
| 12 | +import java.time.Instant | |
| 13 | +import java.time.LocalDate | |
| 14 | +import java.util.UUID | |
| 15 | + | |
| 16 | +/** | |
| 17 | + * Populates the database with comprehensive demo data on first boot. | |
| 18 | + * | |
| 19 | + * Activated by `vibeerp.demo.seed=true` (set in application-dev.yaml). | |
| 20 | + * The runner is **idempotent**: it checks whether catalog items already | |
| 21 | + * exist and skips seeding entirely if they do. This means you can restart | |
| 22 | + * the dev server without duplicating rows. | |
| 23 | + * | |
| 24 | + * All inserts go through [NamedParameterJdbcTemplate] rather than the | |
| 25 | + * service layer so we bypass permission checks, event publishing, and | |
| 26 | + * other side effects that would fire during normal runtime. The demo | |
| 27 | + * seed is infrastructure, not a business operation. | |
| 28 | + * | |
| 29 | + * Data seeded: | |
| 30 | + * - 5 catalog items (goods + services) | |
| 31 | + * - 4 partners (customers + suppliers) | |
| 32 | + * - 3 inventory locations (warehouse + quarantine) | |
| 33 | + * - 3 opening stock balances with movement ledger entries | |
| 34 | + * - 2 sales orders with lines | |
| 35 | + * - 1 purchase order with lines | |
| 36 | + * - 1 work order with BOM inputs | |
| 37 | + * - 4 user-created metadata rows (custom field, form, rule, list view) | |
| 38 | + */ | |
| 39 | +@Component | |
| 40 | +@ConditionalOnProperty(prefix = "vibeerp.demo", name = ["seed"], havingValue = "true") | |
| 41 | +class DemoSeedRunner( | |
| 42 | + private val jdbc: NamedParameterJdbcTemplate, | |
| 43 | +) : ApplicationRunner { | |
| 44 | + | |
| 45 | + private val log = LoggerFactory.getLogger(DemoSeedRunner::class.java) | |
| 46 | + | |
| 47 | + override fun run(args: ApplicationArguments) { | |
| 48 | + if (alreadySeeded()) { | |
| 49 | + log.info("[demo-seed] Demo data already present -- skipping.") | |
| 50 | + return | |
| 51 | + } | |
| 52 | + log.info("[demo-seed] Seeding demo data ...") | |
| 53 | + | |
| 54 | + val now = Timestamp.from(Instant.now()) | |
| 55 | + val principal = "__demo_seed__" | |
| 56 | + | |
| 57 | + seedUoms(now, principal) | |
| 58 | + seedItems(now, principal) | |
| 59 | + seedPartners(now, principal) | |
| 60 | + val locationIds = seedLocations(now, principal) | |
| 61 | + seedStockBalances(now, principal, locationIds) | |
| 62 | + seedSalesOrders(now, principal) | |
| 63 | + seedPurchaseOrders(now, principal) | |
| 64 | + seedWorkOrders(now, principal) | |
| 65 | + seedMetadata(now) | |
| 66 | + | |
| 67 | + log.info("[demo-seed] Demo data seeded successfully.") | |
| 68 | + } | |
| 69 | + | |
| 70 | + // ── guard ──────────────────────────────────────────────────────────── | |
| 71 | + | |
| 72 | + private fun alreadySeeded(): Boolean { | |
| 73 | + val count = jdbc.queryForObject( | |
| 74 | + "SELECT COUNT(*) FROM catalog__item", | |
| 75 | + MapSqlParameterSource(), | |
| 76 | + Long::class.java, | |
| 77 | + ) ?: 0L | |
| 78 | + return count > 0 | |
| 79 | + } | |
| 80 | + | |
| 81 | + // ── UoMs ───────────────────────────────────────────────────────────── | |
| 82 | + // The Liquibase seed covers kg/g/t/m/cm/mm/km/m2/l/ml/ea/sheet/pack/h/min. | |
| 83 | + // We need a few more for the demo items: PCS and LTR are covered by | |
| 84 | + // existing 'ea' and 'l'. We map demo UOM codes to the canonical ones | |
| 85 | + // already in catalog__uom. If any are missing we insert them. | |
| 86 | + | |
| 87 | + @Suppress("UNUSED_PARAMETER") | |
| 88 | + private fun seedUoms(now: Timestamp, principal: String) { | |
| 89 | + // The Liquibase seed already provides: ea, l, sheet, h. | |
| 90 | + // We do not need additional UoMs for the demo items. | |
| 91 | + log.debug("[demo-seed] UoMs: using Liquibase-seeded codes (sheet, l, ea, h).") | |
| 92 | + } | |
| 93 | + | |
| 94 | + // ── Items ──────────────────────────────────────────────────────────── | |
| 95 | + | |
| 96 | + private fun seedItems(now: Timestamp, principal: String) { | |
| 97 | + data class DemoItem( | |
| 98 | + val code: String, | |
| 99 | + val name: String, | |
| 100 | + val description: String, | |
| 101 | + val itemType: String, | |
| 102 | + val baseUomCode: String, | |
| 103 | + ) | |
| 104 | + | |
| 105 | + val items = listOf( | |
| 106 | + DemoItem("PAPER-A3-120G", "120g A3 Coated Paper", "Premium 120gsm coated paper, A3 size, suitable for brochure printing", "GOOD", "sheet"), | |
| 107 | + DemoItem("INK-CMYK-BLACK", "CMYK Black Ink", "High-density CMYK black process ink for offset printing", "GOOD", "l"), | |
| 108 | + DemoItem("BINDING-PERFECT", "Perfect Binding Service", "Machine-assisted perfect (adhesive) binding for booklets and brochures", "SERVICE", "ea"), | |
| 109 | + DemoItem("DESIGN-HOURLY", "Design Consultation", "Graphic design and layout consultation, billed per hour", "SERVICE", "h"), | |
| 110 | + DemoItem("BROCHURE-A4", "A4 Full-Color Brochure", "Finished A4 full-color brochure, CMYK offset print on 120gsm coated stock", "GOOD", "ea"), | |
| 111 | + ) | |
| 112 | + | |
| 113 | + val sql = """ | |
| 114 | + INSERT INTO catalog__item | |
| 115 | + (id, code, name, description, item_type, base_uom_code, active, ext, | |
| 116 | + created_at, created_by, updated_at, updated_by, version) | |
| 117 | + VALUES | |
| 118 | + (:id, :code, :name, :description, :itemType, :baseUomCode, true, '{}'::jsonb, | |
| 119 | + :now, :principal, :now, :principal, 0) | |
| 120 | + """.trimIndent() | |
| 121 | + | |
| 122 | + for (item in items) { | |
| 123 | + jdbc.update(sql, MapSqlParameterSource() | |
| 124 | + .addValue("id", UUID.randomUUID()) | |
| 125 | + .addValue("code", item.code) | |
| 126 | + .addValue("name", item.name) | |
| 127 | + .addValue("description", item.description) | |
| 128 | + .addValue("itemType", item.itemType) | |
| 129 | + .addValue("baseUomCode", item.baseUomCode) | |
| 130 | + .addValue("now", now) | |
| 131 | + .addValue("principal", principal)) | |
| 132 | + } | |
| 133 | + log.debug("[demo-seed] Seeded {} items.", items.size) | |
| 134 | + } | |
| 135 | + | |
| 136 | + // ── Partners ───────────────────────────────────────────────────────── | |
| 137 | + | |
| 138 | + private fun seedPartners(now: Timestamp, principal: String) { | |
| 139 | + data class DemoPartner( | |
| 140 | + val code: String, | |
| 141 | + val name: String, | |
| 142 | + val type: String, | |
| 143 | + val email: String, | |
| 144 | + val phone: String, | |
| 145 | + ) | |
| 146 | + | |
| 147 | + val partners = listOf( | |
| 148 | + DemoPartner("CUST-ACME", "Acme Publishing Inc.", "CUSTOMER", "orders@acme-publishing.example.com", "+1-555-0101"), | |
| 149 | + DemoPartner("CUST-GLOBEX", "Globex Print Services", "CUSTOMER", "procurement@globex-print.example.com", "+1-555-0102"), | |
| 150 | + DemoPartner("SUPP-PAPERCO", "PaperCo Wholesale", "SUPPLIER", "sales@paperco-wholesale.example.com", "+1-555-0201"), | |
| 151 | + DemoPartner("SUPP-INKMASTER", "InkMaster Supplies", "BOTH", "info@inkmaster.example.com", "+1-555-0202"), | |
| 152 | + ) | |
| 153 | + | |
| 154 | + val sql = """ | |
| 155 | + INSERT INTO partners__partner | |
| 156 | + (id, code, name, type, email, phone, tax_id, website, active, ext, | |
| 157 | + created_at, created_by, updated_at, updated_by, version) | |
| 158 | + VALUES | |
| 159 | + (:id, :code, :name, :type, :email, :phone, NULL, NULL, true, '{}'::jsonb, | |
| 160 | + :now, :principal, :now, :principal, 0) | |
| 161 | + """.trimIndent() | |
| 162 | + | |
| 163 | + for (p in partners) { | |
| 164 | + jdbc.update(sql, MapSqlParameterSource() | |
| 165 | + .addValue("id", UUID.randomUUID()) | |
| 166 | + .addValue("code", p.code) | |
| 167 | + .addValue("name", p.name) | |
| 168 | + .addValue("type", p.type) | |
| 169 | + .addValue("email", p.email) | |
| 170 | + .addValue("phone", p.phone) | |
| 171 | + .addValue("now", now) | |
| 172 | + .addValue("principal", principal)) | |
| 173 | + } | |
| 174 | + log.debug("[demo-seed] Seeded {} partners.", partners.size) | |
| 175 | + } | |
| 176 | + | |
| 177 | + // ── Locations ──────────────────────────────────────────────────────── | |
| 178 | + | |
| 179 | + /** | |
| 180 | + * Returns a map of location code to location UUID for FK references | |
| 181 | + * in stock balance rows. | |
| 182 | + */ | |
| 183 | + private fun seedLocations(now: Timestamp, principal: String): Map<String, UUID> { | |
| 184 | + data class DemoLocation(val code: String, val name: String, val type: String) | |
| 185 | + | |
| 186 | + val locations = listOf( | |
| 187 | + DemoLocation("WH-RAW", "Raw Materials Warehouse", "WAREHOUSE"), | |
| 188 | + DemoLocation("WH-FG", "Finished Goods Warehouse", "WAREHOUSE"), | |
| 189 | + DemoLocation("WH-QUARANTINE", "Quarantine Area", "QUARANTINE"), | |
| 190 | + ) | |
| 191 | + | |
| 192 | + val sql = """ | |
| 193 | + INSERT INTO inventory__location | |
| 194 | + (id, code, name, type, active, ext, | |
| 195 | + created_at, created_by, updated_at, updated_by, version) | |
| 196 | + VALUES | |
| 197 | + (:id, :code, :name, :type, true, '{}'::jsonb, | |
| 198 | + :now, :principal, :now, :principal, 0) | |
| 199 | + """.trimIndent() | |
| 200 | + | |
| 201 | + val ids = mutableMapOf<String, UUID>() | |
| 202 | + for (loc in locations) { | |
| 203 | + val id = UUID.randomUUID() | |
| 204 | + ids[loc.code] = id | |
| 205 | + jdbc.update(sql, MapSqlParameterSource() | |
| 206 | + .addValue("id", id) | |
| 207 | + .addValue("code", loc.code) | |
| 208 | + .addValue("name", loc.name) | |
| 209 | + .addValue("type", loc.type) | |
| 210 | + .addValue("now", now) | |
| 211 | + .addValue("principal", principal)) | |
| 212 | + } | |
| 213 | + log.debug("[demo-seed] Seeded {} locations.", locations.size) | |
| 214 | + return ids | |
| 215 | + } | |
| 216 | + | |
| 217 | + // ── Stock balances + movement ledger ───────────────────────────────── | |
| 218 | + | |
| 219 | + private fun seedStockBalances( | |
| 220 | + now: Timestamp, | |
| 221 | + principal: String, | |
| 222 | + locationIds: Map<String, UUID>, | |
| 223 | + ) { | |
| 224 | + data class DemoBalance( | |
| 225 | + val itemCode: String, | |
| 226 | + val locationCode: String, | |
| 227 | + val quantity: BigDecimal, | |
| 228 | + ) | |
| 229 | + | |
| 230 | + val balances = listOf( | |
| 231 | + DemoBalance("PAPER-A3-120G", "WH-RAW", BigDecimal("5000.0000")), | |
| 232 | + DemoBalance("INK-CMYK-BLACK", "WH-RAW", BigDecimal("200.0000")), | |
| 233 | + DemoBalance("BROCHURE-A4", "WH-FG", BigDecimal("500.0000")), | |
| 234 | + ) | |
| 235 | + | |
| 236 | + val balanceSql = """ | |
| 237 | + INSERT INTO inventory__stock_balance | |
| 238 | + (id, item_code, location_id, quantity, | |
| 239 | + created_at, created_by, updated_at, updated_by, version) | |
| 240 | + VALUES | |
| 241 | + (:id, :itemCode, :locationId, :quantity, | |
| 242 | + :now, :principal, :now, :principal, 0) | |
| 243 | + """.trimIndent() | |
| 244 | + | |
| 245 | + val movementSql = """ | |
| 246 | + INSERT INTO inventory__stock_movement | |
| 247 | + (id, item_code, location_id, delta, reason, reference, occurred_at, | |
| 248 | + created_at, created_by, updated_at, updated_by, version) | |
| 249 | + VALUES | |
| 250 | + (:id, :itemCode, :locationId, :delta, :reason, :reference, :occurredAt, | |
| 251 | + :now, :principal, :now, :principal, 0) | |
| 252 | + """.trimIndent() | |
| 253 | + | |
| 254 | + for (b in balances) { | |
| 255 | + val locationId = locationIds[b.locationCode] | |
| 256 | + ?: error("Location ${b.locationCode} not found in seeded locations") | |
| 257 | + | |
| 258 | + // Insert the balance row | |
| 259 | + jdbc.update(balanceSql, MapSqlParameterSource() | |
| 260 | + .addValue("id", UUID.randomUUID()) | |
| 261 | + .addValue("itemCode", b.itemCode) | |
| 262 | + .addValue("locationId", locationId) | |
| 263 | + .addValue("quantity", b.quantity) | |
| 264 | + .addValue("now", now) | |
| 265 | + .addValue("principal", principal)) | |
| 266 | + | |
| 267 | + // Insert a corresponding OPENING_BALANCE movement ledger entry | |
| 268 | + jdbc.update(movementSql, MapSqlParameterSource() | |
| 269 | + .addValue("id", UUID.randomUUID()) | |
| 270 | + .addValue("itemCode", b.itemCode) | |
| 271 | + .addValue("locationId", locationId) | |
| 272 | + .addValue("delta", b.quantity) | |
| 273 | + .addValue("reason", "ADJUSTMENT") | |
| 274 | + .addValue("reference", "DEMO-OPENING-BALANCE") | |
| 275 | + .addValue("occurredAt", now) | |
| 276 | + .addValue("now", now) | |
| 277 | + .addValue("principal", principal)) | |
| 278 | + } | |
| 279 | + log.debug("[demo-seed] Seeded {} stock balances with movement ledger entries.", balances.size) | |
| 280 | + } | |
| 281 | + | |
| 282 | + // ── Sales orders ───────────────────────────────────────────────────── | |
| 283 | + | |
| 284 | + private fun seedSalesOrders(now: Timestamp, principal: String) { | |
| 285 | + val orderDate = LocalDate.now() | |
| 286 | + | |
| 287 | + // --- SO-001: Acme Publishing, 2 lines --- | |
| 288 | + val so1Id = UUID.randomUUID() | |
| 289 | + val so1Total = BigDecimal("1000.0000") | |
| 290 | + .add(BigDecimal("400.0000")) // 1000 * 0.50 + 5 * 80 | |
| 291 | + insertSalesOrder(so1Id, "DEMO-SO-001", "CUST-ACME", orderDate, "USD", so1Total, now, principal) | |
| 292 | + | |
| 293 | + insertSalesOrderLine(so1Id, 1, "BROCHURE-A4", | |
| 294 | + BigDecimal("1000.0000"), BigDecimal("0.5000"), "USD", now, principal) | |
| 295 | + insertSalesOrderLine(so1Id, 2, "DESIGN-HOURLY", | |
| 296 | + BigDecimal("5.0000"), BigDecimal("80.0000"), "USD", now, principal) | |
| 297 | + | |
| 298 | + // --- SO-002: Globex Print, 1 line --- | |
| 299 | + val so2Id = UUID.randomUUID() | |
| 300 | + val so2Total = BigDecimal("225.0000") // 500 * 0.45 | |
| 301 | + insertSalesOrder(so2Id, "DEMO-SO-002", "CUST-GLOBEX", orderDate, "USD", so2Total, now, principal) | |
| 302 | + | |
| 303 | + insertSalesOrderLine(so2Id, 1, "BROCHURE-A4", | |
| 304 | + BigDecimal("500.0000"), BigDecimal("0.4500"), "USD", now, principal) | |
| 305 | + | |
| 306 | + log.debug("[demo-seed] Seeded 2 sales orders with 3 lines total.") | |
| 307 | + } | |
| 308 | + | |
| 309 | + private fun insertSalesOrder( | |
| 310 | + id: UUID, code: String, partnerCode: String, orderDate: LocalDate, | |
| 311 | + currencyCode: String, totalAmount: BigDecimal, now: Timestamp, principal: String, | |
| 312 | + ) { | |
| 313 | + jdbc.update(""" | |
| 314 | + INSERT INTO orders_sales__sales_order | |
| 315 | + (id, code, partner_code, status, order_date, currency_code, total_amount, ext, | |
| 316 | + created_at, created_by, updated_at, updated_by, version) | |
| 317 | + VALUES | |
| 318 | + (:id, :code, :partnerCode, 'DRAFT', :orderDate, :currencyCode, :totalAmount, '{}'::jsonb, | |
| 319 | + :now, :principal, :now, :principal, 0) | |
| 320 | + """.trimIndent(), MapSqlParameterSource() | |
| 321 | + .addValue("id", id) | |
| 322 | + .addValue("code", code) | |
| 323 | + .addValue("partnerCode", partnerCode) | |
| 324 | + .addValue("orderDate", java.sql.Date.valueOf(orderDate)) | |
| 325 | + .addValue("currencyCode", currencyCode) | |
| 326 | + .addValue("totalAmount", totalAmount) | |
| 327 | + .addValue("now", now) | |
| 328 | + .addValue("principal", principal)) | |
| 329 | + } | |
| 330 | + | |
| 331 | + private fun insertSalesOrderLine( | |
| 332 | + orderId: UUID, lineNo: Int, itemCode: String, | |
| 333 | + quantity: BigDecimal, unitPrice: BigDecimal, currencyCode: String, | |
| 334 | + now: Timestamp, principal: String, | |
| 335 | + ) { | |
| 336 | + jdbc.update(""" | |
| 337 | + INSERT INTO orders_sales__sales_order_line | |
| 338 | + (id, sales_order_id, line_no, item_code, quantity, unit_price, currency_code, | |
| 339 | + created_at, created_by, updated_at, updated_by, version) | |
| 340 | + VALUES | |
| 341 | + (:id, :orderId, :lineNo, :itemCode, :quantity, :unitPrice, :currencyCode, | |
| 342 | + :now, :principal, :now, :principal, 0) | |
| 343 | + """.trimIndent(), MapSqlParameterSource() | |
| 344 | + .addValue("id", UUID.randomUUID()) | |
| 345 | + .addValue("orderId", orderId) | |
| 346 | + .addValue("lineNo", lineNo) | |
| 347 | + .addValue("itemCode", itemCode) | |
| 348 | + .addValue("quantity", quantity) | |
| 349 | + .addValue("unitPrice", unitPrice) | |
| 350 | + .addValue("currencyCode", currencyCode) | |
| 351 | + .addValue("now", now) | |
| 352 | + .addValue("principal", principal)) | |
| 353 | + } | |
| 354 | + | |
| 355 | + // ── Purchase orders ────────────────────────────────────────────────── | |
| 356 | + | |
| 357 | + private fun seedPurchaseOrders(now: Timestamp, principal: String) { | |
| 358 | + val orderDate = LocalDate.now() | |
| 359 | + | |
| 360 | + val po1Id = UUID.randomUUID() | |
| 361 | + // 10000 * 0.02 + 50 * 15 = 200 + 750 = 950 | |
| 362 | + val po1Total = BigDecimal("950.0000") | |
| 363 | + insertPurchaseOrder(po1Id, "DEMO-PO-001", "SUPP-PAPERCO", orderDate, "USD", po1Total, now, principal) | |
| 364 | + | |
| 365 | + insertPurchaseOrderLine(po1Id, 1, "PAPER-A3-120G", | |
| 366 | + BigDecimal("10000.0000"), BigDecimal("0.0200"), "USD", now, principal) | |
| 367 | + insertPurchaseOrderLine(po1Id, 2, "INK-CMYK-BLACK", | |
| 368 | + BigDecimal("50.0000"), BigDecimal("15.0000"), "USD", now, principal) | |
| 369 | + | |
| 370 | + log.debug("[demo-seed] Seeded 1 purchase order with 2 lines.") | |
| 371 | + } | |
| 372 | + | |
| 373 | + private fun insertPurchaseOrder( | |
| 374 | + id: UUID, code: String, partnerCode: String, orderDate: LocalDate, | |
| 375 | + currencyCode: String, totalAmount: BigDecimal, now: Timestamp, principal: String, | |
| 376 | + ) { | |
| 377 | + jdbc.update(""" | |
| 378 | + INSERT INTO orders_purchase__purchase_order | |
| 379 | + (id, code, partner_code, status, order_date, expected_date, currency_code, total_amount, ext, | |
| 380 | + created_at, created_by, updated_at, updated_by, version) | |
| 381 | + VALUES | |
| 382 | + (:id, :code, :partnerCode, 'DRAFT', :orderDate, :expectedDate, :currencyCode, :totalAmount, '{}'::jsonb, | |
| 383 | + :now, :principal, :now, :principal, 0) | |
| 384 | + """.trimIndent(), MapSqlParameterSource() | |
| 385 | + .addValue("id", id) | |
| 386 | + .addValue("code", code) | |
| 387 | + .addValue("partnerCode", partnerCode) | |
| 388 | + .addValue("orderDate", java.sql.Date.valueOf(orderDate)) | |
| 389 | + .addValue("expectedDate", java.sql.Date.valueOf(orderDate.plusDays(14))) | |
| 390 | + .addValue("currencyCode", currencyCode) | |
| 391 | + .addValue("totalAmount", totalAmount) | |
| 392 | + .addValue("now", now) | |
| 393 | + .addValue("principal", principal)) | |
| 394 | + } | |
| 395 | + | |
| 396 | + private fun insertPurchaseOrderLine( | |
| 397 | + orderId: UUID, lineNo: Int, itemCode: String, | |
| 398 | + quantity: BigDecimal, unitPrice: BigDecimal, currencyCode: String, | |
| 399 | + now: Timestamp, principal: String, | |
| 400 | + ) { | |
| 401 | + jdbc.update(""" | |
| 402 | + INSERT INTO orders_purchase__purchase_order_line | |
| 403 | + (id, purchase_order_id, line_no, item_code, quantity, unit_price, currency_code, | |
| 404 | + created_at, created_by, updated_at, updated_by, version) | |
| 405 | + VALUES | |
| 406 | + (:id, :orderId, :lineNo, :itemCode, :quantity, :unitPrice, :currencyCode, | |
| 407 | + :now, :principal, :now, :principal, 0) | |
| 408 | + """.trimIndent(), MapSqlParameterSource() | |
| 409 | + .addValue("id", UUID.randomUUID()) | |
| 410 | + .addValue("orderId", orderId) | |
| 411 | + .addValue("lineNo", lineNo) | |
| 412 | + .addValue("itemCode", itemCode) | |
| 413 | + .addValue("quantity", quantity) | |
| 414 | + .addValue("unitPrice", unitPrice) | |
| 415 | + .addValue("currencyCode", currencyCode) | |
| 416 | + .addValue("now", now) | |
| 417 | + .addValue("principal", principal)) | |
| 418 | + } | |
| 419 | + | |
| 420 | + // ── Work orders ────────────────────────────────────────────────────── | |
| 421 | + | |
| 422 | + private fun seedWorkOrders(now: Timestamp, principal: String) { | |
| 423 | + val woId = UUID.randomUUID() | |
| 424 | + | |
| 425 | + jdbc.update(""" | |
| 426 | + INSERT INTO production__work_order | |
| 427 | + (id, code, output_item_code, output_quantity, status, due_date, | |
| 428 | + source_sales_order_code, ext, | |
| 429 | + created_at, created_by, updated_at, updated_by, version) | |
| 430 | + VALUES | |
| 431 | + (:id, :code, :outputItemCode, :outputQuantity, 'DRAFT', :dueDate, | |
| 432 | + :sourceSoCode, '{}'::jsonb, | |
| 433 | + :now, :principal, :now, :principal, 0) | |
| 434 | + """.trimIndent(), MapSqlParameterSource() | |
| 435 | + .addValue("id", woId) | |
| 436 | + .addValue("code", "DEMO-WO-001") | |
| 437 | + .addValue("outputItemCode", "BROCHURE-A4") | |
| 438 | + .addValue("outputQuantity", BigDecimal("1000.0000")) | |
| 439 | + .addValue("dueDate", java.sql.Date.valueOf(LocalDate.now().plusDays(7))) | |
| 440 | + .addValue("sourceSoCode", "DEMO-SO-001") | |
| 441 | + .addValue("now", now) | |
| 442 | + .addValue("principal", principal)) | |
| 443 | + | |
| 444 | + // BOM inputs: 2 sheets of paper per brochure, 0.5 liters of ink per brochure | |
| 445 | + insertWorkOrderInput(woId, 1, "PAPER-A3-120G", BigDecimal("2.0000"), "WH-RAW", now, principal) | |
| 446 | + insertWorkOrderInput(woId, 2, "INK-CMYK-BLACK", BigDecimal("0.5000"), "WH-RAW", now, principal) | |
| 447 | + | |
| 448 | + log.debug("[demo-seed] Seeded 1 work order with 2 BOM inputs.") | |
| 449 | + } | |
| 450 | + | |
| 451 | + private fun insertWorkOrderInput( | |
| 452 | + workOrderId: UUID, lineNo: Int, itemCode: String, | |
| 453 | + quantityPerUnit: BigDecimal, sourceLocationCode: String, | |
| 454 | + now: Timestamp, principal: String, | |
| 455 | + ) { | |
| 456 | + jdbc.update(""" | |
| 457 | + INSERT INTO production__work_order_input | |
| 458 | + (id, work_order_id, line_no, item_code, quantity_per_unit, source_location_code, | |
| 459 | + created_at, created_by, updated_at, updated_by, version) | |
| 460 | + VALUES | |
| 461 | + (:id, :workOrderId, :lineNo, :itemCode, :quantityPerUnit, :sourceLocationCode, | |
| 462 | + :now, :principal, :now, :principal, 0) | |
| 463 | + """.trimIndent(), MapSqlParameterSource() | |
| 464 | + .addValue("id", UUID.randomUUID()) | |
| 465 | + .addValue("workOrderId", workOrderId) | |
| 466 | + .addValue("lineNo", lineNo) | |
| 467 | + .addValue("itemCode", itemCode) | |
| 468 | + .addValue("quantityPerUnit", quantityPerUnit) | |
| 469 | + .addValue("sourceLocationCode", sourceLocationCode) | |
| 470 | + .addValue("now", now) | |
| 471 | + .addValue("principal", principal)) | |
| 472 | + } | |
| 473 | + | |
| 474 | + // ── Metadata (Tier 1 customization showcase) ───────────────────────── | |
| 475 | + | |
| 476 | + private fun seedMetadata(now: Timestamp) { | |
| 477 | + // 1) Custom field: user-defined priority enum on SalesOrder | |
| 478 | + jdbc.update(""" | |
| 479 | + INSERT INTO metadata__custom_field (id, source, payload, created_at, updated_at) | |
| 480 | + VALUES (:id, 'user', :payload::jsonb, :now, :now) | |
| 481 | + """.trimIndent(), MapSqlParameterSource() | |
| 482 | + .addValue("id", UUID.randomUUID()) | |
| 483 | + .addValue("payload", """ | |
| 484 | + { | |
| 485 | + "key": "user_priority", | |
| 486 | + "targetEntity": "SalesOrder", | |
| 487 | + "type": { | |
| 488 | + "kind": "enum", | |
| 489 | + "allowedValues": ["LOW", "NORMAL", "HIGH", "URGENT"] | |
| 490 | + }, | |
| 491 | + "required": false, | |
| 492 | + "pii": false, | |
| 493 | + "labelTranslations": { | |
| 494 | + "en": "Priority", | |
| 495 | + "zh-CN": "\u4F18\u5148\u7EA7" | |
| 496 | + } | |
| 497 | + } | |
| 498 | + """.trimIndent()) | |
| 499 | + .addValue("now", now)) | |
| 500 | + | |
| 501 | + // 2) Form definition: quick approval form for sales orders | |
| 502 | + jdbc.update(""" | |
| 503 | + INSERT INTO metadata__form (id, source, payload, created_at, updated_at) | |
| 504 | + VALUES (:id, 'user', :payload::jsonb, :now, :now) | |
| 505 | + """.trimIndent(), MapSqlParameterSource() | |
| 506 | + .addValue("id", UUID.randomUUID()) | |
| 507 | + .addValue("payload", """ | |
| 508 | + { | |
| 509 | + "slug": "demo-quick-approval", | |
| 510 | + "entityName": "SalesOrder", | |
| 511 | + "title": "Quick Order Approval", | |
| 512 | + "purpose": "user-task", | |
| 513 | + "version": 1, | |
| 514 | + "jsonSchema": { | |
| 515 | + "type": "object", | |
| 516 | + "required": ["approved"], | |
| 517 | + "properties": { | |
| 518 | + "orderCode": {"type": "string", "title": "Order Code", "readOnly": true}, | |
| 519 | + "customerName": {"type": "string", "title": "Customer", "readOnly": true}, | |
| 520 | + "totalAmount": {"type": "number", "title": "Total Amount", "readOnly": true}, | |
| 521 | + "approved": {"type": "boolean", "title": "Approve this order?"}, | |
| 522 | + "notes": {"type": "string", "title": "Approval Notes", "maxLength": 500} | |
| 523 | + } | |
| 524 | + }, | |
| 525 | + "uiSchema": { | |
| 526 | + "ui:order": ["orderCode", "customerName", "totalAmount", "approved", "notes"], | |
| 527 | + "notes": {"ui:widget": "textarea"} | |
| 528 | + } | |
| 529 | + } | |
| 530 | + """.trimIndent()) | |
| 531 | + .addValue("now", now)) | |
| 532 | + | |
| 533 | + // 3) Rule: high-value order alert | |
| 534 | + jdbc.update(""" | |
| 535 | + INSERT INTO metadata__rule (id, source, payload, created_at, updated_at) | |
| 536 | + VALUES (:id, 'user', :payload::jsonb, :now, :now) | |
| 537 | + """.trimIndent(), MapSqlParameterSource() | |
| 538 | + .addValue("id", UUID.randomUUID()) | |
| 539 | + .addValue("payload", """ | |
| 540 | + { | |
| 541 | + "slug": "high-value-order-alert", | |
| 542 | + "name": "High Value Order Alert", | |
| 543 | + "description": "Logs an alert when a sales order over $500 is confirmed", | |
| 544 | + "enabled": true, | |
| 545 | + "triggerEvent": "SalesOrderConfirmedEvent", | |
| 546 | + "conditionLogic": "AND", | |
| 547 | + "conditions": [ | |
| 548 | + {"field": "totalAmount", "operator": "gt", "value": "500"} | |
| 549 | + ], | |
| 550 | + "actions": [ | |
| 551 | + {"type": "log", "config": {"message": "HIGH VALUE ORDER: {orderCode} for ${"\$"}{totalAmount} from {partnerCode}"}} | |
| 552 | + ], | |
| 553 | + "version": 1 | |
| 554 | + } | |
| 555 | + """.trimIndent()) | |
| 556 | + .addValue("now", now)) | |
| 557 | + | |
| 558 | + // 4) List view: custom sales orders view | |
| 559 | + jdbc.update(""" | |
| 560 | + INSERT INTO metadata__list_view (id, source, payload, created_at, updated_at) | |
| 561 | + VALUES (:id, 'user', :payload::jsonb, :now, :now) | |
| 562 | + """.trimIndent(), MapSqlParameterSource() | |
| 563 | + .addValue("id", UUID.randomUUID()) | |
| 564 | + .addValue("payload", """ | |
| 565 | + { | |
| 566 | + "slug": "demo-sales-orders-view", | |
| 567 | + "entityName": "SalesOrder", | |
| 568 | + "title": "Sales Orders (Custom View)", | |
| 569 | + "columns": [ | |
| 570 | + {"field": "code", "label": "Order #", "sortable": true, "format": "link"}, | |
| 571 | + {"field": "partnerCode", "label": "Customer", "sortable": true}, | |
| 572 | + {"field": "status", "label": "Status", "sortable": true, "format": "status-badge"}, | |
| 573 | + {"field": "totalAmount", "label": "Total", "sortable": true, "format": "money"} | |
| 574 | + ], | |
| 575 | + "defaultSort": {"field": "code", "direction": "desc"}, | |
| 576 | + "filters": [ | |
| 577 | + {"field": "status", "operator": "eq", "label": "Status"} | |
| 578 | + ], | |
| 579 | + "pageSize": 25, | |
| 580 | + "version": 1 | |
| 581 | + } | |
| 582 | + """.trimIndent()) | |
| 583 | + .addValue("now", now)) | |
| 584 | + | |
| 585 | + log.debug("[demo-seed] Seeded 4 user-created metadata rows (custom field, form, rule, list view).") | |
| 586 | + } | |
| 587 | +} | ... | ... |
distribution/src/main/resources/application-dev.yaml
platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/rules/RuleEvaluator.kt
0 → 100644
| 1 | +package org.vibeerp.platform.metadata.rules | |
| 2 | + | |
| 3 | +import java.math.BigDecimal | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Stateless evaluator for metadata-driven rule conditions. | |
| 7 | + * | |
| 8 | + * Each [Condition] tests one field from the event payload against a | |
| 9 | + * threshold value using a comparison operator. The [evaluate] method | |
| 10 | + * combines multiple conditions with AND / OR logic. | |
| 11 | + * | |
| 12 | + * Supported operators: | |
| 13 | + * - `eq` -- string equality (case-sensitive) | |
| 14 | + * - `neq` -- not equal | |
| 15 | + * - `gt` -- greater-than (numeric comparison) | |
| 16 | + * - `gte` -- greater-than-or-equal | |
| 17 | + * - `lt` -- less-than | |
| 18 | + * - `lte` -- less-than-or-equal | |
| 19 | + * - `contains` -- substring check (case-insensitive) | |
| 20 | + * - `in` -- value is one of a comma-separated list | |
| 21 | + * | |
| 22 | + * All comparisons coerce event values to strings first; numeric | |
| 23 | + * operators parse both sides as [BigDecimal] and fall back to false | |
| 24 | + * on parse failure. | |
| 25 | + */ | |
| 26 | +object RuleEvaluator { | |
| 27 | + | |
| 28 | + data class Condition( | |
| 29 | + val field: String, | |
| 30 | + val operator: String, | |
| 31 | + val value: String, | |
| 32 | + ) | |
| 33 | + | |
| 34 | + /** | |
| 35 | + * Evaluate a list of [conditions] against the [eventMap]. | |
| 36 | + * | |
| 37 | + * @param conditionLogic `"AND"` (all must match) or `"OR"` (any must match). | |
| 38 | + * Defaults to AND if unrecognised. | |
| 39 | + */ | |
| 40 | + fun evaluate( | |
| 41 | + eventMap: Map<String, Any?>, | |
| 42 | + conditions: List<Condition>, | |
| 43 | + conditionLogic: String, | |
| 44 | + ): Boolean { | |
| 45 | + if (conditions.isEmpty()) return true | |
| 46 | + | |
| 47 | + val results = conditions.map { c -> evaluateSingle(eventMap, c) } | |
| 48 | + return when (conditionLogic.uppercase()) { | |
| 49 | + "OR" -> results.any { it } | |
| 50 | + else -> results.all { it } // AND is the default | |
| 51 | + } | |
| 52 | + } | |
| 53 | + | |
| 54 | + private fun evaluateSingle(eventMap: Map<String, Any?>, c: Condition): Boolean { | |
| 55 | + val actual = eventMap[c.field]?.toString() ?: return false | |
| 56 | + return when (c.operator.lowercase()) { | |
| 57 | + "eq" -> actual == c.value | |
| 58 | + "neq" -> actual != c.value | |
| 59 | + "gt" -> compareNumeric(actual, c.value) { it > 0 } | |
| 60 | + "gte" -> compareNumeric(actual, c.value) { it >= 0 } | |
| 61 | + "lt" -> compareNumeric(actual, c.value) { it < 0 } | |
| 62 | + "lte" -> compareNumeric(actual, c.value) { it <= 0 } | |
| 63 | + "contains" -> actual.contains(c.value, ignoreCase = true) | |
| 64 | + "in" -> c.value.split(",").map { it.trim() }.contains(actual) | |
| 65 | + else -> false | |
| 66 | + } | |
| 67 | + } | |
| 68 | + | |
| 69 | + private inline fun compareNumeric( | |
| 70 | + actual: String, | |
| 71 | + threshold: String, | |
| 72 | + predicate: (Int) -> Boolean, | |
| 73 | + ): Boolean { | |
| 74 | + return try { | |
| 75 | + val a = BigDecimal(actual) | |
| 76 | + val b = BigDecimal(threshold) | |
| 77 | + predicate(a.compareTo(b)) | |
| 78 | + } catch (_: NumberFormatException) { | |
| 79 | + false | |
| 80 | + } | |
| 81 | + } | |
| 82 | +} | ... | ... |