Commit 16c59310df31e8d8591ac1f0205ea4f1be4506c9
1 parent
bc11041a
feat(ref-plugin): printing-shop plug-in Tier 1 customization of core entities
The reference printing-shop plug-in now demonstrates the framework's
most important promise — that a customer plug-in can EXTEND core
business entities (Partner, SalesOrder, WorkOrder) with customer-
specific fields WITHOUT touching any core code.
Added to `printing-shop.yml` customFields section:
Partner:
printing_shop_customer_segment (enum: agency, in_house, end_client, reseller)
SalesOrder:
printing_shop_quote_number (string, maxLength 32)
WorkOrder:
printing_shop_press_id (string, maxLength 32)
Mechanism (no code changes, all metadata-driven):
1. Plug-in YAML carries a `customFields:` section alongside its
entities / permissions / menus.
2. MetadataLoader.loadFromPluginJar reads the section and inserts
rows into `metadata__custom_field` tagged
`source='plugin:printing-shop'`.
3. CustomFieldRegistry.refresh re-reads ALL rows (both `source='core'`
and `source='plugin:*'`) and merges them by `targetEntity`.
4. ExtJsonValidator.applyTo now validates incoming ext against the
MERGED set, so a POST to /api/v1/partners/partners can include
both core-declared (partners_industry) and plug-in-declared
(printing_shop_customer_segment) fields in the same request,
and both are enforced.
5. Uninstalling the plug-in removes the plugin:printing-shop rows
and the fields disappear from validation AND from the UI's
custom-field catalog — no migration, no restart of anything
other than the plug-in lifecycle itself.
Convention established: plug-in-contributed custom field keys are
prefixed with the plug-in id (e.g. `printing_shop_*`) so two
independent plug-ins can't collide on the same entity. Documented
inline in the YAML.
Smoke verified end-to-end against real Postgres with the plug-in
staged:
- CustomFieldRegistry logs "refreshed 4 custom fields" after core
load, then "refreshed 7 custom fields across 3 entities" after
plug-in load — 4 core + 3 plug-in fields, merged.
- GET /_meta/metadata/custom-fields/Partner returns 3 fields
(2 core + 1 plug-in).
- GET /_meta/metadata/custom-fields/SalesOrder returns 1 field
(plug-in only).
- GET /_meta/metadata/custom-fields/WorkOrder returns 3 fields
(2 core + 1 plug-in).
- POST /partners/partners with BOTH a core field AND a plug-in
field → 201, canonical form persisted.
- POST with an invalid plug-in enum value → 400 "ext.printing_shop_customer_segment:
value 'ghost' is not in allowed set [agency, in_house, end_client, reseller]".
- POST /orders/sales-orders with printing_shop_quote_number → 201,
quote number round-trips.
- POST /production/work-orders with mixed production_priority +
printing_shop_press_id → 201, both fields persisted.
This is the first executable demonstration of Clean Core
extensibility (CLAUDE.md guardrail #7) — the plug-in extends
core-owned data through a stable public contract (api.v1 HasExt +
metadata__custom_field) without reaching into any core or platform
internal class. This is the "A" grade on the A/B/C/D extensibility
safety scale.
No code changes. No test changes. 246 unit tests, still green.
Showing
1 changed file
with
70 additions
and
0 deletions
reference-customer/plugin-printing-shop/src/main/resources/META-INF/vibe-erp/metadata/printing-shop.yml
| @@ -40,3 +40,73 @@ menus: | @@ -40,3 +40,73 @@ menus: | ||
| 40 | icon: palette | 40 | icon: palette |
| 41 | section: Printing shop | 41 | section: Printing shop |
| 42 | order: 510 | 42 | order: 510 |
| 43 | + | ||
| 44 | +# ───────────────────────────────────────────────────────────────── | ||
| 45 | +# Tier 1 customization of CORE entities by a plug-in. | ||
| 46 | +# | ||
| 47 | +# This is the framework's most important capability demonstration: a | ||
| 48 | +# printing-shop customer plug-in extends core entities (Partner, Item, | ||
| 49 | +# SalesOrder, WorkOrder) with printing-specific fields WITHOUT touching | ||
| 50 | +# any core code. The MetadataLoader reads this section at plug-in load, | ||
| 51 | +# inserts rows into metadata__custom_field tagged | ||
| 52 | +# source='plugin:printing-shop', and CustomFieldRegistry merges them | ||
| 53 | +# with the core declarations. ExtJsonValidator then enforces the | ||
| 54 | +# merged set on every save. | ||
| 55 | +# | ||
| 56 | +# When the plug-in is UNINSTALLED, the loader removes the | ||
| 57 | +# plugin:printing-shop rows and the fields disappear from the UI and | ||
| 58 | +# from validation. No database migration, no code change, no restart | ||
| 59 | +# of anything but the plug-in load/unload lifecycle. | ||
| 60 | +# | ||
| 61 | +# Naming convention for plug-in-contributed custom field keys: | ||
| 62 | +# prefix with the plug-in id (printing_shop_*) so two independent | ||
| 63 | +# plug-ins cannot collide on the same entity. | ||
| 64 | +# ───────────────────────────────────────────────────────────────── | ||
| 65 | +customFields: | ||
| 66 | + # ── Partner: printing shops care about which kind of customer this | ||
| 67 | + # is (agency, in-house marketing, direct end-client). This adds a | ||
| 68 | + # dimension that the core framework doesn't know about. | ||
| 69 | + - key: printing_shop_customer_segment | ||
| 70 | + targetEntity: Partner | ||
| 71 | + type: | ||
| 72 | + kind: enum | ||
| 73 | + allowedValues: | ||
| 74 | + - agency | ||
| 75 | + - in_house | ||
| 76 | + - end_client | ||
| 77 | + - reseller | ||
| 78 | + required: false | ||
| 79 | + pii: false | ||
| 80 | + labelTranslations: | ||
| 81 | + en: Customer segment | ||
| 82 | + zh-CN: 客户细分 | ||
| 83 | + | ||
| 84 | + # ── SalesOrder: a printing shop sales rep tracks a quote number | ||
| 85 | + # that originated in a pre-ERP quoting tool. Storing it as a | ||
| 86 | + # free-form string on the order keeps the lineage traceable without | ||
| 87 | + # forcing the shop to use the framework's (future) quoting PBC. | ||
| 88 | + - key: printing_shop_quote_number | ||
| 89 | + targetEntity: SalesOrder | ||
| 90 | + type: | ||
| 91 | + kind: string | ||
| 92 | + maxLength: 32 | ||
| 93 | + required: false | ||
| 94 | + pii: false | ||
| 95 | + labelTranslations: | ||
| 96 | + en: Quote number | ||
| 97 | + zh-CN: 报价单号 | ||
| 98 | + | ||
| 99 | + # ── WorkOrder: the press the job was scheduled on. Core production | ||
| 100 | + # has no notion of "press" — that's printing-specific equipment. A | ||
| 101 | + # future shop-floor scheduling plug-in could also reference this | ||
| 102 | + # field. | ||
| 103 | + - key: printing_shop_press_id | ||
| 104 | + targetEntity: WorkOrder | ||
| 105 | + type: | ||
| 106 | + kind: string | ||
| 107 | + maxLength: 32 | ||
| 108 | + required: false | ||
| 109 | + pii: false | ||
| 110 | + labelTranslations: | ||
| 111 | + en: Press | ||
| 112 | + zh-CN: 印刷机 |