ERP Group ERP Extension
Version: 1.0 | Release Date: 20/08/2026
# Overview
Group ERP (app name ce01_grouperp) synchronizes configuration across several independently deployed aiM18 systems: one master server owns the creation, update and deletion of master-file / configuration modules, and pushes them over REST + OAuth2 to N sub-servers. On a sub-server those modules become read-only — the menu is locked and offers a "Go to master server" shortcut instead.
| Item | Description |
|---|---|
| Direction | One-way: master server → sub-server |
| Deployment | Independent systems with independent databases |
# Deployment Configuration
The role of a server is decided by its WildFly deployment configuration:
| Configuration | Description |
|---|---|
caw.grouperp.master | This machine is the master server |
caw.grouperp.slave | This machine is a sub-server |
caw.grouperp.gerpKey | Identity code of this machine, unique per server. A request whose key does not match the local one is rejected |
In code, read them through GrouperpUtil.isMasterServer() / isSlaveServer() / getGerpKey().
# Related Modules
| Menu | Module / Table | Description |
|---|---|---|
| [Group ERP Server Setup] | grouperpServer | Registers the address, server code and integration account of every server in the group and verifies each connection; also assigns the business entities each server is responsible for |
| [Group ERP Module Setup] | grouperpSetting | Lists every module and menu that joins the sync and marks whether each one uses Excel / Object / Event; lets you configure a special setting per module |
| [Group ERP Sync Error Log] | grouperpaudittrail | Failed synchronizations, including the error returned by the far side and the payload actually sent; entries can be selected and retried |
| [Group ERP Sync Trail Log] | grouperpauditdata | Successful synchronizations; entries can be selected and re-sent to a chosen sub-server |
# Sync Methods
| Method | Applies to | Underlying mechanism | What the 3PD App writes |
|---|---|---|---|
| Record Sync | FM modules (standard master-file / configuration modules) | [Data Export] on the master → [Data Import] on the sub-server | Usually just a declaration in module.xml; a Handler only when something special is needed |
| Object Sync | Configuration modules that implement DataObjectHandler | [Create Objects] on the master → [Install Objects] on the sub-server | Review and adapt your own ObjectHandler |
| Event Sync | View button actions, and configuration that is not a module | Reflective call of the class and method you specify | An Event class + a view Listener |
# Configuration Parameters
# module.xml params
| key | Value | Applies to | Description |
|---|---|---|---|
grouperpSyncAddFm | true | Record / Object | Make this module join the sync |
grouperpSyncSkipFm | true | Record / Object | Exclude this module from the sync |
grouperpSyncLenientTable | Table names separated by ; | Record | Pull a table the framework would not import into the sync scope |
grouperpSyncLenientField | table.column separated by ; | Record | Pull a column the framework would not import into the sync scope |
grouperpSyncHandler | Fully qualified class name | Record / Object | Custom sync handler class for this module |
grouperpSettingXhtml | Path to an xhtml | All | Special-setting dialog for this module in [Group ERP Module Setup] |
# navmenu.xml params
| key | Value | Applies to | Description |
|---|---|---|---|
grouperpSyncAddFm | true | Event | Make this menu join the event sync |
grouperpSyncSkipFm | true | Event | Exclude this menu from the event sync |
# Common Prerequisite: Cross-Server ID Alignment
The master server and a sub-server are two independent databases, so the auto-increment id of the same record differs on both sides. All three sync methods rest on the same premise: the code is used to look up the local id on the far side.
A module that joins the sync must therefore satisfy:
- the main table has a
codecolumn, and thatcodepoints to the same business entity on both sides — it must not be produced by a local running number, must not contain a localid, and must not be rewritten locally after a sync; - every master file referenced by a foreign key must already exist on the sub-server under the same
code, otherwise the sync reportscannot find lookup code.
When a module has no natural code (a one-to-one setting table, for example), build a stable one. Common practice is code = the module name (singleton table), or code = the code of the owning business entity (one row per BE).
Besides the code lookup, the system automatically adds a hidden column gerpSyncSId to the main table of every module that joins the sync. It holds the id the record has on the master server and is used to locate records on delete and to build the "Go to master server" link. A 3PD App neither declares nor writes this column.
# Method 1: Record Sync (FM Modules)
Applies to FM modules — the standard master-file / configuration modules. All FM modules are synchronized by default, with no code to write.
The mechanism is equivalent to a [Data Export] on the master server followed by a [Data Import] on the sub-server: the master exports the whole record to xlsx (plus an optional 7z attachment package) and pushes it to the sub-server, which saves it through the standard import flow. The Checkers of the module itself run as usual.
# 1. Declare whether the module joins the sync
A declaration in the module.xml of your own app is enough:
<!-- make this module join the group sync -->
<module name="myBaseData" mess="my3pd.myBaseData" mainTable="mybasedata" fmShare="N">
<table name="mybasedata" key="code" initRow="1"/>
<param key="grouperpSyncAddFm" value="true"/>
</module>
<!-- keep this module out of the group sync -->
<module name="myLocalConf" mess="my3pd.myLocalConf" mainTable="mylocalconf">
<table name="mylocalconf" key="code" initRow="1"/>
<param key="grouperpSyncSkipFm" value="true"/>
</module>
# 2. Adjust the synchronized field scope
Because the transport is [Data Export] / [Data Import], the fields that can be synchronized are exactly the fields those two functions allow. A table or column marked dataImport="false" is not synchronized.
If the 3PD App has fields that must stay identical to the master server but that the framework does not allow to be imported, pull them in with a white list:
<module name="myBaseData" extend="true">
<param key="grouperpSyncLenientTable" value="mybasedataext"/>
<param key="grouperpSyncLenientField" value="mybasedataext.adminFlag;mybasedataext.apiKey"/>
</module>
| param | Syntax | Description |
|---|---|---|
grouperpSyncLenientTable | table;table | Pulls a whole table into the sync scope |
grouperpSyncLenientField | table.column;table.column | Column level, no wildcard support |
Security note: white-listed fields travel to every sub-server over HTTP inside the sync payload. Never white-list a plain-text key or password.
# 3. Customization: grouperpSyncHandler
When the default export / import behaviour is not enough — an extra lookup code has to travel, the field set has to be trimmed according to a setting, or a foreign key has to be re-mapped before saving — give the module a custom handler class:
<module name="myBaseData" extend="true">
<param key="grouperpSyncHandler" value="com.my3pd.erp.handler.MySyncHandler"/>
</module>
At start-up the class is instantiated and cached per module. During a sync the system uses reflection to look for a method with exactly the same name and signature and calls it. Implement only what you need; no interface has to be implemented. The Excel-related hook points are:
| Signature | Side | Purpose |
|---|---|---|
void handlerExportExcel(DataExportConfig config, SqlEntity entity) | Master | Add or remove synchronized fields, or put values into getParamMap() |
void handlerImportExcel(DataImportConfig config, WorkbookMapping mapping, Long beId, String moduleName) | Sub | The counterpart of the above: trim imported fields, read the parameters back |
void handlerImportExcelEntity(DataImportConfig config, SqlEntity entity, Long beId) | Sub | Final adjustment of the entity before it is saved |
void handlerSyncDto(SeSaveParam param, GrouperpSyncDto syncDto) | Master | Add custom information after the payload is assembled (shared by Excel / Object) |
void updateSyncDto(GrouperpSyncDto syncDto) | Sub | Translate a code into the local id before saving (shared by Excel / Object) |
Example: the main table of module myBaseData has a foreign key myTypeId. The id differs on both sides, so the code has to travel instead.
package com.my3pd.erp.handler;
public class MySyncHandler {
/** master server: put the code of the foreign key into the payload */
public void handlerExportExcel(DataExportConfig config, SqlEntity entity) {
long myTypeId = entity.getMainData().getLong(1, "myTypeId");
StLookupDto dto = StLookupLib.getDto("myType", myTypeId);
if (dto != null) {
config.getParamMap().put("myTypeCode", dto.getCode());
}
}
/** sub-server: translate the code into the local id */
public void handlerImportExcelEntity(DataImportConfig config, SqlEntity entity, Long beId) {
String code = ConvertLib.toString(config.getParamMap().get("myTypeCode"));
if (!code.isEmpty()) {
entity.getMainData().setValue(1, "myTypeId", GrouperpLookupLib.getIdByCode("myType", code));
}
}
}
Type pitfall:
paramMapis aMap<String, Object>, and its values change type once they have been through JSON (a Java object becomes aJSONObject, a number may become a string). Always write the put and the get as a pair, read values throughConvertLib, and never cast directly.
# 4. Customization: DataSwapHandler / SearchCodeHandler
grouperpSyncHandler only affects a single module. The following handlers work across modules:
// register in a serverBoot (com.multiable.core.share.handler.EntityUtil)
EntityUtil.addDataSwap(new MyDataSwapHandler()); // implements DataSwapHandler
EntityUtil.addSearchCode(new MySearchCodeHandler()); // implements SearchCodeHandler
| Interface | Purpose |
|---|---|
DataSwapHandler | Adds or removes virtual columns during export / import to swap between an id and a business key |
SearchCodeHandler | Takes over the "code to local id" lookup |
# Method 2: Object Sync (Configuration Modules)
Applies to configuration modules that implement DataObjectHandler. Such modules are synchronized by default.
The mechanism is equivalent to a [Create Objects] on the master server followed by an [Install Objects] on the sub-server. The key difference from Method 1 is on the landing side: instead of [Data Import], the system calls the 3PD App's own ObjectHandler.install(...).
# 1. Review your ObjectHandler
Review the 3PD App's own ObjectHandler — a group sync has to follow a path that re-maps foreign keys by code:
| DataObject param | Description |
|---|---|
grouperpSyncFlag | true means this installation comes from a group sync, not from a manual installation by a user |
grouperpSyncCheck | true means this is the dry-run stage of the sync and must not cause side effects (writing to the database, sending a notification or an email) |
// branch inside ObjectHandler.install(...)
boolean syncFlag = ConvertLib.toBoolean(ErpDataObjectLib.getParamValue(dsDto, "grouperpSyncFlag"));
boolean syncCheck = ConvertLib.toBoolean(ErpDataObjectLib.getParamValue(dsDto, "grouperpSyncCheck"));
// manual installation keeps the original logic; a group sync re-maps foreign keys by code
boolean status = !syncFlag ? genFkTableRecord(entity) : grouperpSyncSave(entity, syncCheck);
A typical grouperpSyncSave looks up the local id by code in the local cache table, replaces the foreign keys on the entity one by one, and skips every write when syncCheck is true.
# 2. Customization: grouperpSyncHandler
The same grouperpSyncHandler param as in Method 1; only the hook points differ:
| Signature | Side | Purpose |
|---|---|---|
void handlerExportObject(Long beId, String moduleName, DataObjectBaseDto dto) | Master | Append parameters to the data object |
void handlerImportObject(Long beId, String moduleName, DataObjectBaseDto dto, List<DsInstallDto> insList, Object detail) | Sub | Re-map before installation, or keep the local value of the sub-server |
void handlerSyncDto(SeSaveParam param, GrouperpSyncDto syncDto) | Master | Add custom information after the payload is assembled (shared by Excel / Object) |
void updateSyncDto(GrouperpSyncDto syncDto) | Sub | Translate a code into the local id before saving (shared by Excel / Object) |
# Method 3: Event Sync (Event)
Applies to actions triggered by a view button, and to configuration that is not a standard module (data stored in your own tables, or an API that has to be called on the sub-server).
An event sync does not carry data tables; it carries an instruction: the master server tells the sub-server which class to instantiate, which method to call and what payload to pass, and the sub-server performs a reflective call. A 3PD App writes two things: an Event class and a view Listener.
# 1. Write the Event class
One class covers both sides: the static method starts the sync on the master server, the instance method lands it on the sub-server.
package com.my3pd.erp.share.event;
import java.util.List;
import com.multiable.core.share.lib.ConvertLib;
import com.multiable.core.share.message.CheckMsg;
import com.multiable.core.share.message.CheckMsgLib;
import com.multiable.erp.grouperp.share.entity.GrouperpEvent;
import com.multiable.erp.grouperp.share.lib.GrouperpEJBLib;
import com.multiable.logging.CawLog;
public class MySettingEvent {
/** master server: start the event */
public static List<CheckMsg> syncMySetting(String settingValue) {
GrouperpEvent event = new GrouperpEvent();
event.setEjbClass(MySettingEvent.class.getName()); // fully qualified handler class
event.setEjbMethod("handlerMySetting"); // handler method name
event.setData(settingValue); // payload
event.setMenuCode("mySetting"); // shown in the sync log
event.setActionMess("core.save"); // messCode of the action
return GrouperpEJBLib.getCommonEJB().syncEvent(event);
}
/** sub-server: handle the event */
public CheckMsg handlerMySetting(Object data) {
CheckMsg msg = null;
try {
MySettingLib.save(ConvertLib.toString(data));
} catch (Exception e) {
CawLog.logException(e);
msg = CheckMsgLib.createErrorMsg(e.toString());
}
return msg;
}
}
GrouperpEvent fields
| Name | Type | Description | Required |
|---|---|---|---|
| ejbClass | String | Fully qualified name of the class to instantiate on the sub-server | Y |
| ejbMethod | String | Name of the method to call | Y |
| data | Object | Payload, serialized to JSON before it is sent | Y |
| moduleName | String | Module name, used in the sync log and the alert text | N |
| menuCode | String | Menu code, used in the sync log and the alert text | N |
| actionMess | String | messCode of the action, shown in the Operation column of the sync log | N |
| dto | StLookupDto | Points at a specific record (id + code) so the sync log can locate it | N |
| user | UserDto | Operator, taken from the current user on construction; the sub-server maps it to a local uid by user code | Auto |
The handler method is invoked by reflection, so its shape is fixed:
| Requirement | Description |
|---|---|
| Parameter | Exactly Object — not JSONObject data and not String data |
| Return value | Must be CheckMsg (any other type causes no error, but the failure cannot be reported back to the operator) |
| Location | The handler class must be on the sub-server classpath, that is in p-share or p-ejb — never in p-jsf |
# 2. Write the view Listener
The Listener does two things: it locks the view read-only on a sub-server, and it starts the event when the button is pressed on the master server.
To add sync behaviour to an existing FRD view, register the listener through cawweb.xml (see Backend Framework and ERP Decorators).
public class MySettingListener extends ViewBean {
MySettingBean sourceBean = null;
@Override
public void initialized() {
super.initialized();
sourceBean = (MySettingBean) GrouperpWebUtil.getCurrentBeanInstance("mySetting");
// lock the view read-only on a sub-server
if (GrouperpUtil.isSlaveServer() && !UserCc.isSuper()) {
WebUtil.setDisabled(true, "save");
}
}
@Override
public void actionPerformed(ViewActionEvent vae) {
super.actionPerformed(vae);
if ("save".equals(vae.getActionCommand())) {
if (!GrouperpUtil.isMasterServer()) { // only the master server starts a sync
return;
}
GrouperpWebUtil.postMessage(MySettingEvent.syncMySetting(sourceBean.getValue()));
}
}
}
# 3. Register the menu
The menu has to be registered before it appears in [Group ERP Module Setup] and gets locked on a sub-server:
<menu code="mySetting" mess="my3pd.mySetting">
<param key="grouperpSyncAddFm" value="true"/>
</menu>
# Debugging and Troubleshooting
# Integration test steps
A group sync can only be tested across two systems; a single machine is not enough.
- Prepare two independently deployed aiM18 systems (each with its own database) and deploy the same version of the 3PD App on both.
- Set
caw.grouperp.master=trueon one andcaw.grouperp.slave=trueon the other, and give each a differentcaw.grouperp.gerpKey. - Register both servers in [Group ERP Server Setup] on the master and press Verify on each row until it passes.
- Open [Group ERP Module Setup] and confirm that the modules and menus of the 3PD App appear in the list and are marked with the expected method (Excel / Object / Event). If one is missing, it does not take part in the sync.
- Save a record on the master server and check the result on the sub-server.
- On failure look at [Group ERP Sync Error Log], on success at [Group ERP Sync Trail Log]. The
eventDatacolumn of both tables holds the payload that was actually sent.
# Debug mode
With the framework debug mode enabled, both the master and the sub-server print the complete sync payload, and the temporary files are kept. You can open them directly under <jboss>/excel/ and <jboss>/cawobj/ on the master and <jboss>/sync/ on the sub-server and check the content field by field. This is the most direct way to confirm whether a Handler took effect and whether a given field really travelled.
# Common problems
| Symptom | Check first |
|---|---|
| Nothing is synchronized and there is no log at all | The caw.grouperp.master / slave configuration |
| One module is never synchronized | Whether grouperpSyncAddFm is declared, and whether the param key is spelled correctly |
| A few fields are never synchronized | Those columns are dataImport="false" and have to be declared through grouperpSyncLenientField |
| A custom Handler is never called | 1. The signature does not match (in particular beId must be the boxed Long); 2. the module itself does not take part in the sync |
| A foreign key points at the wrong record on the sub-server | The ObjectHandler.install of an object sync does not branch on grouperpSyncFlag and used the master server id directly |
| The sub-server log contains a Java stack trace | The 3PD App version differs between master and sub-server, or the handler parameter / return type does not follow the contract |
Unmatched server code is reported | The gerpKey carried by the request does not match caw.grouperp.gerpKey on the sub-server |