Commit 8b9e0ab202612fc4e7a7d05f38e96d47f1de5546

Authored by zichun
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).
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
@@ -11,6 +11,8 @@ spring: @@ -11,6 +11,8 @@ spring:
11 password: vibeerp 11 password: vibeerp
12 12
13 vibeerp: 13 vibeerp:
  14 + demo:
  15 + seed: true
14 security: 16 security:
15 jwt: 17 jwt:
16 # Dev-only secret — DO NOT use this in production. The application.yaml 18 # Dev-only secret — DO NOT use this in production. The application.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 +}