ERP 集团ERP扩展
版本: 1.0 | 发布日期: 20/08/2026
# 概述
集团ERP(App 名 ce01_grouperp)用于多套独立部署的 aiM18 系统之间的配置同步:一台主服务器统一维护资料/配置类模块的增删改,通过 REST + OAuth2 推送到 N 台子服务器;子服务器上这些模块被强制只读,界面加锁并提供「前往主服务器」的跳转。
| 项目 | 说明 |
|---|---|
| 同步方向 | 单向:主服务器 → 子服务器 |
| 部署形态 | 互相独立的系统、独立的数据库 |
# 部署配置
服务器身份由 WildFly 的部署配置决定:
| 配置项 | 说明 |
|---|---|
caw.grouperp.master | 当前这台是主服务器 |
caw.grouperp.slave | 当前这台是子服务器 |
caw.grouperp.gerpKey | 本机身份码,每台唯一。请求携带的 key 与本机不符即拒绝 |
程序中通过 GrouperpUtil.isMasterServer() / isSlaveServer() / getGerpKey() 读取。
# 相关模块
| 菜单 | 模块 / 表 | 说明 |
|---|---|---|
| [集团ERP服务器设定] | grouperpServer | 登记集团内每一台服务器的地址、身份码与集成账号,逐台验证连接;并指定各服务器负责的企业法人 |
| [集团ERP模块设定] | grouperpSetting | 列出所有参与同步的模块与菜单,标注各自走 Excel / Object / Event;可为个别模块配置特殊设定 |
| [集团ERP同步异常日志] | grouperpaudittrail | 同步失败的明细,含对端返回的错误与实际发出的报文,可勾选后重试 |
| [集团ERP同步追踪日志] | grouperpauditdata | 同步成功的明细,可勾选后重新补录到指定子服务器 |
# 同步方式
| 方式 | 适用对象 | 底层机制 | 第三方要写什么 |
|---|---|---|---|
| 记录同步 | FM 模块(资料/配置类的标准 module) | 主服务器[数据导出] → 子服务器[数据导入] | 一般只需在 module.xml 声明;有特殊需求才写 Handler |
| 对象同步 | 配置模块(已实现 DataObjectHandler) | 主服务器[创建Objects] → 子服务器[安装Objects] | 检查并改造自己的 ObjectHandler |
| 事件同步 | 界面按钮动作、非模块型配置 | 反射调用指定的类与函数 | Event 类 + 界面 Listener |
# 配置项
# module.xml 的 param
| key | 值 | 适用方式 | 说明 |
|---|---|---|---|
grouperpSyncAddFm | true | 记录 / 对象 | 让本模块参与同步 |
grouperpSyncSkipFm | true | 记录 / 对象 | 排除本模块参与同步 |
grouperpSyncLenientTable | ; 分隔的表名 | 记录 | 把框架默认不导入的表拉进同步范围 |
grouperpSyncLenientField | ; 分隔的 表名.列名 | 记录 | 把框架默认不导入的列拉进同步范围 |
grouperpSyncHandler | 全类名 | 记录 / 对象 | 本模块的自定义同步处理类 |
grouperpSettingXhtml | xhtml 路径 | 全部 | 本模块在[集团ERP模块设定]中的特殊设定弹窗 |
# navmenu.xml 的 param
| key | 值 | 适用方式 | 说明 |
|---|---|---|---|
grouperpSyncAddFm | true | 事件 | 让本菜单参与事件同步 |
grouperpSyncSkipFm | true | 事件 | 排除本菜单参与事件同步 |
# 通用前提:跨服务器 id 对齐
主/子服务器是两个独立数据库,两边同一条记录的自增 id 完全不同。三种同步方式都建立在同一个前提上:用 code 在对端反查本地 id。
因此参与同步的模块必须满足:
- 主表有
code栏位,且两边指向同一个业务实体——不能由本机流水号生成、不能包含本机id、不能在同步后被本地改写; - 所引用的外键主档,在子服务器上必须存在同
code的记录,否则报cannot find lookup code。
模块天生没有 code 时(例如一对一的设定表),需要人工构造一个稳定 code,常见做法是 code = 模块名(单例表)或 code = 所属企业法人的 code(一 BE 一行的表)。
除 code 反查外,系统会自动给参与同步的主表补一个隐藏列 gerpSyncSId,存放该记录在主服务器上的 id,用于删除定位与「前往主服务器」跳转。第三方App 不需要声明它,也不应在业务逻辑里写它。
# 方式一:记录同步(FM 模块)
适用:FM 模块——资料/配置类的标准 module。系统默认会同步所有 FM 模块,不需要写任何代码。
机制上等同于主服务器做一次[数据导出]、子服务器做一次[数据导入]:主服务器把整条记录导成 xlsx(外加可选的 7z 附件包)推给子服务器,子服务器按标准导入流程落库,该模块自身的 Checker 会照常执行。
# 1. 声明模块是否参与同步
在自己 App 的 module.xml 里声明即可:
<!-- 让本模块参与集团同步 -->
<module name="myBaseData" mess="my3pd.myBaseData" mainTable="mybasedata" fmShare="N">
<table name="mybasedata" key="code" initRow="1"/>
<param key="grouperpSyncAddFm" value="true"/>
</module>
<!-- 让本模块不参与集团同步 -->
<module name="myLocalConf" mess="my3pd.myLocalConf" mainTable="mylocalconf">
<table name="mylocalconf" key="code" initRow="1"/>
<param key="grouperpSyncSkipFm" value="true"/>
</module>
# 2. 调整同步的字段范围
因为走的是[数据导出] / [数据导入],能同步的字段范围 = 这两个功能允许的范围。被标记 dataImport="false" 的表/列不会被同步。
如果第三方App 有必须与主服务器保持一致、但框架默认不允许导入的字段,用白名单强行拉进来:
<module name="myBaseData" extend="true">
<param key="grouperpSyncLenientTable" value="mybasedataext"/>
<param key="grouperpSyncLenientField" value="mybasedataext.adminFlag;mybasedataext.apiKey"/>
</module>
| param | 语法 | 说明 |
|---|---|---|
grouperpSyncLenientTable | 表名;表名 | 整张表拉进同步范围 |
grouperpSyncLenientField | 表名.列名;表名.列名 | 精确到列,不支持通配 |
安全提醒:白名单里的字段会随报文经 HTTP 发送到每一台子服务器,不要放明文密钥或明文密码。
# 3. 特殊处理:grouperpSyncHandler
当默认的导出/导入行为不够用时(需要额外传一个 lookup 的 code、按设定动态裁剪同步字段、落地前重映射外键),给模块指定一个自定义处理类:
<module name="myBaseData" extend="true">
<param key="grouperpSyncHandler" value="com.my3pd.erp.handler.MySyncHandler"/>
</module>
系统启动时按模块把该类实例化并缓存,同步过程中用反射查找同名同签名的函数并调用,按需实现即可,不需要实现接口。与 Excel 相关的时点:
| 函数签名 | 执行侧 | 用途 |
|---|---|---|
void handlerExportExcel(DataExportConfig config, SqlEntity entity) | 主 | 增删同步字段,或往 getParamMap() 塞值 |
void handlerImportExcel(DataImportConfig config, WorkbookMapping mapping, Long beId, String moduleName) | 子 | 与上一条对称:裁剪导入字段、取回参数 |
void handlerImportExcelEntity(DataImportConfig config, SqlEntity entity, Long beId) | 子 | 实体保存前的最后加工 |
void handlerSyncDto(SeSaveParam param, GrouperpSyncDto syncDto) | 主 | 报文组装后补充自定义信息(Excel / Object 通用) |
void updateSyncDto(GrouperpSyncDto syncDto) | 子 | 落库前把 code 翻译成本机 id(Excel / Object 通用) |
示例:模块 myBaseData 的主表上有外键 myTypeId,两边 id 不同,需要用 code 传递。
package com.my3pd.erp.handler;
public class MySyncHandler {
/** 主服务器:把外键的 code 放进报文 */
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());
}
}
/** 子服务器:把 code 翻译成本机 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));
}
}
}
类型陷阱:
paramMap是Map<String, Object>,经 JSON 传输后类型会变(Java 对象变成JSONObject、数字可能变成字符串)。放入与取出的代码必须成对编写,取值统一用ConvertLib,不要直接强转。
# 4. 特殊处理:DataSwapHandler / SearchCodeHandler
grouperpSyncHandler 只作用于单个模块。以下 Handler 是跨模块的:
// 在 serverBoot 中注册(com.multiable.core.share.handler.EntityUtil)
EntityUtil.addDataSwap(new MyDataSwapHandler()); // implements DataSwapHandler
EntityUtil.addSearchCode(new MySearchCodeHandler()); // implements SearchCodeHandler
| 接口 | 作用 |
|---|---|
DataSwapHandler | 在导出/导入时增删虚拟列,做 id 与业务键之间的互换 |
SearchCodeHandler | 接管「code → 本机 id」的反查逻辑 |
# 方式二:对象同步(配置模块)
适用:已实现 DataObjectHandler 的配置模块。系统默认会同步这类配置模块。
机制上等同于主服务器做一次[创建Objects]、子服务器做一次[安装Objects]。与方式一的关键差别在落地端:不走[数据导入],而是调用第三方App 自己的 ObjectHandler.install(...)。
# 1. 检查你的 ObjectHandler
检查第三方App 自己的 ObjectHandler,集团同步走一条「按 code 重映射外键」的路径:
| DataObject param | 说明 |
|---|---|
grouperpSyncFlag | true 表示本次安装来自集团同步,而非用户手工安装 |
grouperpSyncCheck | true 表示这是同步的预演阶段,不应产生副作用(写库、发通知、发邮件) |
// 在 ObjectHandler.install(...) 中分流
boolean syncFlag = ConvertLib.toBoolean(ErpDataObjectLib.getParamValue(dsDto, "grouperpSyncFlag"));
boolean syncCheck = ConvertLib.toBoolean(ErpDataObjectLib.getParamValue(dsDto, "grouperpSyncCheck"));
// 手工安装走原有逻辑;集团同步走「按 code 重映射外键」的逻辑
boolean status = !syncFlag ? genFkTableRecord(entity) : grouperpSyncSave(entity, syncCheck);
grouperpSyncSave 的典型写法:用 code 在本机缓存表里查出本机 id,再把实体上的外键逐个换掉;syncCheck 为 true 时跳过一切写操作。
# 2. 特殊处理:grouperpSyncHandler
与方式一使用同一个 grouperpSyncHandler 参数,只是命中的是另外两个时点:
| 函数签名 | 执行侧 | 用途 |
|---|---|---|
void handlerExportObject(Long beId, String moduleName, DataObjectBaseDto dto) | 主 | 往数据对象里追加参数 |
void handlerImportObject(Long beId, String moduleName, DataObjectBaseDto dto, List<DsInstallDto> insList, Object detail) | 子 | 安装前重映射,或保留子服务器的本地值 |
void handlerSyncDto(SeSaveParam param, GrouperpSyncDto syncDto) | 主 | 报文组装后补充自定义信息(Excel / Object 通用) |
void updateSyncDto(GrouperpSyncDto syncDto) | 子 | 落库前把 code 翻译成本机 id(Excel / Object 通用) |
# 方式三:事件同步(Event)
适用:界面按钮触发的动作,以及不是标准 module 的配置(数据直接落在自己的表里、或需要在子服务器上调用某个 API)。
事件同步不传数据表,而是传一条「调用指令」:主服务器告诉子服务器「实例化哪个类、调用哪个函数、带什么载荷」,子服务器反射调用。第三方App 需要写两样东西:一个 Event 类 + 一个界面 Listener。
# 1. 编写 Event 类
一个类同时承担两侧的职责:static 函数在主服务器发起,实例函数在子服务器落地。
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 {
/** 主服务器:发起事件 */
public static List<CheckMsg> syncMySetting(String settingValue) {
GrouperpEvent event = new GrouperpEvent();
event.setEjbClass(MySettingEvent.class.getName()); // 处理类全类名
event.setEjbMethod("handlerMySetting"); // 处理函数名
event.setData(settingValue); // 载荷
event.setMenuCode("mySetting"); // 日志显示用
event.setActionMess("core.save"); // 动作 messCode
return GrouperpEJBLib.getCommonEJB().syncEvent(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字段
| 名称 | 类型 | 说明 | 必填 |
|---|---|---|---|
| ejbClass | String | 子服务器上要实例化的类的全类名 | Y |
| ejbMethod | String | 要调用的函数名 | Y |
| data | Object | 载荷,会被 JSON 序列化后传输 | Y |
| moduleName | String | 模块名,用于同步日志与告警文案 | N |
| menuCode | String | 菜单代码,用于同步日志与告警文案 | N |
| actionMess | String | 动作的 messCode,显示在同步日志的「动作」列 | N |
| dto | StLookupDto | 指向具体记录(id + code),让同步日志能定位到记录 | N |
| user | UserDto | 操作人,构造时自动取当前用户;子服务器按 usercode 映射本机 uid | 自动 |
处理函数由反射调用,形态是固定的:
| 要求 | 说明 |
|---|---|
| 参数 | Object,不能写成 JSONObject data 或 String data |
| 返回值 | 必须是 CheckMsg(返回其它类型不报错,但错误无法回传给操作人) |
| 位置 | 处理类必须在子服务器的 classpath 上,即放在 p-share 或 p-ejb,不能放 p-jsf |
# 2. 编写界面 Listener
Listener 负责两件事:在子服务器上把界面锁成只读,在主服务器上点按钮时发起事件。
若要为 FRD 已有的界面追加同步行为,通过 cawweb.xml 注册 listener(做法见后台开发须知与ERP 前端扩展)。
public class MySettingListener extends ViewBean {
MySettingBean sourceBean = null;
@Override
public void initialized() {
super.initialized();
sourceBean = (MySettingBean) GrouperpWebUtil.getCurrentBeanInstance("mySetting");
// 子服务器上锁成只读
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()) { // 只在主服务器发起同步
return;
}
GrouperpWebUtil.postMessage(MySettingEvent.syncMySetting(sourceBean.getValue()));
}
}
}
# 3. 登记菜单
菜单需要登记,才能出现在[集团ERP模块设定]中,并在子服务器上被加锁:
<menu code="mySetting" mess="my3pd.mySetting">
<param key="grouperpSyncAddFm" value="true"/>
</menu>
# 调试与排错
# 联调步骤
集团同步必须在两套系统上联调,无法在单机验证。
- 准备两套独立部署的 aiM18(各自独立数据库),部署同一版本的第三方App;
- 一台配
caw.grouperp.master=true,另一台配caw.grouperp.slave=true,两台各配一个互不相同的caw.grouperp.gerpKey; - 在主服务器的[集团ERP服务器设定]中登记两台服务器,逐条点「验证」直到通过;
- 打开[集团ERP模块设定],确认第三方App 的模块/菜单已出现在清单里,且标注的方式(Excel / Object / Event)与预期一致——没出现就说明没参与同步;
- 在主服务器上做一次保存,到子服务器上核对数据;
- 失败看[集团ERP同步异常日志],成功看[集团ERP同步追踪日志],两张表的
eventData列就是实际发出的报文。
# debug 模式
开启框架 debug 模式后,主/子服务器都会打印完整同步报文,且同步产生的临时文件不会被删除——可直接到 <jboss>/excel/、<jboss>/cawobj/(主服务器)和 <jboss>/sync/(子服务器)解包逐字段核对。这是验证「Handler 有没有生效」「某栏位到底传没传」最直接的手段。
# 常见问题
| 症状 | 先检查 |
|---|---|
| 完全不同步、无任何日志 | caw.grouperp.master / slave 配置 |
| 某个模块从来不同步 | 是否声明了 grouperpSyncAddFm;param key 有没有拼错 |
| 某几个栏位不同步 | 该栏位 dataImport="false",需用 grouperpSyncLenientField 显式声明 |
| 自定义 Handler 不执行 | ① 签名不匹配(尤其 beId 要用装箱的 Long);② 模块本身没参与同步 |
| 子服务器上外键挂错了记录 | 对象同步的 ObjectHandler.install 没有按 grouperpSyncFlag 分流,直接用了主服务器的 id |
| 子服务器日志里是 Java 异常堆栈 | 主/子服务器的第三方App 版本不一致,或处理函数的参数/返回值不符合约定 |
报 Unmatched server code | 请求携带的 gerpKey 与子服务器的 caw.grouperp.gerpKey 不一致 |