You need to sign in before continuing.
Commit 7b0d2f8c2cd7ec814fe37e519885a5d8845c16cc
1 parent
87399862
feat(demo): printing-company seed + create-order form matching EBC-PP-001
Reworks the demo seed and SPA to match the reference customer's
work-order management process (EBC-PP-001 from raw/ docs).
Demo seed (DemoSeedRunner):
- 7 printing-specific items: paper stock, 4-color ink, CTP plates,
lamination film, business cards, brochures, posters
- 4 partners: 2 customers (Wucai Advertising, Globe Marketing),
2 suppliers (Huazhong Paper, InkPro Industries)
- 2 warehouses with opening stock for all items
- Pre-seeded WO-PRINT-0001 with full BOM (3 inputs: paper +
ink + CTP plates from WH-RAW) and 3-step routing (CTP
plate-making @ CTP-ROOM-01 -> offset printing @ PRESS-A ->
post-press finishing @ BIND-01) matching EBC-PP-001 steps
C-010/C-040
- 2 DRAFT sales orders: SO-2026-0001 (100x business cards +
500x brochures, $1950), SO-2026-0002 (200x posters, $760)
- 1 DRAFT purchase order: PO-2026-0001 (10000x paper + 50kg
ink, $2550) from Huazhong Paper
SPA additions:
- New CreateSalesOrderPage with customer dropdown, item
selector, dynamic line add/remove, quantity + price inputs.
Navigates to the detail page on creation.
- "+ New Order" button on the SalesOrdersPage header
- Dashboard "Try the demo" section rewritten to walk the
EBC-PP-001 flow: create SO -> confirm (auto-spawns WOs) ->
walk WO routing -> complete (material issue + production
receipt) -> ship SO (stock debit + AR settle)
- salesOrders.create() added to the typed API client
The key demo beat: confirming SO-2026-0001 auto-spawns
WO-FROM-SO-2026-0001-L1 and -L2 via SalesOrderConfirmedSubscriber
(EBC-PP-001 step B-010). The pre-seeded WO-PRINT-0001 shows
the full BOM + routing story separately. Together they
demonstrate that the framework expresses the customer's
production workflow through configuration, not code.
Smoke verified on fresh Postgres: all 7 items seeded, WO with
3 BOM + 3 ops created, SO confirm spawns 2 WOs with source
traceability, SPA /sales-orders/new renders and creates orders.
Showing
6 changed files
with
360 additions
and
156 deletions
distribution/src/main/kotlin/org/vibeerp/demo/DemoSeedRunner.kt
| @@ -21,63 +21,28 @@ import org.vibeerp.pbc.orders.sales.application.SalesOrderService | @@ -21,63 +21,28 @@ import org.vibeerp.pbc.orders.sales.application.SalesOrderService | ||
| 21 | import org.vibeerp.pbc.partners.application.CreatePartnerCommand | 21 | import org.vibeerp.pbc.partners.application.CreatePartnerCommand |
| 22 | import org.vibeerp.pbc.partners.application.PartnerService | 22 | import org.vibeerp.pbc.partners.application.PartnerService |
| 23 | import org.vibeerp.pbc.partners.domain.PartnerType | 23 | import org.vibeerp.pbc.partners.domain.PartnerType |
| 24 | +import org.vibeerp.pbc.production.application.CreateWorkOrderCommand | ||
| 25 | +import org.vibeerp.pbc.production.application.WorkOrderInputCommand | ||
| 26 | +import org.vibeerp.pbc.production.application.WorkOrderOperationCommand | ||
| 27 | +import org.vibeerp.pbc.production.application.WorkOrderService | ||
| 24 | import org.vibeerp.platform.persistence.security.PrincipalContext | 28 | import org.vibeerp.platform.persistence.security.PrincipalContext |
| 25 | import java.math.BigDecimal | 29 | import java.math.BigDecimal |
| 26 | import java.time.LocalDate | 30 | import java.time.LocalDate |
| 27 | 31 | ||
| 28 | /** | 32 | /** |
| 29 | - * One-shot demo data seeder, gated behind `vibeerp.demo.seed=true`. | 33 | + * One-shot demo data seeder matching the EBC-PP-001 work-order |
| 34 | + * management process from the reference printing company (昆明五彩印务). | ||
| 30 | * | 35 | * |
| 31 | - * **Why this exists.** Out-of-the-box, vibe_erp boots against an | ||
| 32 | - * empty Postgres and the SPA dashboard shows zeros for every PBC. | ||
| 33 | - * Onboarding a new operator (or running a tomorrow-morning demo) | ||
| 34 | - * needs a couple of minutes of clicking to create items, | ||
| 35 | - * locations, partners, and a starting inventory before the | ||
| 36 | - * interesting screens become useful. This runner stages a tiny | ||
| 37 | - * but representative dataset on first boot so the moment the | ||
| 38 | - * bootstrap admin lands on `/`, every page already has rows. | 36 | + * Gated behind `vibeerp.demo.seed=true` (dev profile only). |
| 37 | + * Idempotent via sentinel item check. | ||
| 39 | * | 38 | * |
| 40 | - * **Opt-in by property.** `@ConditionalOnProperty` keeps this | ||
| 41 | - * bean entirely absent from production deployments — only the | ||
| 42 | - * dev profile (`application-dev.yaml` sets `vibeerp.demo.seed: | ||
| 43 | - * true`) opts in. A future release can ship a `--demo` CLI flag | ||
| 44 | - * or a one-time admin "Load demo data" button that flips the | ||
| 45 | - * same property at runtime; for v1 the dev profile is enough. | ||
| 46 | - * | ||
| 47 | - * **Idempotent.** The runner checks for one of its own seeded | ||
| 48 | - * item codes and short-circuits if already present. Restarting | ||
| 49 | - * the dev server is a no-op; deleting the demo data has to | ||
| 50 | - * happen via SQL or by dropping the DB. Idempotency on the | ||
| 51 | - * sentinel item is intentional (vs. on every entity it creates): | ||
| 52 | - * a half-seeded DB from a crashed first run will *not* recover | ||
| 53 | - * cleanly, but that case is exotic and we can clear and retry | ||
| 54 | - * in dev. | ||
| 55 | - * | ||
| 56 | - * **All seeded data shares the `DEMO-` prefix.** Items, partners, | ||
| 57 | - * locations, and order codes all start with `DEMO-`. This makes | ||
| 58 | - * the seeded data trivially distinguishable from anything an | ||
| 59 | - * operator creates by hand later — and gives a future | ||
| 60 | - * "delete demo data" command an obvious filter. | ||
| 61 | - * | ||
| 62 | - * **System principal.** Audit columns need a non-blank | ||
| 63 | - * `created_by`; `PrincipalContext.runAs("__demo_seed__")` wraps | ||
| 64 | - * the entire seed so every row carries that sentinel. The | ||
| 65 | - * authorization aspect (`@RequirePermission`) lives on | ||
| 66 | - * controllers, not services — calling services directly bypasses | ||
| 67 | - * it cleanly, which is correct for system-level seeders. | ||
| 68 | - * | ||
| 69 | - * **Why CommandLineRunner.** Equivalent to `ApplicationRunner` | ||
| 70 | - * here — there are no command-line args this seeder cares about. | ||
| 71 | - * Spring runs every CommandLineRunner once, after the application | ||
| 72 | - * context is fully initialized but before serving traffic, which | ||
| 73 | - * is exactly the right window: services are wired, the schema is | ||
| 74 | - * applied, but the first HTTP request hasn't arrived yet. | ||
| 75 | - * | ||
| 76 | - * **Lives in distribution.** This is the only module that | ||
| 77 | - * already depends on every PBC, which is what the seeder needs | ||
| 78 | - * to compose. It's gated behind a property the production | ||
| 79 | - * application.yaml never sets, so its presence in the fat-jar | ||
| 80 | - * is dormant unless explicitly opted in. | 39 | + * The seeded data demonstrates the core flow from the doc: |
| 40 | + * A-010 Sales order confirmed (SO already in DRAFT, ready to confirm) | ||
| 41 | + * B-010 System auto-generates work orders from confirmed SO lines | ||
| 42 | + * B-020 Work order with BOM + routing (pre-seeded WO-PRINT-0001) | ||
| 43 | + * C-040 Material requisition (BOM inputs consumed on WO complete) | ||
| 44 | + * C-050 Material picking from warehouse (inventory ledger writes) | ||
| 45 | + * E-010 Shop floor tracking (shop-floor dashboard polls IN_PROGRESS WOs) | ||
| 81 | */ | 46 | */ |
| 82 | @Component | 47 | @Component |
| 83 | @ConditionalOnProperty(prefix = "vibeerp.demo", name = ["seed"], havingValue = "true") | 48 | @ConditionalOnProperty(prefix = "vibeerp.demo", name = ["seed"], havingValue = "true") |
| @@ -88,166 +53,170 @@ class DemoSeedRunner( | @@ -88,166 +53,170 @@ class DemoSeedRunner( | ||
| 88 | private val partnerService: PartnerService, | 53 | private val partnerService: PartnerService, |
| 89 | private val salesOrderService: SalesOrderService, | 54 | private val salesOrderService: SalesOrderService, |
| 90 | private val purchaseOrderService: PurchaseOrderService, | 55 | private val purchaseOrderService: PurchaseOrderService, |
| 56 | + private val workOrderService: WorkOrderService, | ||
| 91 | ) : CommandLineRunner { | 57 | ) : CommandLineRunner { |
| 92 | 58 | ||
| 93 | private val log = LoggerFactory.getLogger(DemoSeedRunner::class.java) | 59 | private val log = LoggerFactory.getLogger(DemoSeedRunner::class.java) |
| 94 | 60 | ||
| 95 | @Transactional | 61 | @Transactional |
| 96 | override fun run(vararg args: String?) { | 62 | override fun run(vararg args: String?) { |
| 97 | - if (itemService.findByCode(SENTINEL_ITEM_CODE) != null) { | ||
| 98 | - log.info("Demo seed: data already present (sentinel item {} found); skipping", SENTINEL_ITEM_CODE) | 63 | + if (itemService.findByCode(SENTINEL) != null) { |
| 64 | + log.info("Demo seed: already present ({}); skipping", SENTINEL) | ||
| 99 | return | 65 | return |
| 100 | } | 66 | } |
| 101 | - | ||
| 102 | - log.info("Demo seed: populating starter dataset…") | 67 | + log.info("Demo seed: populating printing-company dataset...") |
| 103 | PrincipalContext.runAs("__demo_seed__") { | 68 | PrincipalContext.runAs("__demo_seed__") { |
| 104 | seedItems() | 69 | seedItems() |
| 105 | seedLocations() | 70 | seedLocations() |
| 106 | seedPartners() | 71 | seedPartners() |
| 107 | seedStock() | 72 | seedStock() |
| 108 | - seedSalesOrder() | 73 | + seedWorkOrder() |
| 74 | + seedSalesOrders() | ||
| 109 | seedPurchaseOrder() | 75 | seedPurchaseOrder() |
| 110 | } | 76 | } |
| 111 | log.info("Demo seed: done") | 77 | log.info("Demo seed: done") |
| 112 | } | 78 | } |
| 113 | 79 | ||
| 114 | - // ─── Items ─────────────────────────────────────────────────────── | 80 | + // ─── Catalog items (printing industry) ─────────────────────────── |
| 115 | 81 | ||
| 116 | private fun seedItems() { | 82 | private fun seedItems() { |
| 117 | - item(SENTINEL_ITEM_CODE, "A4 paper, 80gsm, white", ItemType.GOOD, "sheet") | ||
| 118 | - item("DEMO-INK-CYAN", "Cyan offset ink", ItemType.GOOD, "kg") | ||
| 119 | - item("DEMO-INK-MAGENTA", "Magenta offset ink", ItemType.GOOD, "kg") | ||
| 120 | - item("DEMO-CARD-BIZ", "Business cards, 100/pack", ItemType.GOOD, "pack") | ||
| 121 | - item("DEMO-BROCHURE-A5", "Folded A5 brochure", ItemType.GOOD, "ea") | 83 | + // Raw materials |
| 84 | + item(SENTINEL, "80g A4 white card stock", ItemType.GOOD, "sheet") | ||
| 85 | + item("INK-4C", "4-color offset ink set (CMYK)", ItemType.GOOD, "kg") | ||
| 86 | + item("PLATE-CTP", "CTP printing plate", ItemType.GOOD, "ea") | ||
| 87 | + item("COVER-MATT", "Matt lamination film", ItemType.GOOD, "m2") | ||
| 88 | + // Finished goods | ||
| 89 | + item("BIZ-CARD-250", "Business cards, 250gsm coated, 100/box", ItemType.GOOD, "pack") | ||
| 90 | + item("BROCHURE-A5", "A5 tri-fold brochure, full color", ItemType.GOOD, "ea") | ||
| 91 | + item("POSTER-A3", "A3 promotional poster, glossy", ItemType.GOOD, "ea") | ||
| 122 | } | 92 | } |
| 123 | 93 | ||
| 124 | - private fun item(code: String, name: String, type: ItemType, baseUomCode: String) { | ||
| 125 | - itemService.create( | ||
| 126 | - CreateItemCommand( | ||
| 127 | - code = code, | ||
| 128 | - name = name, | ||
| 129 | - description = null, | ||
| 130 | - itemType = type, | ||
| 131 | - baseUomCode = baseUomCode, | ||
| 132 | - active = true, | ||
| 133 | - ), | ||
| 134 | - ) | 94 | + private fun item(code: String, name: String, type: ItemType, uom: String) { |
| 95 | + itemService.create(CreateItemCommand(code = code, name = name, description = null, itemType = type, baseUomCode = uom)) | ||
| 135 | } | 96 | } |
| 136 | 97 | ||
| 137 | // ─── Locations ─────────────────────────────────────────────────── | 98 | // ─── Locations ─────────────────────────────────────────────────── |
| 138 | 99 | ||
| 139 | private fun seedLocations() { | 100 | private fun seedLocations() { |
| 140 | - location("DEMO-WH-RAW", "Raw materials warehouse") | ||
| 141 | - location("DEMO-WH-FG", "Finished goods warehouse") | 101 | + location("WH-RAW", "Raw materials warehouse") |
| 102 | + location("WH-FG", "Finished goods warehouse") | ||
| 142 | } | 103 | } |
| 143 | 104 | ||
| 144 | private fun location(code: String, name: String) { | 105 | private fun location(code: String, name: String) { |
| 145 | - locationService.create( | ||
| 146 | - CreateLocationCommand( | ||
| 147 | - code = code, | ||
| 148 | - name = name, | ||
| 149 | - type = LocationType.WAREHOUSE, | ||
| 150 | - active = true, | ||
| 151 | - ), | ||
| 152 | - ) | 106 | + locationService.create(CreateLocationCommand(code = code, name = name, type = LocationType.WAREHOUSE)) |
| 153 | } | 107 | } |
| 154 | 108 | ||
| 155 | // ─── Partners ──────────────────────────────────────────────────── | 109 | // ─── Partners ──────────────────────────────────────────────────── |
| 156 | 110 | ||
| 157 | private fun seedPartners() { | 111 | private fun seedPartners() { |
| 158 | - partner("DEMO-CUST-ACME", "Acme Print Co.", PartnerType.CUSTOMER, "ap@acme.example") | ||
| 159 | - partner("DEMO-CUST-GLOBE", "Globe Marketing", PartnerType.CUSTOMER, "ops@globe.example") | ||
| 160 | - partner("DEMO-SUPP-PAPERWORLD", "Paper World Ltd.", PartnerType.SUPPLIER, "sales@paperworld.example") | ||
| 161 | - partner("DEMO-SUPP-INKCO", "InkCo Industries", PartnerType.SUPPLIER, "orders@inkco.example") | 112 | + partner("CUST-WCAD", "Wucai Advertising Co.", PartnerType.CUSTOMER, "info@wucai-ad.example") |
| 113 | + partner("CUST-GLOBE", "Globe Marketing Ltd.", PartnerType.CUSTOMER, "ops@globe.example") | ||
| 114 | + partner("SUPP-HZPAPER", "Huazhong Paper Co.", PartnerType.SUPPLIER, "sales@hzpaper.example") | ||
| 115 | + partner("SUPP-INKPRO", "InkPro Industries", PartnerType.SUPPLIER, "orders@inkpro.example") | ||
| 162 | } | 116 | } |
| 163 | 117 | ||
| 164 | private fun partner(code: String, name: String, type: PartnerType, email: String) { | 118 | private fun partner(code: String, name: String, type: PartnerType, email: String) { |
| 165 | - partnerService.create( | ||
| 166 | - CreatePartnerCommand( | ||
| 167 | - code = code, | ||
| 168 | - name = name, | ||
| 169 | - type = type, | ||
| 170 | - email = email, | ||
| 171 | - active = true, | ||
| 172 | - ), | ||
| 173 | - ) | 119 | + partnerService.create(CreatePartnerCommand(code = code, name = name, type = type, email = email)) |
| 174 | } | 120 | } |
| 175 | 121 | ||
| 176 | - // ─── Initial stock ─────────────────────────────────────────────── | 122 | + // ─── Opening stock ─────────────────────────────────────────────── |
| 177 | 123 | ||
| 178 | private fun seedStock() { | 124 | private fun seedStock() { |
| 179 | - val rawWh = locationService.findByCode("DEMO-WH-RAW")!! | ||
| 180 | - val fgWh = locationService.findByCode("DEMO-WH-FG")!! | 125 | + val raw = locationService.findByCode("WH-RAW")!! |
| 126 | + val fg = locationService.findByCode("WH-FG")!! | ||
| 127 | + | ||
| 128 | + stockBalanceService.adjust(SENTINEL, raw.id, BigDecimal("5000")) | ||
| 129 | + stockBalanceService.adjust("INK-4C", raw.id, BigDecimal("80")) | ||
| 130 | + stockBalanceService.adjust("PLATE-CTP", raw.id, BigDecimal("100")) | ||
| 131 | + stockBalanceService.adjust("COVER-MATT", raw.id, BigDecimal("500")) | ||
| 181 | 132 | ||
| 182 | - stockBalanceService.adjust(SENTINEL_ITEM_CODE, rawWh.id, BigDecimal("5000")) | ||
| 183 | - stockBalanceService.adjust("DEMO-INK-CYAN", rawWh.id, BigDecimal("50")) | ||
| 184 | - stockBalanceService.adjust("DEMO-INK-MAGENTA", rawWh.id, BigDecimal("50")) | 133 | + stockBalanceService.adjust("BIZ-CARD-250", fg.id, BigDecimal("200")) |
| 134 | + stockBalanceService.adjust("BROCHURE-A5", fg.id, BigDecimal("150")) | ||
| 135 | + stockBalanceService.adjust("POSTER-A3", fg.id, BigDecimal("50")) | ||
| 136 | + } | ||
| 185 | 137 | ||
| 186 | - stockBalanceService.adjust("DEMO-CARD-BIZ", fgWh.id, BigDecimal("200")) | ||
| 187 | - stockBalanceService.adjust("DEMO-BROCHURE-A5", fgWh.id, BigDecimal("100")) | 138 | + // ─── Pre-seeded work order with BOM + routing ──────────────────── |
| 139 | + // | ||
| 140 | + // Matches the EBC-PP-001 flow: a production work order for | ||
| 141 | + // business cards with 3 BOM inputs and a 3-step routing | ||
| 142 | + // (CTP plate-making → offset printing → post-press finishing). | ||
| 143 | + // Left in DRAFT so the demo operator can start it, walk the | ||
| 144 | + // operations on the shop-floor dashboard, and complete it. | ||
| 145 | + | ||
| 146 | + private fun seedWorkOrder() { | ||
| 147 | + workOrderService.create( | ||
| 148 | + CreateWorkOrderCommand( | ||
| 149 | + code = "WO-PRINT-0001", | ||
| 150 | + outputItemCode = "BIZ-CARD-250", | ||
| 151 | + outputQuantity = BigDecimal("50"), | ||
| 152 | + dueDate = LocalDate.now().plusDays(3), | ||
| 153 | + inputs = listOf( | ||
| 154 | + WorkOrderInputCommand(lineNo = 1, itemCode = SENTINEL, quantityPerUnit = BigDecimal("10"), sourceLocationCode = "WH-RAW"), | ||
| 155 | + WorkOrderInputCommand(lineNo = 2, itemCode = "INK-4C", quantityPerUnit = BigDecimal("0.2"), sourceLocationCode = "WH-RAW"), | ||
| 156 | + WorkOrderInputCommand(lineNo = 3, itemCode = "PLATE-CTP", quantityPerUnit = BigDecimal("1"), sourceLocationCode = "WH-RAW"), | ||
| 157 | + ), | ||
| 158 | + operations = listOf( | ||
| 159 | + WorkOrderOperationCommand(lineNo = 1, operationCode = "CTP", workCenter = "CTP-ROOM-01", standardMinutes = BigDecimal("30")), | ||
| 160 | + WorkOrderOperationCommand(lineNo = 2, operationCode = "PRINT", workCenter = "PRESS-A", standardMinutes = BigDecimal("45")), | ||
| 161 | + WorkOrderOperationCommand(lineNo = 3, operationCode = "FINISH", workCenter = "BIND-01", standardMinutes = BigDecimal("20")), | ||
| 162 | + ), | ||
| 163 | + ), | ||
| 164 | + ) | ||
| 188 | } | 165 | } |
| 189 | 166 | ||
| 190 | - // ─── Open sales order (DRAFT — ready to confirm + ship) ────────── | 167 | + // ─── Sales orders (DRAFT — confirm to auto-spawn work orders) ─── |
| 168 | + // | ||
| 169 | + // Two orders for different customers. Confirming either one | ||
| 170 | + // triggers SalesOrderConfirmedSubscriber in pbc-production, | ||
| 171 | + // which auto-creates one DRAFT work order per SO line (the | ||
| 172 | + // B-010 step from EBC-PP-001). The demo operator watches the | ||
| 173 | + // work order count jump on the dashboard after clicking Confirm. | ||
| 191 | 174 | ||
| 192 | - private fun seedSalesOrder() { | 175 | + private fun seedSalesOrders() { |
| 176 | + salesOrderService.create( | ||
| 177 | + CreateSalesOrderCommand( | ||
| 178 | + code = "SO-2026-0001", | ||
| 179 | + partnerCode = "CUST-WCAD", | ||
| 180 | + orderDate = LocalDate.now(), | ||
| 181 | + currencyCode = "USD", | ||
| 182 | + lines = listOf( | ||
| 183 | + SalesOrderLineCommand(lineNo = 1, itemCode = "BIZ-CARD-250", quantity = BigDecimal("100"), unitPrice = BigDecimal("8.50"), currencyCode = "USD"), | ||
| 184 | + SalesOrderLineCommand(lineNo = 2, itemCode = "BROCHURE-A5", quantity = BigDecimal("500"), unitPrice = BigDecimal("2.20"), currencyCode = "USD"), | ||
| 185 | + ), | ||
| 186 | + ), | ||
| 187 | + ) | ||
| 193 | salesOrderService.create( | 188 | salesOrderService.create( |
| 194 | CreateSalesOrderCommand( | 189 | CreateSalesOrderCommand( |
| 195 | - code = "DEMO-SO-0001", | ||
| 196 | - partnerCode = "DEMO-CUST-ACME", | 190 | + code = "SO-2026-0002", |
| 191 | + partnerCode = "CUST-GLOBE", | ||
| 197 | orderDate = LocalDate.now(), | 192 | orderDate = LocalDate.now(), |
| 198 | currencyCode = "USD", | 193 | currencyCode = "USD", |
| 199 | lines = listOf( | 194 | lines = listOf( |
| 200 | - SalesOrderLineCommand( | ||
| 201 | - lineNo = 1, | ||
| 202 | - itemCode = "DEMO-CARD-BIZ", | ||
| 203 | - quantity = BigDecimal("50"), | ||
| 204 | - unitPrice = BigDecimal("12.50"), | ||
| 205 | - currencyCode = "USD", | ||
| 206 | - ), | ||
| 207 | - SalesOrderLineCommand( | ||
| 208 | - lineNo = 2, | ||
| 209 | - itemCode = "DEMO-BROCHURE-A5", | ||
| 210 | - quantity = BigDecimal("20"), | ||
| 211 | - unitPrice = BigDecimal("4.75"), | ||
| 212 | - currencyCode = "USD", | ||
| 213 | - ), | 195 | + SalesOrderLineCommand(lineNo = 1, itemCode = "POSTER-A3", quantity = BigDecimal("200"), unitPrice = BigDecimal("3.80"), currencyCode = "USD"), |
| 214 | ), | 196 | ), |
| 215 | ), | 197 | ), |
| 216 | ) | 198 | ) |
| 217 | } | 199 | } |
| 218 | 200 | ||
| 219 | - // ─── Open purchase order (DRAFT — ready to confirm + receive) ──── | 201 | + // ─── Purchase order (DRAFT — confirm + receive to restock) ─────── |
| 220 | 202 | ||
| 221 | private fun seedPurchaseOrder() { | 203 | private fun seedPurchaseOrder() { |
| 222 | purchaseOrderService.create( | 204 | purchaseOrderService.create( |
| 223 | CreatePurchaseOrderCommand( | 205 | CreatePurchaseOrderCommand( |
| 224 | - code = "DEMO-PO-0001", | ||
| 225 | - partnerCode = "DEMO-SUPP-PAPERWORLD", | 206 | + code = "PO-2026-0001", |
| 207 | + partnerCode = "SUPP-HZPAPER", | ||
| 226 | orderDate = LocalDate.now(), | 208 | orderDate = LocalDate.now(), |
| 227 | - expectedDate = LocalDate.now().plusDays(7), | 209 | + expectedDate = LocalDate.now().plusDays(5), |
| 228 | currencyCode = "USD", | 210 | currencyCode = "USD", |
| 229 | lines = listOf( | 211 | lines = listOf( |
| 230 | - PurchaseOrderLineCommand( | ||
| 231 | - lineNo = 1, | ||
| 232 | - itemCode = SENTINEL_ITEM_CODE, | ||
| 233 | - quantity = BigDecimal("10000"), | ||
| 234 | - unitPrice = BigDecimal("0.04"), | ||
| 235 | - currencyCode = "USD", | ||
| 236 | - ), | 212 | + PurchaseOrderLineCommand(lineNo = 1, itemCode = SENTINEL, quantity = BigDecimal("10000"), unitPrice = BigDecimal("0.03"), currencyCode = "USD"), |
| 213 | + PurchaseOrderLineCommand(lineNo = 2, itemCode = "INK-4C", quantity = BigDecimal("50"), unitPrice = BigDecimal("45.00"), currencyCode = "USD"), | ||
| 237 | ), | 214 | ), |
| 238 | ), | 215 | ), |
| 239 | ) | 216 | ) |
| 240 | } | 217 | } |
| 241 | 218 | ||
| 242 | companion object { | 219 | companion object { |
| 243 | - /** | ||
| 244 | - * The seeder uses the presence of this item as the | ||
| 245 | - * idempotency marker — re-running the seeder against a | ||
| 246 | - * Postgres that already contains it short-circuits. The | ||
| 247 | - * choice of "the very first item the seeder creates" is | ||
| 248 | - * deliberate: if the seed transaction commits at all, this | ||
| 249 | - * row is in the DB; if it doesn't, nothing is. | ||
| 250 | - */ | ||
| 251 | - const val SENTINEL_ITEM_CODE: String = "DEMO-PAPER-A4" | 220 | + const val SENTINEL: String = "PAPER-80G-A4" |
| 252 | } | 221 | } |
| 253 | } | 222 | } |
web/src/App.tsx
| @@ -20,6 +20,7 @@ import { LocationsPage } from '@/pages/LocationsPage' | @@ -20,6 +20,7 @@ import { LocationsPage } from '@/pages/LocationsPage' | ||
| 20 | import { BalancesPage } from '@/pages/BalancesPage' | 20 | import { BalancesPage } from '@/pages/BalancesPage' |
| 21 | import { MovementsPage } from '@/pages/MovementsPage' | 21 | import { MovementsPage } from '@/pages/MovementsPage' |
| 22 | import { SalesOrdersPage } from '@/pages/SalesOrdersPage' | 22 | import { SalesOrdersPage } from '@/pages/SalesOrdersPage' |
| 23 | +import { CreateSalesOrderPage } from '@/pages/CreateSalesOrderPage' | ||
| 23 | import { SalesOrderDetailPage } from '@/pages/SalesOrderDetailPage' | 24 | import { SalesOrderDetailPage } from '@/pages/SalesOrderDetailPage' |
| 24 | import { PurchaseOrdersPage } from '@/pages/PurchaseOrdersPage' | 25 | import { PurchaseOrdersPage } from '@/pages/PurchaseOrdersPage' |
| 25 | import { PurchaseOrderDetailPage } from '@/pages/PurchaseOrderDetailPage' | 26 | import { PurchaseOrderDetailPage } from '@/pages/PurchaseOrderDetailPage' |
| @@ -48,6 +49,7 @@ export default function App() { | @@ -48,6 +49,7 @@ export default function App() { | ||
| 48 | <Route path="balances" element={<BalancesPage />} /> | 49 | <Route path="balances" element={<BalancesPage />} /> |
| 49 | <Route path="movements" element={<MovementsPage />} /> | 50 | <Route path="movements" element={<MovementsPage />} /> |
| 50 | <Route path="sales-orders" element={<SalesOrdersPage />} /> | 51 | <Route path="sales-orders" element={<SalesOrdersPage />} /> |
| 52 | + <Route path="sales-orders/new" element={<CreateSalesOrderPage />} /> | ||
| 51 | <Route path="sales-orders/:id" element={<SalesOrderDetailPage />} /> | 53 | <Route path="sales-orders/:id" element={<SalesOrderDetailPage />} /> |
| 52 | <Route path="purchase-orders" element={<PurchaseOrdersPage />} /> | 54 | <Route path="purchase-orders" element={<PurchaseOrdersPage />} /> |
| 53 | <Route path="purchase-orders/:id" element={<PurchaseOrderDetailPage />} /> | 55 | <Route path="purchase-orders/:id" element={<PurchaseOrderDetailPage />} /> |
web/src/api/client.ts
| @@ -159,6 +159,17 @@ export const inventory = { | @@ -159,6 +159,17 @@ export const inventory = { | ||
| 159 | export const salesOrders = { | 159 | export const salesOrders = { |
| 160 | list: () => apiFetch<SalesOrder[]>('/api/v1/orders/sales-orders'), | 160 | list: () => apiFetch<SalesOrder[]>('/api/v1/orders/sales-orders'), |
| 161 | get: (id: string) => apiFetch<SalesOrder>(`/api/v1/orders/sales-orders/${id}`), | 161 | get: (id: string) => apiFetch<SalesOrder>(`/api/v1/orders/sales-orders/${id}`), |
| 162 | + create: (body: { | ||
| 163 | + code: string | ||
| 164 | + partnerCode: string | ||
| 165 | + orderDate: string | ||
| 166 | + currencyCode: string | ||
| 167 | + lines: { lineNo: number; itemCode: string; quantity: number; unitPrice: number; currencyCode: string }[] | ||
| 168 | + }) => | ||
| 169 | + apiFetch<SalesOrder>('/api/v1/orders/sales-orders', { | ||
| 170 | + method: 'POST', | ||
| 171 | + body: JSON.stringify(body), | ||
| 172 | + }), | ||
| 162 | confirm: (id: string) => | 173 | confirm: (id: string) => |
| 163 | apiFetch<SalesOrder>(`/api/v1/orders/sales-orders/${id}/confirm`, { | 174 | apiFetch<SalesOrder>(`/api/v1/orders/sales-orders/${id}/confirm`, { |
| 164 | method: 'POST', | 175 | method: 'POST', |
web/src/pages/CreateSalesOrderPage.tsx
0 → 100644
| 1 | +import { useEffect, useState, type FormEvent } from 'react' | ||
| 2 | +import { useNavigate } from 'react-router-dom' | ||
| 3 | +import { catalog, partners, salesOrders } from '@/api/client' | ||
| 4 | +import type { Item, Partner } from '@/types/api' | ||
| 5 | +import { PageHeader } from '@/components/PageHeader' | ||
| 6 | +import { ErrorBox } from '@/components/ErrorBox' | ||
| 7 | + | ||
| 8 | +interface LineInput { | ||
| 9 | + itemCode: string | ||
| 10 | + quantity: string | ||
| 11 | + unitPrice: string | ||
| 12 | +} | ||
| 13 | + | ||
| 14 | +export function CreateSalesOrderPage() { | ||
| 15 | + const navigate = useNavigate() | ||
| 16 | + const [code, setCode] = useState('') | ||
| 17 | + const [partnerCode, setPartnerCode] = useState('') | ||
| 18 | + const [currencyCode] = useState('USD') | ||
| 19 | + const [lines, setLines] = useState<LineInput[]>([ | ||
| 20 | + { itemCode: '', quantity: '', unitPrice: '' }, | ||
| 21 | + ]) | ||
| 22 | + const [items, setItems] = useState<Item[]>([]) | ||
| 23 | + const [partnerList, setPartnerList] = useState<Partner[]>([]) | ||
| 24 | + const [submitting, setSubmitting] = useState(false) | ||
| 25 | + const [error, setError] = useState<Error | null>(null) | ||
| 26 | + | ||
| 27 | + useEffect(() => { | ||
| 28 | + Promise.all([catalog.listItems(), partners.list()]).then(([i, p]) => { | ||
| 29 | + setItems(i) | ||
| 30 | + const customers = p.filter((x) => x.type === 'CUSTOMER' || x.type === 'BOTH') | ||
| 31 | + setPartnerList(customers) | ||
| 32 | + if (customers.length > 0 && !partnerCode) setPartnerCode(customers[0].code) | ||
| 33 | + }) | ||
| 34 | + }, []) // eslint-disable-line react-hooks/exhaustive-deps | ||
| 35 | + | ||
| 36 | + const addLine = () => | ||
| 37 | + setLines([...lines, { itemCode: items[0]?.code ?? '', quantity: '1', unitPrice: '1.00' }]) | ||
| 38 | + | ||
| 39 | + const removeLine = (idx: number) => { | ||
| 40 | + if (lines.length <= 1) return | ||
| 41 | + setLines(lines.filter((_, i) => i !== idx)) | ||
| 42 | + } | ||
| 43 | + | ||
| 44 | + const updateLine = (idx: number, field: keyof LineInput, value: string) => { | ||
| 45 | + const next = [...lines] | ||
| 46 | + next[idx] = { ...next[idx], [field]: value } | ||
| 47 | + setLines(next) | ||
| 48 | + } | ||
| 49 | + | ||
| 50 | + const onSubmit = async (e: FormEvent) => { | ||
| 51 | + e.preventDefault() | ||
| 52 | + setError(null) | ||
| 53 | + setSubmitting(true) | ||
| 54 | + try { | ||
| 55 | + const created = await salesOrders.create({ | ||
| 56 | + code, | ||
| 57 | + partnerCode, | ||
| 58 | + orderDate: new Date().toISOString().slice(0, 10), | ||
| 59 | + currencyCode, | ||
| 60 | + lines: lines.map((l, i) => ({ | ||
| 61 | + lineNo: i + 1, | ||
| 62 | + itemCode: l.itemCode, | ||
| 63 | + quantity: Number(l.quantity), | ||
| 64 | + unitPrice: Number(l.unitPrice), | ||
| 65 | + currencyCode, | ||
| 66 | + })), | ||
| 67 | + }) | ||
| 68 | + navigate(`/sales-orders/${created.id}`) | ||
| 69 | + } catch (err: unknown) { | ||
| 70 | + setError(err instanceof Error ? err : new Error(String(err))) | ||
| 71 | + } finally { | ||
| 72 | + setSubmitting(false) | ||
| 73 | + } | ||
| 74 | + } | ||
| 75 | + | ||
| 76 | + return ( | ||
| 77 | + <div> | ||
| 78 | + <PageHeader | ||
| 79 | + title="New Sales Order" | ||
| 80 | + subtitle="Create a sales order. Confirming it will auto-generate production work orders." | ||
| 81 | + actions={ | ||
| 82 | + <button className="btn-secondary" onClick={() => navigate('/sales-orders')}> | ||
| 83 | + Cancel | ||
| 84 | + </button> | ||
| 85 | + } | ||
| 86 | + /> | ||
| 87 | + <form onSubmit={onSubmit} className="card p-6 space-y-5 max-w-3xl"> | ||
| 88 | + <div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> | ||
| 89 | + <div> | ||
| 90 | + <label className="block text-sm font-medium text-slate-700">Order code</label> | ||
| 91 | + <input | ||
| 92 | + type="text" | ||
| 93 | + required | ||
| 94 | + value={code} | ||
| 95 | + onChange={(e) => setCode(e.target.value)} | ||
| 96 | + placeholder="SO-2026-0003" | ||
| 97 | + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-brand-500 focus:ring-brand-500" | ||
| 98 | + /> | ||
| 99 | + </div> | ||
| 100 | + <div> | ||
| 101 | + <label className="block text-sm font-medium text-slate-700">Customer</label> | ||
| 102 | + <select | ||
| 103 | + required | ||
| 104 | + value={partnerCode} | ||
| 105 | + onChange={(e) => setPartnerCode(e.target.value)} | ||
| 106 | + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-brand-500 focus:ring-brand-500" | ||
| 107 | + > | ||
| 108 | + {partnerList.map((p) => ( | ||
| 109 | + <option key={p.id} value={p.code}> | ||
| 110 | + {p.code} — {p.name} | ||
| 111 | + </option> | ||
| 112 | + ))} | ||
| 113 | + </select> | ||
| 114 | + </div> | ||
| 115 | + <div> | ||
| 116 | + <label className="block text-sm font-medium text-slate-700">Currency</label> | ||
| 117 | + <input | ||
| 118 | + type="text" | ||
| 119 | + value={currencyCode} | ||
| 120 | + disabled | ||
| 121 | + className="mt-1 w-full rounded-md border border-slate-200 bg-slate-50 px-3 py-2 text-sm" | ||
| 122 | + /> | ||
| 123 | + </div> | ||
| 124 | + </div> | ||
| 125 | + | ||
| 126 | + <div> | ||
| 127 | + <div className="flex items-center justify-between mb-2"> | ||
| 128 | + <label className="text-sm font-medium text-slate-700">Order lines</label> | ||
| 129 | + <button type="button" className="btn-secondary text-xs" onClick={addLine}> | ||
| 130 | + + Add line | ||
| 131 | + </button> | ||
| 132 | + </div> | ||
| 133 | + <div className="space-y-2"> | ||
| 134 | + {lines.map((line, idx) => ( | ||
| 135 | + <div key={idx} className="flex items-center gap-2"> | ||
| 136 | + <span className="w-6 text-xs text-slate-400 text-right">{idx + 1}</span> | ||
| 137 | + <select | ||
| 138 | + value={line.itemCode} | ||
| 139 | + onChange={(e) => updateLine(idx, 'itemCode', e.target.value)} | ||
| 140 | + className="flex-1 rounded-md border border-slate-300 px-2 py-1.5 text-sm" | ||
| 141 | + > | ||
| 142 | + <option value="">Select item...</option> | ||
| 143 | + {items.map((it) => ( | ||
| 144 | + <option key={it.id} value={it.code}> | ||
| 145 | + {it.code} — {it.name} | ||
| 146 | + </option> | ||
| 147 | + ))} | ||
| 148 | + </select> | ||
| 149 | + <input | ||
| 150 | + type="number" | ||
| 151 | + min="1" | ||
| 152 | + step="1" | ||
| 153 | + placeholder="Qty" | ||
| 154 | + value={line.quantity} | ||
| 155 | + onChange={(e) => updateLine(idx, 'quantity', e.target.value)} | ||
| 156 | + className="w-20 rounded-md border border-slate-300 px-2 py-1.5 text-sm text-right" | ||
| 157 | + /> | ||
| 158 | + <input | ||
| 159 | + type="number" | ||
| 160 | + min="0" | ||
| 161 | + step="0.01" | ||
| 162 | + placeholder="Price" | ||
| 163 | + value={line.unitPrice} | ||
| 164 | + onChange={(e) => updateLine(idx, 'unitPrice', e.target.value)} | ||
| 165 | + className="w-24 rounded-md border border-slate-300 px-2 py-1.5 text-sm text-right" | ||
| 166 | + /> | ||
| 167 | + <button | ||
| 168 | + type="button" | ||
| 169 | + className="text-slate-400 hover:text-rose-500" | ||
| 170 | + onClick={() => removeLine(idx)} | ||
| 171 | + title="Remove line" | ||
| 172 | + > | ||
| 173 | + × | ||
| 174 | + </button> | ||
| 175 | + </div> | ||
| 176 | + ))} | ||
| 177 | + </div> | ||
| 178 | + </div> | ||
| 179 | + | ||
| 180 | + {error && <ErrorBox error={error} />} | ||
| 181 | + | ||
| 182 | + <div className="flex items-center gap-3 pt-2"> | ||
| 183 | + <button type="submit" className="btn-primary" disabled={submitting}> | ||
| 184 | + {submitting ? 'Creating...' : 'Create Sales Order'} | ||
| 185 | + </button> | ||
| 186 | + <span className="text-xs text-slate-400"> | ||
| 187 | + After creation, confirm the order to auto-generate work orders. | ||
| 188 | + </span> | ||
| 189 | + </div> | ||
| 190 | + </form> | ||
| 191 | + </div> | ||
| 192 | + ) | ||
| 193 | +} |
web/src/pages/DashboardPage.tsx
| @@ -100,25 +100,48 @@ export function DashboardPage() { | @@ -100,25 +100,48 @@ export function DashboardPage() { | ||
| 100 | </div> | 100 | </div> |
| 101 | )} | 101 | )} |
| 102 | <div className="mt-8 card p-5 text-sm text-slate-600"> | 102 | <div className="mt-8 card p-5 text-sm text-slate-600"> |
| 103 | - <h2 className="mb-2 text-base font-semibold text-slate-800">Try the demo</h2> | ||
| 104 | - <ol className="list-decimal space-y-1 pl-5"> | 103 | + <h2 className="mb-2 text-base font-semibold text-slate-800"> |
| 104 | + Demo: EBC-PP-001 Work Order Management Flow | ||
| 105 | + </h2> | ||
| 106 | + <p className="mb-3 text-slate-500"> | ||
| 107 | + Walk the printing company's production flow end-to-end — sales order to finished goods. | ||
| 108 | + </p> | ||
| 109 | + <ol className="list-decimal space-y-2 pl-5"> | ||
| 105 | <li> | 110 | <li> |
| 106 | - Open a <Link to="/sales-orders" className="text-brand-600 hover:underline">sales order</Link>{' '} | ||
| 107 | - in DRAFT, click <span className="font-mono">Confirm</span>. | 111 | + <strong>Create or open a sales order</strong> —{' '} |
| 112 | + <Link to="/sales-orders/new" className="text-brand-600 hover:underline"> | ||
| 113 | + create a new order | ||
| 114 | + </Link>{' '} | ||
| 115 | + or open an existing one from the{' '} | ||
| 116 | + <Link to="/sales-orders" className="text-brand-600 hover:underline">list</Link>. | ||
| 108 | </li> | 117 | </li> |
| 109 | <li> | 118 | <li> |
| 110 | - With the same sales order CONFIRMED, click <span className="font-mono">Ship</span>. | ||
| 111 | - The framework atomically debits stock, flips status to SHIPPED, and emits a domain | ||
| 112 | - event. | 119 | + <strong>Confirm the order</strong> — click{' '} |
| 120 | + <span className="font-mono bg-slate-100 px-1 rounded">Confirm</span>. The system | ||
| 121 | + auto-generates one production work order per line (EBC-PP-001 step B-010). Check the{' '} | ||
| 122 | + <Link to="/work-orders" className="text-brand-600 hover:underline">Work Orders</Link>{' '} | ||
| 123 | + page to see them appear. Finance posts an AR entry automatically. | ||
| 113 | </li> | 124 | </li> |
| 114 | <li> | 125 | <li> |
| 115 | - Watch <Link to="/balances" className="text-brand-600 hover:underline">stock balances</Link>{' '} | ||
| 116 | - drop, <Link to="/movements" className="text-brand-600 hover:underline">movements</Link>{' '} | ||
| 117 | - grow by one row, and{' '} | ||
| 118 | - <Link to="/journal-entries" className="text-brand-600 hover:underline"> | ||
| 119 | - journal entries | ||
| 120 | - </Link>{' '} | ||
| 121 | - settle from POSTED to SETTLED — all from the cross-PBC event subscriber in pbc-finance. | 126 | + <strong>Walk the pre-seeded work order</strong> — open{' '} |
| 127 | + <Link to="/work-orders" className="text-brand-600 hover:underline">WO-PRINT-0001</Link>. | ||
| 128 | + It has a 3-step routing (CTP plate-making → offset printing → post-press finishing) and | ||
| 129 | + a BOM with paper, ink, and CTP plates. Click <span className="font-mono bg-slate-100 px-1 rounded">Start</span>{' '} | ||
| 130 | + to put it in progress, then watch it on the{' '} | ||
| 131 | + <Link to="/shop-floor" className="text-brand-600 hover:underline">Shop Floor</Link> dashboard. | ||
| 132 | + </li> | ||
| 133 | + <li> | ||
| 134 | + <strong>Complete the work order</strong> — materials are consumed from the warehouse, | ||
| 135 | + finished goods are credited. Check{' '} | ||
| 136 | + <Link to="/balances" className="text-brand-600 hover:underline">Stock Balances</Link>{' '} | ||
| 137 | + and{' '} | ||
| 138 | + <Link to="/movements" className="text-brand-600 hover:underline">Movements</Link>{' '} | ||
| 139 | + to see the ledger entries. | ||
| 140 | + </li> | ||
| 141 | + <li> | ||
| 142 | + <strong>Ship the sales order</strong> — finished goods leave the warehouse, the AR | ||
| 143 | + journal entry settles from POSTED to SETTLED in{' '} | ||
| 144 | + <Link to="/journal-entries" className="text-brand-600 hover:underline">Finance</Link>. | ||
| 122 | </li> | 145 | </li> |
| 123 | </ol> | 146 | </ol> |
| 124 | </div> | 147 | </div> |
web/src/pages/SalesOrdersPage.tsx
| @@ -56,7 +56,13 @@ export function SalesOrdersPage() { | @@ -56,7 +56,13 @@ export function SalesOrdersPage() { | ||
| 56 | 56 | ||
| 57 | return ( | 57 | return ( |
| 58 | <div> | 58 | <div> |
| 59 | - <PageHeader title="Sales Orders" subtitle="Customer-facing orders. Click a code to drill in." /> | 59 | + <PageHeader |
| 60 | + title="Sales Orders" | ||
| 61 | + subtitle="Customer-facing orders. Confirm to auto-generate production work orders." | ||
| 62 | + actions={ | ||
| 63 | + <Link to="/sales-orders/new" className="btn-primary">+ New Order</Link> | ||
| 64 | + } | ||
| 65 | + /> | ||
| 60 | {loading && <Loading />} | 66 | {loading && <Loading />} |
| 61 | {error && <ErrorBox error={error} />} | 67 | {error && <ErrorBox error={error} />} |
| 62 | {!loading && !error && <DataTable rows={rows} columns={columns} />} | 68 | {!loading && !error && <DataTable rows={rows} columns={columns} />} |