diff --git a/distribution/src/main/kotlin/org/vibeerp/demo/DemoSeedRunner.kt b/distribution/src/main/kotlin/org/vibeerp/demo/DemoSeedRunner.kt index 5c22898..7b0a88c 100644 --- a/distribution/src/main/kotlin/org/vibeerp/demo/DemoSeedRunner.kt +++ b/distribution/src/main/kotlin/org/vibeerp/demo/DemoSeedRunner.kt @@ -21,63 +21,28 @@ import org.vibeerp.pbc.orders.sales.application.SalesOrderService import org.vibeerp.pbc.partners.application.CreatePartnerCommand import org.vibeerp.pbc.partners.application.PartnerService import org.vibeerp.pbc.partners.domain.PartnerType +import org.vibeerp.pbc.production.application.CreateWorkOrderCommand +import org.vibeerp.pbc.production.application.WorkOrderInputCommand +import org.vibeerp.pbc.production.application.WorkOrderOperationCommand +import org.vibeerp.pbc.production.application.WorkOrderService import org.vibeerp.platform.persistence.security.PrincipalContext import java.math.BigDecimal import java.time.LocalDate /** - * One-shot demo data seeder, gated behind `vibeerp.demo.seed=true`. + * One-shot demo data seeder matching the EBC-PP-001 work-order + * management process from the reference printing company (昆明五彩印务). * - * **Why this exists.** Out-of-the-box, vibe_erp boots against an - * empty Postgres and the SPA dashboard shows zeros for every PBC. - * Onboarding a new operator (or running a tomorrow-morning demo) - * needs a couple of minutes of clicking to create items, - * locations, partners, and a starting inventory before the - * interesting screens become useful. This runner stages a tiny - * but representative dataset on first boot so the moment the - * bootstrap admin lands on `/`, every page already has rows. + * Gated behind `vibeerp.demo.seed=true` (dev profile only). + * Idempotent via sentinel item check. * - * **Opt-in by property.** `@ConditionalOnProperty` keeps this - * bean entirely absent from production deployments — only the - * dev profile (`application-dev.yaml` sets `vibeerp.demo.seed: - * true`) opts in. A future release can ship a `--demo` CLI flag - * or a one-time admin "Load demo data" button that flips the - * same property at runtime; for v1 the dev profile is enough. - * - * **Idempotent.** The runner checks for one of its own seeded - * item codes and short-circuits if already present. Restarting - * the dev server is a no-op; deleting the demo data has to - * happen via SQL or by dropping the DB. Idempotency on the - * sentinel item is intentional (vs. on every entity it creates): - * a half-seeded DB from a crashed first run will *not* recover - * cleanly, but that case is exotic and we can clear and retry - * in dev. - * - * **All seeded data shares the `DEMO-` prefix.** Items, partners, - * locations, and order codes all start with `DEMO-`. This makes - * the seeded data trivially distinguishable from anything an - * operator creates by hand later — and gives a future - * "delete demo data" command an obvious filter. - * - * **System principal.** Audit columns need a non-blank - * `created_by`; `PrincipalContext.runAs("__demo_seed__")` wraps - * the entire seed so every row carries that sentinel. The - * authorization aspect (`@RequirePermission`) lives on - * controllers, not services — calling services directly bypasses - * it cleanly, which is correct for system-level seeders. - * - * **Why CommandLineRunner.** Equivalent to `ApplicationRunner` - * here — there are no command-line args this seeder cares about. - * Spring runs every CommandLineRunner once, after the application - * context is fully initialized but before serving traffic, which - * is exactly the right window: services are wired, the schema is - * applied, but the first HTTP request hasn't arrived yet. - * - * **Lives in distribution.** This is the only module that - * already depends on every PBC, which is what the seeder needs - * to compose. It's gated behind a property the production - * application.yaml never sets, so its presence in the fat-jar - * is dormant unless explicitly opted in. + * The seeded data demonstrates the core flow from the doc: + * A-010 Sales order confirmed (SO already in DRAFT, ready to confirm) + * B-010 System auto-generates work orders from confirmed SO lines + * B-020 Work order with BOM + routing (pre-seeded WO-PRINT-0001) + * C-040 Material requisition (BOM inputs consumed on WO complete) + * C-050 Material picking from warehouse (inventory ledger writes) + * E-010 Shop floor tracking (shop-floor dashboard polls IN_PROGRESS WOs) */ @Component @ConditionalOnProperty(prefix = "vibeerp.demo", name = ["seed"], havingValue = "true") @@ -88,166 +53,170 @@ class DemoSeedRunner( private val partnerService: PartnerService, private val salesOrderService: SalesOrderService, private val purchaseOrderService: PurchaseOrderService, + private val workOrderService: WorkOrderService, ) : CommandLineRunner { private val log = LoggerFactory.getLogger(DemoSeedRunner::class.java) @Transactional override fun run(vararg args: String?) { - if (itemService.findByCode(SENTINEL_ITEM_CODE) != null) { - log.info("Demo seed: data already present (sentinel item {} found); skipping", SENTINEL_ITEM_CODE) + if (itemService.findByCode(SENTINEL) != null) { + log.info("Demo seed: already present ({}); skipping", SENTINEL) return } - - log.info("Demo seed: populating starter dataset…") + log.info("Demo seed: populating printing-company dataset...") PrincipalContext.runAs("__demo_seed__") { seedItems() seedLocations() seedPartners() seedStock() - seedSalesOrder() + seedWorkOrder() + seedSalesOrders() seedPurchaseOrder() } log.info("Demo seed: done") } - // ─── Items ─────────────────────────────────────────────────────── + // ─── Catalog items (printing industry) ─────────────────────────── private fun seedItems() { - item(SENTINEL_ITEM_CODE, "A4 paper, 80gsm, white", ItemType.GOOD, "sheet") - item("DEMO-INK-CYAN", "Cyan offset ink", ItemType.GOOD, "kg") - item("DEMO-INK-MAGENTA", "Magenta offset ink", ItemType.GOOD, "kg") - item("DEMO-CARD-BIZ", "Business cards, 100/pack", ItemType.GOOD, "pack") - item("DEMO-BROCHURE-A5", "Folded A5 brochure", ItemType.GOOD, "ea") + // Raw materials + item(SENTINEL, "80g A4 white card stock", ItemType.GOOD, "sheet") + item("INK-4C", "4-color offset ink set (CMYK)", ItemType.GOOD, "kg") + item("PLATE-CTP", "CTP printing plate", ItemType.GOOD, "ea") + item("COVER-MATT", "Matt lamination film", ItemType.GOOD, "m2") + // Finished goods + item("BIZ-CARD-250", "Business cards, 250gsm coated, 100/box", ItemType.GOOD, "pack") + item("BROCHURE-A5", "A5 tri-fold brochure, full color", ItemType.GOOD, "ea") + item("POSTER-A3", "A3 promotional poster, glossy", ItemType.GOOD, "ea") } - private fun item(code: String, name: String, type: ItemType, baseUomCode: String) { - itemService.create( - CreateItemCommand( - code = code, - name = name, - description = null, - itemType = type, - baseUomCode = baseUomCode, - active = true, - ), - ) + private fun item(code: String, name: String, type: ItemType, uom: String) { + itemService.create(CreateItemCommand(code = code, name = name, description = null, itemType = type, baseUomCode = uom)) } // ─── Locations ─────────────────────────────────────────────────── private fun seedLocations() { - location("DEMO-WH-RAW", "Raw materials warehouse") - location("DEMO-WH-FG", "Finished goods warehouse") + location("WH-RAW", "Raw materials warehouse") + location("WH-FG", "Finished goods warehouse") } private fun location(code: String, name: String) { - locationService.create( - CreateLocationCommand( - code = code, - name = name, - type = LocationType.WAREHOUSE, - active = true, - ), - ) + locationService.create(CreateLocationCommand(code = code, name = name, type = LocationType.WAREHOUSE)) } // ─── Partners ──────────────────────────────────────────────────── private fun seedPartners() { - partner("DEMO-CUST-ACME", "Acme Print Co.", PartnerType.CUSTOMER, "ap@acme.example") - partner("DEMO-CUST-GLOBE", "Globe Marketing", PartnerType.CUSTOMER, "ops@globe.example") - partner("DEMO-SUPP-PAPERWORLD", "Paper World Ltd.", PartnerType.SUPPLIER, "sales@paperworld.example") - partner("DEMO-SUPP-INKCO", "InkCo Industries", PartnerType.SUPPLIER, "orders@inkco.example") + partner("CUST-WCAD", "Wucai Advertising Co.", PartnerType.CUSTOMER, "info@wucai-ad.example") + partner("CUST-GLOBE", "Globe Marketing Ltd.", PartnerType.CUSTOMER, "ops@globe.example") + partner("SUPP-HZPAPER", "Huazhong Paper Co.", PartnerType.SUPPLIER, "sales@hzpaper.example") + partner("SUPP-INKPRO", "InkPro Industries", PartnerType.SUPPLIER, "orders@inkpro.example") } private fun partner(code: String, name: String, type: PartnerType, email: String) { - partnerService.create( - CreatePartnerCommand( - code = code, - name = name, - type = type, - email = email, - active = true, - ), - ) + partnerService.create(CreatePartnerCommand(code = code, name = name, type = type, email = email)) } - // ─── Initial stock ─────────────────────────────────────────────── + // ─── Opening stock ─────────────────────────────────────────────── private fun seedStock() { - val rawWh = locationService.findByCode("DEMO-WH-RAW")!! - val fgWh = locationService.findByCode("DEMO-WH-FG")!! + val raw = locationService.findByCode("WH-RAW")!! + val fg = locationService.findByCode("WH-FG")!! + + stockBalanceService.adjust(SENTINEL, raw.id, BigDecimal("5000")) + stockBalanceService.adjust("INK-4C", raw.id, BigDecimal("80")) + stockBalanceService.adjust("PLATE-CTP", raw.id, BigDecimal("100")) + stockBalanceService.adjust("COVER-MATT", raw.id, BigDecimal("500")) - stockBalanceService.adjust(SENTINEL_ITEM_CODE, rawWh.id, BigDecimal("5000")) - stockBalanceService.adjust("DEMO-INK-CYAN", rawWh.id, BigDecimal("50")) - stockBalanceService.adjust("DEMO-INK-MAGENTA", rawWh.id, BigDecimal("50")) + stockBalanceService.adjust("BIZ-CARD-250", fg.id, BigDecimal("200")) + stockBalanceService.adjust("BROCHURE-A5", fg.id, BigDecimal("150")) + stockBalanceService.adjust("POSTER-A3", fg.id, BigDecimal("50")) + } - stockBalanceService.adjust("DEMO-CARD-BIZ", fgWh.id, BigDecimal("200")) - stockBalanceService.adjust("DEMO-BROCHURE-A5", fgWh.id, BigDecimal("100")) + // ─── Pre-seeded work order with BOM + routing ──────────────────── + // + // Matches the EBC-PP-001 flow: a production work order for + // business cards with 3 BOM inputs and a 3-step routing + // (CTP plate-making → offset printing → post-press finishing). + // Left in DRAFT so the demo operator can start it, walk the + // operations on the shop-floor dashboard, and complete it. + + private fun seedWorkOrder() { + workOrderService.create( + CreateWorkOrderCommand( + code = "WO-PRINT-0001", + outputItemCode = "BIZ-CARD-250", + outputQuantity = BigDecimal("50"), + dueDate = LocalDate.now().plusDays(3), + inputs = listOf( + WorkOrderInputCommand(lineNo = 1, itemCode = SENTINEL, quantityPerUnit = BigDecimal("10"), sourceLocationCode = "WH-RAW"), + WorkOrderInputCommand(lineNo = 2, itemCode = "INK-4C", quantityPerUnit = BigDecimal("0.2"), sourceLocationCode = "WH-RAW"), + WorkOrderInputCommand(lineNo = 3, itemCode = "PLATE-CTP", quantityPerUnit = BigDecimal("1"), sourceLocationCode = "WH-RAW"), + ), + operations = listOf( + WorkOrderOperationCommand(lineNo = 1, operationCode = "CTP", workCenter = "CTP-ROOM-01", standardMinutes = BigDecimal("30")), + WorkOrderOperationCommand(lineNo = 2, operationCode = "PRINT", workCenter = "PRESS-A", standardMinutes = BigDecimal("45")), + WorkOrderOperationCommand(lineNo = 3, operationCode = "FINISH", workCenter = "BIND-01", standardMinutes = BigDecimal("20")), + ), + ), + ) } - // ─── Open sales order (DRAFT — ready to confirm + ship) ────────── + // ─── Sales orders (DRAFT — confirm to auto-spawn work orders) ─── + // + // Two orders for different customers. Confirming either one + // triggers SalesOrderConfirmedSubscriber in pbc-production, + // which auto-creates one DRAFT work order per SO line (the + // B-010 step from EBC-PP-001). The demo operator watches the + // work order count jump on the dashboard after clicking Confirm. - private fun seedSalesOrder() { + private fun seedSalesOrders() { + salesOrderService.create( + CreateSalesOrderCommand( + code = "SO-2026-0001", + partnerCode = "CUST-WCAD", + orderDate = LocalDate.now(), + currencyCode = "USD", + lines = listOf( + SalesOrderLineCommand(lineNo = 1, itemCode = "BIZ-CARD-250", quantity = BigDecimal("100"), unitPrice = BigDecimal("8.50"), currencyCode = "USD"), + SalesOrderLineCommand(lineNo = 2, itemCode = "BROCHURE-A5", quantity = BigDecimal("500"), unitPrice = BigDecimal("2.20"), currencyCode = "USD"), + ), + ), + ) salesOrderService.create( CreateSalesOrderCommand( - code = "DEMO-SO-0001", - partnerCode = "DEMO-CUST-ACME", + code = "SO-2026-0002", + partnerCode = "CUST-GLOBE", orderDate = LocalDate.now(), currencyCode = "USD", lines = listOf( - SalesOrderLineCommand( - lineNo = 1, - itemCode = "DEMO-CARD-BIZ", - quantity = BigDecimal("50"), - unitPrice = BigDecimal("12.50"), - currencyCode = "USD", - ), - SalesOrderLineCommand( - lineNo = 2, - itemCode = "DEMO-BROCHURE-A5", - quantity = BigDecimal("20"), - unitPrice = BigDecimal("4.75"), - currencyCode = "USD", - ), + SalesOrderLineCommand(lineNo = 1, itemCode = "POSTER-A3", quantity = BigDecimal("200"), unitPrice = BigDecimal("3.80"), currencyCode = "USD"), ), ), ) } - // ─── Open purchase order (DRAFT — ready to confirm + receive) ──── + // ─── Purchase order (DRAFT — confirm + receive to restock) ─────── private fun seedPurchaseOrder() { purchaseOrderService.create( CreatePurchaseOrderCommand( - code = "DEMO-PO-0001", - partnerCode = "DEMO-SUPP-PAPERWORLD", + code = "PO-2026-0001", + partnerCode = "SUPP-HZPAPER", orderDate = LocalDate.now(), - expectedDate = LocalDate.now().plusDays(7), + expectedDate = LocalDate.now().plusDays(5), currencyCode = "USD", lines = listOf( - PurchaseOrderLineCommand( - lineNo = 1, - itemCode = SENTINEL_ITEM_CODE, - quantity = BigDecimal("10000"), - unitPrice = BigDecimal("0.04"), - currencyCode = "USD", - ), + PurchaseOrderLineCommand(lineNo = 1, itemCode = SENTINEL, quantity = BigDecimal("10000"), unitPrice = BigDecimal("0.03"), currencyCode = "USD"), + PurchaseOrderLineCommand(lineNo = 2, itemCode = "INK-4C", quantity = BigDecimal("50"), unitPrice = BigDecimal("45.00"), currencyCode = "USD"), ), ), ) } companion object { - /** - * The seeder uses the presence of this item as the - * idempotency marker — re-running the seeder against a - * Postgres that already contains it short-circuits. The - * choice of "the very first item the seeder creates" is - * deliberate: if the seed transaction commits at all, this - * row is in the DB; if it doesn't, nothing is. - */ - const val SENTINEL_ITEM_CODE: String = "DEMO-PAPER-A4" + const val SENTINEL: String = "PAPER-80G-A4" } } diff --git a/web/src/App.tsx b/web/src/App.tsx index 040a008..9bf7dda 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -20,6 +20,7 @@ import { LocationsPage } from '@/pages/LocationsPage' import { BalancesPage } from '@/pages/BalancesPage' import { MovementsPage } from '@/pages/MovementsPage' import { SalesOrdersPage } from '@/pages/SalesOrdersPage' +import { CreateSalesOrderPage } from '@/pages/CreateSalesOrderPage' import { SalesOrderDetailPage } from '@/pages/SalesOrderDetailPage' import { PurchaseOrdersPage } from '@/pages/PurchaseOrdersPage' import { PurchaseOrderDetailPage } from '@/pages/PurchaseOrderDetailPage' @@ -48,6 +49,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 7025927..e879b3c 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -159,6 +159,17 @@ export const inventory = { export const salesOrders = { list: () => apiFetch('/api/v1/orders/sales-orders'), get: (id: string) => apiFetch(`/api/v1/orders/sales-orders/${id}`), + create: (body: { + code: string + partnerCode: string + orderDate: string + currencyCode: string + lines: { lineNo: number; itemCode: string; quantity: number; unitPrice: number; currencyCode: string }[] + }) => + apiFetch('/api/v1/orders/sales-orders', { + method: 'POST', + body: JSON.stringify(body), + }), confirm: (id: string) => apiFetch(`/api/v1/orders/sales-orders/${id}/confirm`, { method: 'POST', diff --git a/web/src/pages/CreateSalesOrderPage.tsx b/web/src/pages/CreateSalesOrderPage.tsx new file mode 100644 index 0000000..6014e1c --- /dev/null +++ b/web/src/pages/CreateSalesOrderPage.tsx @@ -0,0 +1,193 @@ +import { useEffect, useState, type FormEvent } from 'react' +import { useNavigate } from 'react-router-dom' +import { catalog, partners, salesOrders } from '@/api/client' +import type { Item, Partner } from '@/types/api' +import { PageHeader } from '@/components/PageHeader' +import { ErrorBox } from '@/components/ErrorBox' + +interface LineInput { + itemCode: string + quantity: string + unitPrice: string +} + +export function CreateSalesOrderPage() { + const navigate = useNavigate() + const [code, setCode] = useState('') + const [partnerCode, setPartnerCode] = useState('') + const [currencyCode] = useState('USD') + const [lines, setLines] = useState([ + { itemCode: '', quantity: '', unitPrice: '' }, + ]) + const [items, setItems] = useState([]) + const [partnerList, setPartnerList] = useState([]) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + Promise.all([catalog.listItems(), partners.list()]).then(([i, p]) => { + setItems(i) + const customers = p.filter((x) => x.type === 'CUSTOMER' || x.type === 'BOTH') + setPartnerList(customers) + if (customers.length > 0 && !partnerCode) setPartnerCode(customers[0].code) + }) + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + const addLine = () => + setLines([...lines, { itemCode: items[0]?.code ?? '', quantity: '1', unitPrice: '1.00' }]) + + const removeLine = (idx: number) => { + if (lines.length <= 1) return + setLines(lines.filter((_, i) => i !== idx)) + } + + const updateLine = (idx: number, field: keyof LineInput, value: string) => { + const next = [...lines] + next[idx] = { ...next[idx], [field]: value } + setLines(next) + } + + const onSubmit = async (e: FormEvent) => { + e.preventDefault() + setError(null) + setSubmitting(true) + try { + const created = await salesOrders.create({ + code, + partnerCode, + orderDate: new Date().toISOString().slice(0, 10), + currencyCode, + lines: lines.map((l, i) => ({ + lineNo: i + 1, + itemCode: l.itemCode, + quantity: Number(l.quantity), + unitPrice: Number(l.unitPrice), + currencyCode, + })), + }) + navigate(`/sales-orders/${created.id}`) + } catch (err: unknown) { + setError(err instanceof Error ? err : new Error(String(err))) + } finally { + setSubmitting(false) + } + } + + return ( +
+ navigate('/sales-orders')}> + Cancel + + } + /> +
+
+
+ + setCode(e.target.value)} + placeholder="SO-2026-0003" + 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" + /> +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ {lines.map((line, idx) => ( +
+ {idx + 1} + + updateLine(idx, 'quantity', e.target.value)} + className="w-20 rounded-md border border-slate-300 px-2 py-1.5 text-sm text-right" + /> + updateLine(idx, 'unitPrice', e.target.value)} + className="w-24 rounded-md border border-slate-300 px-2 py-1.5 text-sm text-right" + /> + +
+ ))} +
+
+ + {error && } + +
+ + + After creation, confirm the order to auto-generate work orders. + +
+ +
+ ) +} diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx index 27162e5..fbaed75 100644 --- a/web/src/pages/DashboardPage.tsx +++ b/web/src/pages/DashboardPage.tsx @@ -100,25 +100,48 @@ export function DashboardPage() { )}
-

Try the demo

-
    +

    + Demo: EBC-PP-001 Work Order Management Flow +

    +

    + Walk the printing company's production flow end-to-end — sales order to finished goods. +

    +
    1. - Open a sales order{' '} - in DRAFT, click Confirm. + Create or open a sales order —{' '} + + create a new order + {' '} + or open an existing one from the{' '} + list.
    2. - With the same sales order CONFIRMED, click Ship. - The framework atomically debits stock, flips status to SHIPPED, and emits a domain - event. + Confirm the order — click{' '} + Confirm. The system + auto-generates one production work order per line (EBC-PP-001 step B-010). Check the{' '} + Work Orders{' '} + page to see them appear. Finance posts an AR entry automatically.
    3. - Watch stock balances{' '} - drop, movements{' '} - grow by one row, and{' '} - - journal entries - {' '} - settle from POSTED to SETTLED — all from the cross-PBC event subscriber in pbc-finance. + Walk the pre-seeded work order — open{' '} + WO-PRINT-0001. + It has a 3-step routing (CTP plate-making → offset printing → post-press finishing) and + a BOM with paper, ink, and CTP plates. Click Start{' '} + to put it in progress, then watch it on the{' '} + Shop Floor dashboard. +
    4. +
    5. + Complete the work order — materials are consumed from the warehouse, + finished goods are credited. Check{' '} + Stock Balances{' '} + and{' '} + Movements{' '} + to see the ledger entries. +
    6. +
    7. + Ship the sales order — finished goods leave the warehouse, the AR + journal entry settles from POSTED to SETTLED in{' '} + Finance.
diff --git a/web/src/pages/SalesOrdersPage.tsx b/web/src/pages/SalesOrdersPage.tsx index 5ebd8c8..ff3c9b8 100644 --- a/web/src/pages/SalesOrdersPage.tsx +++ b/web/src/pages/SalesOrdersPage.tsx @@ -56,7 +56,13 @@ export function SalesOrdersPage() { return (
- + + New Order + } + /> {loading && } {error && } {!loading && !error && }