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 | 21 | import org.vibeerp.pbc.partners.application.CreatePartnerCommand |
| 22 | 22 | import org.vibeerp.pbc.partners.application.PartnerService |
| 23 | 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 | 28 | import org.vibeerp.platform.persistence.security.PrincipalContext |
| 25 | 29 | import java.math.BigDecimal |
| 26 | 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 | 47 | @Component |
| 83 | 48 | @ConditionalOnProperty(prefix = "vibeerp.demo", name = ["seed"], havingValue = "true") |
| ... | ... | @@ -88,166 +53,170 @@ class DemoSeedRunner( |
| 88 | 53 | private val partnerService: PartnerService, |
| 89 | 54 | private val salesOrderService: SalesOrderService, |
| 90 | 55 | private val purchaseOrderService: PurchaseOrderService, |
| 56 | + private val workOrderService: WorkOrderService, | |
| 91 | 57 | ) : CommandLineRunner { |
| 92 | 58 | |
| 93 | 59 | private val log = LoggerFactory.getLogger(DemoSeedRunner::class.java) |
| 94 | 60 | |
| 95 | 61 | @Transactional |
| 96 | 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 | 65 | return |
| 100 | 66 | } |
| 101 | - | |
| 102 | - log.info("Demo seed: populating starter dataset…") | |
| 67 | + log.info("Demo seed: populating printing-company dataset...") | |
| 103 | 68 | PrincipalContext.runAs("__demo_seed__") { |
| 104 | 69 | seedItems() |
| 105 | 70 | seedLocations() |
| 106 | 71 | seedPartners() |
| 107 | 72 | seedStock() |
| 108 | - seedSalesOrder() | |
| 73 | + seedWorkOrder() | |
| 74 | + seedSalesOrders() | |
| 109 | 75 | seedPurchaseOrder() |
| 110 | 76 | } |
| 111 | 77 | log.info("Demo seed: done") |
| 112 | 78 | } |
| 113 | 79 | |
| 114 | - // ─── Items ─────────────────────────────────────────────────────── | |
| 80 | + // ─── Catalog items (printing industry) ─────────────────────────── | |
| 115 | 81 | |
| 116 | 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 | 98 | // ─── Locations ─────────────────────────────────────────────────── |
| 138 | 99 | |
| 139 | 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 | 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 | 109 | // ─── Partners ──────────────────────────────────────────────────── |
| 156 | 110 | |
| 157 | 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 | 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 | 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 | 188 | salesOrderService.create( |
| 194 | 189 | CreateSalesOrderCommand( |
| 195 | - code = "DEMO-SO-0001", | |
| 196 | - partnerCode = "DEMO-CUST-ACME", | |
| 190 | + code = "SO-2026-0002", | |
| 191 | + partnerCode = "CUST-GLOBE", | |
| 197 | 192 | orderDate = LocalDate.now(), |
| 198 | 193 | currencyCode = "USD", |
| 199 | 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 | 203 | private fun seedPurchaseOrder() { |
| 222 | 204 | purchaseOrderService.create( |
| 223 | 205 | CreatePurchaseOrderCommand( |
| 224 | - code = "DEMO-PO-0001", | |
| 225 | - partnerCode = "DEMO-SUPP-PAPERWORLD", | |
| 206 | + code = "PO-2026-0001", | |
| 207 | + partnerCode = "SUPP-HZPAPER", | |
| 226 | 208 | orderDate = LocalDate.now(), |
| 227 | - expectedDate = LocalDate.now().plusDays(7), | |
| 209 | + expectedDate = LocalDate.now().plusDays(5), | |
| 228 | 210 | currencyCode = "USD", |
| 229 | 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 | 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 | 20 | import { BalancesPage } from '@/pages/BalancesPage' |
| 21 | 21 | import { MovementsPage } from '@/pages/MovementsPage' |
| 22 | 22 | import { SalesOrdersPage } from '@/pages/SalesOrdersPage' |
| 23 | +import { CreateSalesOrderPage } from '@/pages/CreateSalesOrderPage' | |
| 23 | 24 | import { SalesOrderDetailPage } from '@/pages/SalesOrderDetailPage' |
| 24 | 25 | import { PurchaseOrdersPage } from '@/pages/PurchaseOrdersPage' |
| 25 | 26 | import { PurchaseOrderDetailPage } from '@/pages/PurchaseOrderDetailPage' |
| ... | ... | @@ -48,6 +49,7 @@ export default function App() { |
| 48 | 49 | <Route path="balances" element={<BalancesPage />} /> |
| 49 | 50 | <Route path="movements" element={<MovementsPage />} /> |
| 50 | 51 | <Route path="sales-orders" element={<SalesOrdersPage />} /> |
| 52 | + <Route path="sales-orders/new" element={<CreateSalesOrderPage />} /> | |
| 51 | 53 | <Route path="sales-orders/:id" element={<SalesOrderDetailPage />} /> |
| 52 | 54 | <Route path="purchase-orders" element={<PurchaseOrdersPage />} /> |
| 53 | 55 | <Route path="purchase-orders/:id" element={<PurchaseOrderDetailPage />} /> | ... | ... |
web/src/api/client.ts
| ... | ... | @@ -159,6 +159,17 @@ export const inventory = { |
| 159 | 159 | export const salesOrders = { |
| 160 | 160 | list: () => apiFetch<SalesOrder[]>('/api/v1/orders/sales-orders'), |
| 161 | 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 | 173 | confirm: (id: string) => |
| 163 | 174 | apiFetch<SalesOrder>(`/api/v1/orders/sales-orders/${id}/confirm`, { |
| 164 | 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 | 100 | </div> |
| 101 | 101 | )} |
| 102 | 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 | 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 | 117 | </li> |
| 109 | 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 | 124 | </li> |
| 114 | 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 | 145 | </li> |
| 123 | 146 | </ol> |
| 124 | 147 | </div> | ... | ... |
web/src/pages/SalesOrdersPage.tsx
| ... | ... | @@ -56,7 +56,13 @@ export function SalesOrdersPage() { |
| 56 | 56 | |
| 57 | 57 | return ( |
| 58 | 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 | 66 | {loading && <Loading />} |
| 61 | 67 | {error && <ErrorBox error={error} />} |
| 62 | 68 | {!loading && !error && <DataTable rows={rows} columns={columns} />} | ... | ... |