You need to sign in before continuing.

Commit 7b0d2f8c2cd7ec814fe37e519885a5d8845c16cc

Authored by zichun
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.
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 + &times;
  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} />}