Преглед изворни кода

feat(benteler): support dynamic user updates

malk пре 2 недеља
родитељ
комит
c7a6a5a120

+ 4 - 0
mjava-benteler/src/main/java/com/malk/benteler/config/BentelerYidaConf.java

@@ -32,6 +32,10 @@ public class BentelerYidaConf {
     private String updateEmployeeFieldId;
     private String updateDescriptionFieldId;
     private String updateOrganizationalUnitIdFieldId;
+    private String updateContentFieldId;
+    private String updatePhoneFieldId;
+    private String updateEmployeeNumberFieldId;
+    private String updateJobTitleFieldId;
 
     private String offboardingFormUuid;
     private String offboardingTableFieldId;

+ 1 - 0
mjava-benteler/src/main/java/com/malk/benteler/dto/EiamBatchStage.java

@@ -8,6 +8,7 @@ public enum EiamBatchStage {
     CREATE_USER,
     PATCH_USER,
     SET_PRIMARY_ORG,
+    ADD_ORG,
     DELETE_USER,
     COMPLETED
 }

+ 2 - 0
mjava-benteler/src/main/java/com/malk/benteler/dto/EiamUpdateUserItem.java

@@ -19,5 +19,7 @@ public class EiamUpdateUserItem {
     private String phoneNumber;
     private String email;
     private String primaryOrganizationalUnitId;
+    private String additionalOrganizationalUnitId;
+    private List<String> updateContents;
     private List<EiamCustomFieldUpdate> customFields;
 }

+ 53 - 3
mjava-benteler/src/main/java/com/malk/benteler/service/BentelerYidaFormMapper.java

@@ -6,6 +6,7 @@ import com.malk.benteler.dto.EiamCreateUserItem;
 import com.malk.benteler.dto.EiamDeleteUserItem;
 import com.malk.benteler.dto.EiamUpdateUserItem;
 import com.malk.server.eiam.EiamCustomField;
+import com.malk.server.eiam.EiamCustomFieldUpdate;
 import org.apache.commons.lang3.StringUtils;
 import org.springframework.stereotype.Component;
 
@@ -24,6 +25,11 @@ public class BentelerYidaFormMapper {
 
     private static final String STATUS_SUCCESS = "成功";
     private static final String STATUS_FAILED = "失败";
+    static final String UPDATE_PRIMARY_ORG = "更新部门";
+    static final String ADD_ORG = "增加部门";
+    static final String UPDATE_PHONE = "手机号";
+    static final String UPDATE_EMPLOYEE_NUMBER = "工号";
+    static final String UPDATE_JOB_TITLE = "职位";
 
     private final BentelerYidaConf conf;
 
@@ -66,17 +72,26 @@ public class BentelerYidaFormMapper {
      * @param formInstanceId 宜搭实例 ID
      * @param rows 人员子表全量数据
      * @param organizationalUnitId EIAM 目标部门 ID
+     * @param updateContents 更新内容多选值
      * @return EIAM 更新条目
      */
     public List<EiamUpdateUserItem> mapUpdateItems(String formInstanceId,
                                                    List<Map<String, Object>> rows,
-                                                   String organizationalUnitId) {
+                                                   String organizationalUnitId,
+                                                   List<String> updateContents) {
         List<EiamUpdateUserItem> items = new ArrayList<>();
         for (Map<String, Object> row : rows) {
             EiamUpdateUserItem item = new EiamUpdateUserItem();
             item.setFormInstanceId(formInstanceId);
             item.setUserId(firstString(row.get(conf.getUpdateEmployeeFieldId() + "_id")));
-            item.setPrimaryOrganizationalUnitId(organizationalUnitId);
+            item.setUpdateContents(updateContents);
+            if (updateContents.contains(UPDATE_PRIMARY_ORG)) {
+                item.setPrimaryOrganizationalUnitId(organizationalUnitId);
+            }
+            if (updateContents.contains(ADD_ORG)) {
+                item.setAdditionalOrganizationalUnitId(organizationalUnitId);
+            }
+            mapUpdatePatchFields(item, row, updateContents);
             items.add(item);
         }
         return items;
@@ -123,7 +138,9 @@ public class BentelerYidaFormMapper {
                         conf.getOnboardingJobTitleFieldId()));
                 copyEmployee(row, target, conf.getOnboardingManagerFieldId());
             } else if (StringUtils.equals(tableFieldId, conf.getUpdateTableFieldId())) {
-                copyFields(row, target, Arrays.asList(conf.getUpdateDescriptionFieldId()));
+                copyFields(row, target, Arrays.asList(conf.getUpdatePhoneFieldId(),
+                        conf.getUpdateEmployeeNumberFieldId(), conf.getUpdateJobTitleFieldId(),
+                        conf.getUpdateDescriptionFieldId()));
                 copyEmployee(row, target, conf.getUpdateEmployeeFieldId());
             } else if (StringUtils.equals(tableFieldId, conf.getOffboardingTableFieldId())) {
                 copyFields(row, target, Arrays.asList(conf.getOffboardingRemarkFieldId()));
@@ -177,6 +194,39 @@ public class BentelerYidaFormMapper {
         return fields.isEmpty() ? null : fields;
     }
 
+    private void mapUpdatePatchFields(EiamUpdateUserItem item, Map<String, Object> row,
+                                      List<String> updateContents) {
+        if (updateContents.contains(UPDATE_PHONE)) {
+            String phoneNumber = stringValue(row.get(conf.getUpdatePhoneFieldId()));
+            item.setUsername(phoneNumber);
+            item.setPhoneNumber(phoneNumber);
+            item.setPhoneRegion("86");
+        }
+        List<EiamCustomFieldUpdate> customFields = new ArrayList<>();
+        if (updateContents.contains(UPDATE_EMPLOYEE_NUMBER)) {
+            addCustomFieldUpdate(customFields, "employee_id_number",
+                    row.get(conf.getUpdateEmployeeNumberFieldId()));
+        }
+        if (updateContents.contains(UPDATE_JOB_TITLE)) {
+            addCustomFieldUpdate(customFields, "job_title",
+                    row.get(conf.getUpdateJobTitleFieldId()));
+        }
+        item.setCustomFields(customFields.isEmpty() ? null : customFields);
+    }
+
+    private void addCustomFieldUpdate(List<EiamCustomFieldUpdate> fields, String fieldName,
+                                      Object value) {
+        String fieldValue = stringValue(value);
+        if (StringUtils.isBlank(fieldValue)) {
+            return;
+        }
+        EiamCustomFieldUpdate field = new EiamCustomFieldUpdate();
+        field.setFieldName(fieldName);
+        field.setFieldValue(fieldValue);
+        field.setOperation("replace");
+        fields.add(field);
+    }
+
     private void addCustomField(List<EiamCustomField> fields, String fieldName, Object value) {
         String fieldValue = stringValue(value);
         if (StringUtils.isBlank(fieldValue)) {

+ 35 - 5
mjava-benteler/src/main/java/com/malk/benteler/service/BentelerYidaSyncService.java

@@ -107,13 +107,20 @@ public class BentelerYidaSyncService {
                     formInstanceId, formData, rows));
         }
         if (operation == EiamBatchOperation.UPDATE) {
-            String organizationalUnitId = firstText(formData.get(
-                    conf.getUpdateOrganizationalUnitIdFieldId()));
-            if (!StringUtils.startsWith(organizationalUnitId, "ou_")) {
-                throw new McException("EIAM_ORG_INVALID", "iDaaS 部门 ID 无效");
+            List<String> updateContents = texts(formData.get(conf.getUpdateContentFieldId()));
+            if (updateContents.isEmpty()) {
+                throw new McException("EIAM_UPDATE_CONTENT_EMPTY", "更新内容不能为空");
+            }
+            String organizationalUnitId = null;
+            if (requiresOrganizationalUnit(updateContents)) {
+                organizationalUnitId = firstText(formData.get(
+                        conf.getUpdateOrganizationalUnitIdFieldId()));
+                if (!StringUtils.startsWith(organizationalUnitId, "ou_")) {
+                    throw new McException("EIAM_ORG_INVALID", "iDaaS 部门 ID 无效");
+                }
             }
             return eiamLocalService.batchUpdate(mapper.mapUpdateItems(
-                    formInstanceId, rows, organizationalUnitId));
+                    formInstanceId, rows, organizationalUnitId, updateContents));
         }
         return eiamLocalService.batchDelete(mapper.mapDeleteItems(formInstanceId, rows));
     }
@@ -217,6 +224,29 @@ public class BentelerYidaSyncService {
         return text(value);
     }
 
+    private boolean requiresOrganizationalUnit(List<String> updateContents) {
+        return updateContents.contains(BentelerYidaFormMapper.UPDATE_PRIMARY_ORG)
+                || updateContents.contains(BentelerYidaFormMapper.ADD_ORG);
+    }
+
+    private List<String> texts(Object value) {
+        List<String> values = new ArrayList<>();
+        if (value instanceof Iterable) {
+            for (Object item : (Iterable<?>) value) {
+                String itemText = text(item);
+                if (StringUtils.isNotBlank(itemText)) {
+                    values.add(itemText);
+                }
+            }
+            return values;
+        }
+        String itemText = text(value);
+        if (StringUtils.isNotBlank(itemText)) {
+            values.add(itemText);
+        }
+        return values;
+    }
+
     private String text(Object value) {
         return value == null ? null : StringUtils.trimToNull(String.valueOf(value));
     }

+ 42 - 5
mjava-benteler/src/main/java/com/malk/benteler/service/EiamLocalService.java

@@ -11,12 +11,14 @@ import com.malk.server.common.McException;
 import com.malk.server.eiam.EiamApiResponse;
 import com.malk.server.eiam.EiamConf;
 import com.malk.server.eiam.EiamCustomField;
+import com.malk.server.eiam.EiamCustomFieldUpdate;
 import com.malk.service.eiam.EiamClient_User;
 import com.malk.service.eiam.EiamService;
 import org.apache.commons.lang3.StringUtils;
 import org.springframework.stereotype.Service;
 
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -107,7 +109,7 @@ public class EiamLocalService {
 
     private EiamBatchItemResult updateOne(String token, EiamUpdateUserItem item, int index) {
         EiamBatchStage stage = EiamBatchStage.VALIDATE;
-        boolean patchCompleted = false;
+        boolean updateCompleted = false;
         try {
             validateUpdateItem(item);
             Map<String, Object> patchBody = updateBody(item);
@@ -115,19 +117,26 @@ public class EiamLocalService {
                 stage = EiamBatchStage.PATCH_USER;
                 assertEiamSuccess(eiamClientUser.patchUser(token, eiamConf.getInstanceId(),
                         eiamConf.getApplicationId(), item.getUserId(), patchBody));
-                patchCompleted = true;
+                updateCompleted = true;
             }
             if (StringUtils.isNotBlank(item.getPrimaryOrganizationalUnitId())) {
                 stage = EiamBatchStage.SET_PRIMARY_ORG;
                 assertEiamSuccess(eiamClientUser.setUserPrimaryOrganizationalUnit(token,
                         eiamConf.getInstanceId(), eiamConf.getApplicationId(), item.getUserId(),
                         item.getPrimaryOrganizationalUnitId()));
+                updateCompleted = true;
+            }
+            if (StringUtils.isNotBlank(item.getAdditionalOrganizationalUnitId())) {
+                stage = EiamBatchStage.ADD_ORG;
+                assertEiamSuccess(eiamClientUser.addUserToOrganizationalUnits(token,
+                        eiamConf.getInstanceId(), eiamConf.getApplicationId(), item.getUserId(),
+                        Collections.singletonList(item.getAdditionalOrganizationalUnitId())));
             }
             return success(index, item.getFormInstanceId(), userKey(item), item.getUserId());
         } catch (RuntimeException ex) {
             return failure(index, item == null ? null : item.getFormInstanceId(),
                     item == null ? null : userKey(item), item == null ? null : item.getUserId(),
-                    stage, ex, patchCompleted && stage == EiamBatchStage.SET_PRIMARY_ORG);
+                    stage, ex, updateCompleted);
         }
     }
 
@@ -188,7 +197,7 @@ public class EiamLocalService {
         if (StringUtils.isNotBlank(item.getEmail())) {
             body.put("emailVerified", false);
         }
-        if (item.getCustomFields() != null) {
+        if (item.getCustomFields() != null && !item.getCustomFields().isEmpty()) {
             body.put("customFields", item.getCustomFields());
         }
         return body;
@@ -230,11 +239,39 @@ public class EiamLocalService {
         if (item == null || StringUtils.isBlank(item.getUserId())) {
             throw new McException("EIAM_ITEM_INVALID", "userId 不能为空");
         }
-        if (updateBody(item).isEmpty() && StringUtils.isBlank(item.getPrimaryOrganizationalUnitId())) {
+        if (containsUpdateContent(item, BentelerYidaFormMapper.UPDATE_PHONE)
+                && StringUtils.isBlank(item.getPhoneNumber())) {
+            throw new McException("EIAM_ITEM_INVALID", "手机号不能为空");
+        }
+        if (containsUpdateContent(item, BentelerYidaFormMapper.UPDATE_EMPLOYEE_NUMBER)) {
+            validateCustomFieldUpdate(item, "employee_id_number", "工号");
+        }
+        if (containsUpdateContent(item, BentelerYidaFormMapper.UPDATE_JOB_TITLE)) {
+            validateCustomFieldUpdate(item, "job_title", "职位");
+        }
+        if (updateBody(item).isEmpty() && StringUtils.isBlank(item.getPrimaryOrganizationalUnitId())
+                && StringUtils.isBlank(item.getAdditionalOrganizationalUnitId())) {
             throw new McException("EIAM_ITEM_INVALID", "更新内容不能为空");
         }
     }
 
+    private boolean containsUpdateContent(EiamUpdateUserItem item, String updateContent) {
+        return item.getUpdateContents() != null && item.getUpdateContents().contains(updateContent);
+    }
+
+    private void validateCustomFieldUpdate(EiamUpdateUserItem item, String fieldName,
+                                           String displayName) {
+        if (item.getCustomFields() != null) {
+            for (EiamCustomFieldUpdate field : item.getCustomFields()) {
+                if (field != null && StringUtils.equals(fieldName, field.getFieldName())
+                        && StringUtils.isNotBlank(field.getFieldValue())) {
+                    return;
+                }
+            }
+        }
+        throw new McException("EIAM_ITEM_INVALID", displayName + " 不能为空");
+    }
+
     private void validateBatch(List<?> items) {
         if (items == null || items.isEmpty()) {
             throw new McException("EIAM_BATCH_EMPTY", "items 不能为空");

+ 4 - 0
mjava-benteler/src/main/resources/application.yml

@@ -49,6 +49,10 @@ benteler:
     updateEmployeeFieldId: employeeField_mrcxpt3g
     updateDescriptionFieldId: textField_mrcw2nr1
     updateOrganizationalUnitIdFieldId: selectField_mrmyvzrt_id
+    updateContentFieldId: checkboxField_mrsmvthc
+    updatePhoneFieldId: textField_mrt7u3hp
+    updateEmployeeNumberFieldId: textField_mrofclv5
+    updateJobTitleFieldId: selectField_mrn4cg3s
     offboardingFormUuid: FORM-3D8B56B5A9EE45738A03D4DB6C1D22E16Z5T
     offboardingTableFieldId: tableField_mrcxjcq5
     offboardingEmployeeFieldId: employeeField_mrcxgkq0

+ 64 - 0
mjava-benteler/src/test/java/com/malk/benteler/service/BentelerYidaFormMapperTest.java

@@ -4,7 +4,9 @@ import com.malk.benteler.config.BentelerYidaConf;
 import com.malk.benteler.dto.EiamBatchItemResult;
 import com.malk.benteler.dto.EiamBatchStage;
 import com.malk.benteler.dto.EiamCreateUserItem;
+import com.malk.benteler.dto.EiamUpdateUserItem;
 import com.malk.server.eiam.EiamCustomField;
+import com.malk.server.eiam.EiamCustomFieldUpdate;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -63,6 +65,9 @@ public class BentelerYidaFormMapperTest {
         Map<String, Object> first = new HashMap<>();
         first.put(conf.getUpdateEmployeeFieldId(), "显示姓名");
         first.put(conf.getUpdateEmployeeFieldId() + "_id", Arrays.asList("user_1"));
+        first.put(conf.getUpdatePhoneFieldId(), "17612168216");
+        first.put(conf.getUpdateEmployeeNumberFieldId(), "EMP-001");
+        first.put(conf.getUpdateJobTitleFieldId(), "Software Engineer");
         first.put(conf.getUpdateDescriptionFieldId(), "说明一");
         Map<String, Object> second = new HashMap<>();
         second.put(conf.getUpdateEmployeeFieldId() + "_id", Arrays.asList("user_2"));
@@ -73,6 +78,9 @@ public class BentelerYidaFormMapperTest {
 
         assertEquals(2, rebuilt.size());
         assertEquals(Arrays.asList("user_1"), rebuilt.get(0).get(conf.getUpdateEmployeeFieldId()));
+        assertEquals("17612168216", rebuilt.get(0).get(conf.getUpdatePhoneFieldId()));
+        assertEquals("EMP-001", rebuilt.get(0).get(conf.getUpdateEmployeeNumberFieldId()));
+        assertEquals("Software Engineer", rebuilt.get(0).get(conf.getUpdateJobTitleFieldId()));
         assertFalse(rebuilt.get(0).containsKey(conf.getUpdateEmployeeFieldId() + "_id"));
         assertEquals("成功", rebuilt.get(0).get(conf.getDetailStatusFieldId()));
         assertEquals("失败", rebuilt.get(1).get(conf.getDetailStatusFieldId()));
@@ -96,6 +104,49 @@ public class BentelerYidaFormMapperTest {
         assertEquals("", successMessage);
     }
 
+    @Test
+    public void mapUpdateItems_allSelections_mapsOnlyRequestedOperations() {
+        Map<String, Object> row = new HashMap<>();
+        row.put(conf.getUpdateEmployeeFieldId() + "_id", Collections.singletonList("user_1"));
+        row.put(conf.getUpdatePhoneFieldId(), "17612168216");
+        row.put(conf.getUpdateEmployeeNumberFieldId(), "EMP-001");
+        row.put(conf.getUpdateJobTitleFieldId(), "Software Engineer");
+        List<String> updateContents = Arrays.asList(BentelerYidaFormMapper.UPDATE_PRIMARY_ORG,
+                BentelerYidaFormMapper.ADD_ORG, BentelerYidaFormMapper.UPDATE_PHONE,
+                BentelerYidaFormMapper.UPDATE_EMPLOYEE_NUMBER,
+                BentelerYidaFormMapper.UPDATE_JOB_TITLE);
+
+        EiamUpdateUserItem item = mapper.mapUpdateItems("form_update",
+                Collections.singletonList(row), "ou_target", updateContents).get(0);
+
+        assertEquals("user_1", item.getUserId());
+        assertEquals("ou_target", item.getPrimaryOrganizationalUnitId());
+        assertEquals("ou_target", item.getAdditionalOrganizationalUnitId());
+        assertEquals("17612168216", item.getUsername());
+        assertEquals("17612168216", item.getPhoneNumber());
+        assertEquals("86", item.getPhoneRegion());
+        assertEquals("EMP-001", customFieldValue(item, "employee_id_number"));
+        assertEquals("Software Engineer", customFieldValue(item, "job_title"));
+    }
+
+    @Test
+    public void mapUpdateItems_departmentOnly_ignoresPopulatedPatchFields() {
+        Map<String, Object> row = new HashMap<>();
+        row.put(conf.getUpdateEmployeeFieldId() + "_id", Collections.singletonList("user_1"));
+        row.put(conf.getUpdatePhoneFieldId(), "17612168216");
+        row.put(conf.getUpdateEmployeeNumberFieldId(), "EMP-001");
+        row.put(conf.getUpdateJobTitleFieldId(), "Software Engineer");
+
+        EiamUpdateUserItem item = mapper.mapUpdateItems("form_update",
+                Collections.singletonList(row), "ou_target",
+                Collections.singletonList(BentelerYidaFormMapper.UPDATE_PRIMARY_ORG)).get(0);
+
+        assertEquals("ou_target", item.getPrimaryOrganizationalUnitId());
+        assertEquals(null, item.getAdditionalOrganizationalUnitId());
+        assertEquals(null, item.getPhoneNumber());
+        assertEquals(null, item.getCustomFields());
+    }
+
     private EiamBatchItemResult result(boolean success) {
         return EiamBatchItemResult.builder().success(success).stage(EiamBatchStage.COMPLETED)
                 .build();
@@ -109,4 +160,17 @@ public class BentelerYidaFormMapperTest {
         }
         return null;
     }
+
+    private String customFieldValue(EiamUpdateUserItem item, String fieldName) {
+        if (item.getCustomFields() == null) {
+            return null;
+        }
+        for (EiamCustomFieldUpdate field : item.getCustomFields()) {
+            if (fieldName.equals(field.getFieldName())) {
+                assertEquals("replace", field.getOperation());
+                return field.getFieldValue();
+            }
+        }
+        return null;
+    }
 }

+ 30 - 0
mjava-benteler/src/test/java/com/malk/benteler/service/BentelerYidaSyncServiceTest.java

@@ -119,6 +119,8 @@ public class BentelerYidaSyncServiceTest {
         row.put(conf.getUpdateEmployeeFieldId() + "_id",
                 Collections.singletonList("user_1"));
         Map<String, Object> formData = new HashMap<>();
+        formData.put(conf.getUpdateContentFieldId(),
+                Collections.singletonList(BentelerYidaFormMapper.UPDATE_PRIMARY_ORG));
         formData.put(conf.getUpdateOrganizationalUnitIdFieldId(), "ou_target");
         formData.put(conf.getUpdateTableFieldId(), Collections.singletonList(row));
         when(ydClientForm.getForm(any(YDAuth.class), anyString(), isNull()))
@@ -138,6 +140,34 @@ public class BentelerYidaSyncServiceTest {
         assertEquals("ou_target", items.get(0).getPrimaryOrganizationalUnitId());
     }
 
+    @Test
+    @SuppressWarnings("unchecked")
+    public void syncUpdate_phoneOnly_doesNotRequireOrganizationalUnit() {
+        Map<String, Object> row = new HashMap<>();
+        row.put(conf.getUpdateEmployeeFieldId() + "_id",
+                Collections.singletonList("user_1"));
+        row.put(conf.getUpdatePhoneFieldId(), "17612168216");
+        Map<String, Object> formData = new HashMap<>();
+        formData.put(conf.getUpdateContentFieldId(),
+                Collections.singletonList(BentelerYidaFormMapper.UPDATE_PHONE));
+        formData.put(conf.getUpdateTableFieldId(), Collections.singletonList(row));
+        when(ydClientForm.getForm(any(YDAuth.class), anyString(), isNull()))
+                .thenReturn(instance(formData));
+        when(eiamLocalService.batchUpdate(any())).thenReturn(batchResult(
+                EiamBatchOperation.UPDATE, true));
+        when(ydClientForm.updateForm(any(YDAuth.class), anyString(), anyString(), anyMap()))
+                .thenReturn(Collections.emptyMap());
+
+        EiamFormSyncResult result = service.syncUpdate("form_update_phone");
+
+        assertEquals(1, result.getSuccessCount());
+        ArgumentCaptor<List> itemsCaptor = ArgumentCaptor.forClass(List.class);
+        verify(eiamLocalService).batchUpdate(itemsCaptor.capture());
+        List<EiamUpdateUserItem> items = (List<EiamUpdateUserItem>) itemsCaptor.getValue();
+        assertEquals("17612168216", items.get(0).getUsername());
+        assertEquals(null, items.get(0).getPrimaryOrganizationalUnitId());
+    }
+
     @Test
     public void syncDelete_exactlyFiftyRowsFallsBackWhenDetailQueryThrowsNpe() {
         List<Map<String, Object>> inlineRows = new ArrayList<>();

+ 83 - 0
mjava-benteler/src/test/java/com/malk/benteler/service/EiamLocalServiceTest.java

@@ -23,8 +23,10 @@ import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
 import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.ArgumentMatchers.anyList;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
@@ -166,6 +168,80 @@ public class EiamLocalServiceTest {
         assertTrue(result.getItems().get(0).getMessage().contains("账户属性可能已更新"));
     }
 
+    @Test
+    public void batchUpdate_primaryOrgOnly_callsOnlyPrimaryOrgAction() {
+        EiamUpdateUserItem item = updateItem();
+        item.setPrimaryOrganizationalUnitId("ou_new");
+        when(eiamClientUser.setUserPrimaryOrganizationalUnit(anyString(), anyString(),
+                anyString(), anyString(), anyString())).thenReturn(new EiamApiResponse());
+
+        EiamBatchResult result = localService.batchUpdate(Collections.singletonList(item));
+
+        assertEquals(1, result.getSuccessCount());
+        verify(eiamClientUser).setUserPrimaryOrganizationalUnit(anyString(), anyString(),
+                anyString(), anyString(), anyString());
+        verify(eiamClientUser, never()).patchUser(anyString(), anyString(), anyString(),
+                anyString(), anyMap());
+        verify(eiamClientUser, never()).addUserToOrganizationalUnits(anyString(), anyString(),
+                anyString(), anyString(), anyList());
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    public void batchUpdate_phone_updatesUsernameAndPhoneDefaults() {
+        EiamUpdateUserItem item = updateItem();
+        item.setUpdateContents(Collections.singletonList(BentelerYidaFormMapper.UPDATE_PHONE));
+        item.setUsername("17612168216");
+        item.setPhoneNumber("17612168216");
+        when(eiamClientUser.patchUser(anyString(), anyString(), anyString(),
+                anyString(), anyMap())).thenReturn(new EiamApiResponse());
+
+        EiamBatchResult result = localService.batchUpdate(Collections.singletonList(item));
+
+        assertEquals(1, result.getSuccessCount());
+        ArgumentCaptor<Map> bodyCaptor = ArgumentCaptor.forClass(Map.class);
+        verify(eiamClientUser).patchUser(anyString(), anyString(), anyString(),
+                anyString(), bodyCaptor.capture());
+        assertEquals("17612168216", bodyCaptor.getValue().get("username"));
+        assertEquals("17612168216", bodyCaptor.getValue().get("phoneNumber"));
+        assertEquals("86", bodyCaptor.getValue().get("phoneRegion"));
+        assertEquals(true, bodyCaptor.getValue().get("phoneNumberVerified"));
+    }
+
+    @Test
+    public void batchUpdate_additionalOrgOnly_preservesPrimaryOrg() {
+        EiamUpdateUserItem item = updateItem();
+        item.setAdditionalOrganizationalUnitId("ou_additional");
+        when(eiamClientUser.addUserToOrganizationalUnits(anyString(), anyString(), anyString(),
+                anyString(), anyList())).thenReturn(new EiamApiResponse());
+
+        EiamBatchResult result = localService.batchUpdate(Collections.singletonList(item));
+
+        assertEquals(1, result.getSuccessCount());
+        verify(eiamClientUser).addUserToOrganizationalUnits(anyString(), anyString(), anyString(),
+                anyString(), anyList());
+        verify(eiamClientUser, never()).setUserPrimaryOrganizationalUnit(anyString(), anyString(),
+                anyString(), anyString(), anyString());
+    }
+
+    @Test
+    public void batchUpdate_additionalOrgFailure_reportsEarlierPatch() {
+        EiamUpdateUserItem item = updateItem();
+        item.setUsername("17612168216");
+        item.setPhoneNumber("17612168216");
+        item.setAdditionalOrganizationalUnitId("ou_additional");
+        when(eiamClientUser.patchUser(anyString(), anyString(), anyString(),
+                anyString(), anyMap())).thenReturn(new EiamApiResponse());
+        when(eiamClientUser.addUserToOrganizationalUnits(anyString(), anyString(), anyString(),
+                anyString(), anyList())).thenReturn(
+                errorResponse("OrganizationUnitIdNotInScopes", "OU not in scope"));
+
+        EiamBatchResult result = localService.batchUpdate(Collections.singletonList(item));
+
+        assertEquals(EiamBatchStage.ADD_ORG, result.getItems().get(0).getStage());
+        assertTrue(result.getItems().get(0).getMessage().contains("账户属性可能已更新"));
+    }
+
     @Test
     public void batchDelete_partialFailure_continuesFollowingItems() {
         when(eiamClientUser.deleteUser(anyString(), anyString(), anyString(), anyString()))
@@ -216,6 +292,13 @@ public class EiamLocalServiceTest {
         return item;
     }
 
+    private EiamUpdateUserItem updateItem() {
+        EiamUpdateUserItem item = new EiamUpdateUserItem();
+        item.setFormInstanceId("form_update");
+        item.setUserId("user_1");
+        return item;
+    }
+
     private EiamApiResponse successResponse(String userId) {
         EiamApiResponse response = new EiamApiResponse();
         response.setUserId(userId);

+ 4 - 0
mjava-benteler/src/test/java/com/malk/benteler/service/TestBentelerYidaConf.java

@@ -34,6 +34,10 @@ final class TestBentelerYidaConf {
         conf.setUpdateEmployeeFieldId("employee_update");
         conf.setUpdateDescriptionFieldId("update_description");
         conf.setUpdateOrganizationalUnitIdFieldId("update_org_id");
+        conf.setUpdateContentFieldId("update_contents");
+        conf.setUpdatePhoneFieldId("update_phone");
+        conf.setUpdateEmployeeNumberFieldId("update_employee_no");
+        conf.setUpdateJobTitleFieldId("update_job_title");
         conf.setOffboardingTableFieldId("table_delete");
         conf.setOffboardingEmployeeFieldId("employee_delete");
         conf.setOffboardingRemarkFieldId("remark");

+ 16 - 0
mjava/src/main/java/com/malk/service/eiam/EiamClient_User.java

@@ -2,6 +2,7 @@ package com.malk.service.eiam;
 
 import com.malk.server.eiam.EiamApiResponse;
 
+import java.util.List;
 import java.util.Map;
 
 /**
@@ -54,6 +55,21 @@ public interface EiamClient_User {
                                                      String applicationId, String userId,
                                                      String organizationalUnitId);
 
+    /**
+     * 将 EIAM 账户加入多个从属组织,不修改现有主组织和其他从属组织。
+     *
+     * @param accessToken Bearer token
+     * @param instanceId IDaaS 实例 ID
+     * @param applicationId 应用 ID
+     * @param userId EIAM 账户 ID
+     * @param organizationalUnitIds 要追加的组织 ID 列表
+     * @return EIAM 原始响应
+     * @apiNote https://help.aliyun.com/zh/idaas/eiam/developer-reference/api-eiam-developerapi-2022-02-25-addusertoorganizationalunits
+     */
+    EiamApiResponse addUserToOrganizationalUnits(String accessToken, String instanceId,
+                                                 String applicationId, String userId,
+                                                 List<String> organizationalUnitIds);
+
     /**
      * 删除 EIAM 账户。
      *

+ 13 - 0
mjava/src/main/java/com/malk/service/eiam/impl/EiamClient_UserImpl.java

@@ -10,6 +10,7 @@ import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 
 /**
@@ -56,6 +57,18 @@ public class EiamClient_UserImpl implements EiamClient_User {
         return parseActionResponse(response);
     }
 
+    @Override
+    public EiamApiResponse addUserToOrganizationalUnits(String accessToken, String instanceId,
+                                                        String applicationId, String userId,
+                                                        List<String> organizationalUnitIds) {
+        Map<String, Object> body = new HashMap<>();
+        body.put("organizationalUnitIds", organizationalUnitIds);
+        String url = userUrl(instanceId, applicationId, userId)
+                + "/actions/addUserToOrganizationalUnits";
+        String response = UtilHttp.doPost(url, headers(accessToken), null, body);
+        return parseActionResponse(response);
+    }
+
     @Override
     public EiamApiResponse deleteUser(String accessToken, String instanceId, String applicationId,
                                       String userId) {