Commit 16c59310df31e8d8591ac1f0205ea4f1be4506c9

Authored by zichun
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.
reference-customer/plugin-printing-shop/src/main/resources/META-INF/vibe-erp/metadata/printing-shop.yml
... ... @@ -40,3 +40,73 @@ menus:
40 40 icon: palette
41 41 section: Printing shop
42 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: 印刷机
... ...