|
@@ -1,27 +1,34 @@
|
|
|
package com.malk.minglei.service.impl.port;
|
|
package com.malk.minglei.service.impl.port;
|
|
|
|
|
|
|
|
import com.alibaba.fastjson.JSON;
|
|
import com.alibaba.fastjson.JSON;
|
|
|
|
|
+import com.fasterxml.jackson.databind.JsonNode;
|
|
|
|
|
+import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
import com.malk.minglei.config.YidaApplicationsProperties;
|
|
import com.malk.minglei.config.YidaApplicationsProperties;
|
|
|
import com.malk.minglei.dto.TravelApplicationPushRequest;
|
|
import com.malk.minglei.dto.TravelApplicationPushRequest;
|
|
|
|
|
+import com.malk.minglei.service.approval.DingTalkApprovalClient;
|
|
|
|
|
+import com.malk.minglei.service.employee.EmployeeRosterService;
|
|
|
import com.malk.minglei.service.port.TravelApplicationPushService;
|
|
import com.malk.minglei.service.port.TravelApplicationPushService;
|
|
|
import com.malk.minglei.service.yida.YidaProcessOpenApiClient;
|
|
import com.malk.minglei.service.yida.YidaProcessOpenApiClient;
|
|
|
import com.malk.server.common.McException;
|
|
import com.malk.server.common.McException;
|
|
|
import com.malk.server.dingtalk.DDConf;
|
|
import com.malk.server.dingtalk.DDConf;
|
|
|
import com.malk.server.dingtalk.DDR_New;
|
|
import com.malk.server.dingtalk.DDR_New;
|
|
|
|
|
+import com.malk.service.dingtalk.DDClient;
|
|
|
import org.slf4j.Logger;
|
|
import org.slf4j.Logger;
|
|
|
import org.slf4j.LoggerFactory;
|
|
import org.slf4j.LoggerFactory;
|
|
|
import org.springframework.stereotype.Service;
|
|
import org.springframework.stereotype.Service;
|
|
|
|
|
|
|
|
|
|
+import java.math.BigDecimal;
|
|
|
import java.time.LocalDate;
|
|
import java.time.LocalDate;
|
|
|
import java.time.ZoneId;
|
|
import java.time.ZoneId;
|
|
|
import java.util.ArrayList;
|
|
import java.util.ArrayList;
|
|
|
|
|
+import java.util.Arrays;
|
|
|
import java.util.Collections;
|
|
import java.util.Collections;
|
|
|
import java.util.LinkedHashMap;
|
|
import java.util.LinkedHashMap;
|
|
|
import java.util.List;
|
|
import java.util.List;
|
|
|
import java.util.Map;
|
|
import java.util.Map;
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * 将钉钉 OA 已通过的出差审批数据映射到宜搭出差申请流程。
|
|
|
|
|
|
|
+ * 将钉钉 OA 审批实例详情映射到宜搭出差申请流程。
|
|
|
* <p>
|
|
* <p>
|
|
|
* 该实现使用出差申请流程所属的协同办公宜搭应用凭据,人员和部门字段只传递钉钉 ID,避免依赖名称反查。
|
|
* 该实现使用出差申请流程所属的协同办公宜搭应用凭据,人员和部门字段只传递钉钉 ID,避免依赖名称反查。
|
|
|
*/
|
|
*/
|
|
@@ -87,8 +94,12 @@ public class TravelApplicationPushServiceImpl implements TravelApplicationPushSe
|
|
|
private static final String BUDGET_TOTAL_COST_FIELD_ID = "numberField_mvorr6ox";
|
|
private static final String BUDGET_TOTAL_COST_FIELD_ID = "numberField_mvorr6ox";
|
|
|
|
|
|
|
|
private final YidaProcessOpenApiClient yidaProcessOpenApiClient;
|
|
private final YidaProcessOpenApiClient yidaProcessOpenApiClient;
|
|
|
|
|
+ private final DDClient dingTalkClient;
|
|
|
|
|
+ private final DingTalkApprovalClient dingTalkApprovalClient;
|
|
|
private final DDConf dingTalkConfiguration;
|
|
private final DDConf dingTalkConfiguration;
|
|
|
private final YidaApplicationsProperties.ApplicationCredentials coordinationOfficeConfiguration;
|
|
private final YidaApplicationsProperties.ApplicationCredentials coordinationOfficeConfiguration;
|
|
|
|
|
+ private final EmployeeRosterService employeeRosterService;
|
|
|
|
|
+ private final ObjectMapper objectMapper;
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
* 创建出差申请推送服务。
|
|
* 创建出差申请推送服务。
|
|
@@ -96,28 +107,44 @@ public class TravelApplicationPushServiceImpl implements TravelApplicationPushSe
|
|
|
* @param yidaProcessOpenApiClient 宜搭流程 OpenAPI 客户端
|
|
* @param yidaProcessOpenApiClient 宜搭流程 OpenAPI 客户端
|
|
|
* @param dingTalkConfiguration 钉钉应用配置
|
|
* @param dingTalkConfiguration 钉钉应用配置
|
|
|
* @param applications 宜搭应用配置
|
|
* @param applications 宜搭应用配置
|
|
|
|
|
+ * @param employeeRosterService 员工花名册服务
|
|
|
*/
|
|
*/
|
|
|
public TravelApplicationPushServiceImpl(YidaProcessOpenApiClient yidaProcessOpenApiClient,
|
|
public TravelApplicationPushServiceImpl(YidaProcessOpenApiClient yidaProcessOpenApiClient,
|
|
|
|
|
+ DDClient dingTalkClient,
|
|
|
|
|
+ DingTalkApprovalClient dingTalkApprovalClient,
|
|
|
DDConf dingTalkConfiguration,
|
|
DDConf dingTalkConfiguration,
|
|
|
- YidaApplicationsProperties applications) {
|
|
|
|
|
|
|
+ YidaApplicationsProperties applications,
|
|
|
|
|
+ EmployeeRosterService employeeRosterService,
|
|
|
|
|
+ ObjectMapper objectMapper) {
|
|
|
this.yidaProcessOpenApiClient = yidaProcessOpenApiClient;
|
|
this.yidaProcessOpenApiClient = yidaProcessOpenApiClient;
|
|
|
|
|
+ this.dingTalkClient = dingTalkClient;
|
|
|
|
|
+ this.dingTalkApprovalClient = dingTalkApprovalClient;
|
|
|
this.dingTalkConfiguration = dingTalkConfiguration;
|
|
this.dingTalkConfiguration = dingTalkConfiguration;
|
|
|
this.coordinationOfficeConfiguration = applications.getCoordinationOffice();
|
|
this.coordinationOfficeConfiguration = applications.getCoordinationOffice();
|
|
|
|
|
+ this.employeeRosterService = employeeRosterService;
|
|
|
|
|
+ this.objectMapper = objectMapper;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
* 发起新的宜搭出差申请流程。
|
|
* 发起新的宜搭出差申请流程。
|
|
|
*
|
|
*
|
|
|
- * @param request 已通过 OA 审批的主表和三张子表数据
|
|
|
|
|
|
|
+ * @param request 仅包含 OA 审批实例 ID 的请求
|
|
|
* @return OA 审批实例与宜搭流程实例的关联结果
|
|
* @return OA 审批实例与宜搭流程实例的关联结果
|
|
|
*/
|
|
*/
|
|
|
@Override
|
|
@Override
|
|
|
public Map<String, Object> push(TravelApplicationPushRequest request) {
|
|
public Map<String, Object> push(TravelApplicationPushRequest request) {
|
|
|
validateConfiguration();
|
|
validateConfiguration();
|
|
|
|
|
|
|
|
- String userId = requireText(request.getUserId(), "Travel application userId must not be blank");
|
|
|
|
|
- String title = requireText(request.getTitle(), "Travel application title must not be blank");
|
|
|
|
|
- Map<String, Object> formData = toYidaFormData(request, userId, title);
|
|
|
|
|
|
|
+ String oaProcessInstanceId = requireText(request.getOaProcessInstanceId(),
|
|
|
|
|
+ "Travel application oaProcessInstanceId must not be blank");
|
|
|
|
|
+ TravelApplicationPushRequest application = queryTravelApplication(oaProcessInstanceId);
|
|
|
|
|
+ String userId = requireText(application.getUserId(), "OA approval instance originator userId must not be blank");
|
|
|
|
|
+ String title = requireText(application.getTitle(), "OA approval instance title must not be blank");
|
|
|
|
|
+ Map<String, Object> formData = toYidaFormData(application, userId, title);
|
|
|
|
|
+ // 临时核对 OA 值是否已转换为宜搭字段;确认线上部署后可按需关闭该日志。
|
|
|
|
|
+ LOGGER.info("Travel application parsed fields processInstanceId={} transportation={} tripMode={} itineraryRows={}",
|
|
|
|
|
+ oaProcessInstanceId, application.getTransportation(), application.getTripMode(),
|
|
|
|
|
+ JSON.toJSONString(formData.get(ITINERARY_TABLE_FIELD_ID)));
|
|
|
Map<String, Object> body = new LinkedHashMap<String, Object>();
|
|
Map<String, Object> body = new LinkedHashMap<String, Object>();
|
|
|
// 流程编码只在协同办公应用内有效,必须使用该应用的 appType 和 systemToken 发起流程。
|
|
// 流程编码只在协同办公应用内有效,必须使用该应用的 appType 和 systemToken 发起流程。
|
|
|
body.put("appType", requireText(coordinationOfficeConfiguration.getAppType(), "YIDA_COORDINATION_OFFICE_APP_TYPE is not configured"));
|
|
body.put("appType", requireText(coordinationOfficeConfiguration.getAppType(), "YIDA_COORDINATION_OFFICE_APP_TYPE is not configured"));
|
|
@@ -133,7 +160,7 @@ public class TravelApplicationPushServiceImpl implements TravelApplicationPushSe
|
|
|
|
|
|
|
|
// 仅记录追踪 ID、发起人和子表数量,避免将 OA 审批数据写入日志。
|
|
// 仅记录追踪 ID、发起人和子表数量,避免将 OA 审批数据写入日志。
|
|
|
LOGGER.info("Starting travel application from OA processInstanceId={} userId={} itineraryCount={} flightCount={} budgetCount={}",
|
|
LOGGER.info("Starting travel application from OA processInstanceId={} userId={} itineraryCount={} flightCount={} budgetCount={}",
|
|
|
- request.getOaProcessInstanceId(), userId, sizeOf(request.getItineraries()), sizeOf(request.getFlights()), sizeOf(request.getBudgets()));
|
|
|
|
|
|
|
+ oaProcessInstanceId, userId, sizeOf(application.getItineraries()), sizeOf(application.getFlights()), sizeOf(application.getBudgets()));
|
|
|
String processInstanceId;
|
|
String processInstanceId;
|
|
|
try {
|
|
try {
|
|
|
processInstanceId = extractProcessInstanceId(yidaProcessOpenApiClient.startProcess(body));
|
|
processInstanceId = extractProcessInstanceId(yidaProcessOpenApiClient.startProcess(body));
|
|
@@ -144,18 +171,707 @@ public class TravelApplicationPushServiceImpl implements TravelApplicationPushSe
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
Map<String, Object> result = new LinkedHashMap<String, Object>();
|
|
Map<String, Object> result = new LinkedHashMap<String, Object>();
|
|
|
- result.put("oaProcessInstanceId", request.getOaProcessInstanceId());
|
|
|
|
|
|
|
+ result.put("oaProcessInstanceId", oaProcessInstanceId);
|
|
|
result.put("yidaProcessInstanceId", processInstanceId);
|
|
result.put("yidaProcessInstanceId", processInstanceId);
|
|
|
result.put("formUuid", FORM_UUID);
|
|
result.put("formUuid", FORM_UUID);
|
|
|
return result;
|
|
return result;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 调用钉钉审批实例详情接口,并将 OA 表单组件转换为既有的宜搭字段映射模型。
|
|
|
|
|
+ * 钉钉明细控件可能以对象、数组或 JSON 字符串返回,因此统一先按 JSON 节点解析。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param oaProcessInstanceId 钉钉 OA 审批实例 ID
|
|
|
|
|
+ * @return 已填充主表和三张子表的内部申请模型
|
|
|
|
|
+ */
|
|
|
|
|
+ private TravelApplicationPushRequest queryTravelApplication(String oaProcessInstanceId) {
|
|
|
|
|
+ String accessToken = requireText(dingTalkClient.getAccessToken(), "Unable to obtain DingTalk access token");
|
|
|
|
|
+ Map<String, Object> detail = dingTalkApprovalClient.getProcessInstanceDetail(accessToken, oaProcessInstanceId);
|
|
|
|
|
+ // 临时排查 OA 字段结构:详情响应不包含 accessToken,仅输出钉钉原始审批 JSON。
|
|
|
|
|
+ LOGGER.info("DingTalk OA approval raw detail processInstanceId={} payload={}",
|
|
|
|
|
+ oaProcessInstanceId, JSON.toJSONString(detail));
|
|
|
|
|
+ if (Boolean.FALSE.equals(detail.get("success"))) {
|
|
|
|
|
+ throw new IllegalArgumentException("DingTalk OA approval instance query was not successful");
|
|
|
|
|
+ }
|
|
|
|
|
+ Map<String, Object> instance = objectMap(detail.get("result"));
|
|
|
|
|
+ if (instance.isEmpty()) {
|
|
|
|
|
+ throw new IllegalArgumentException("OA approval instance does not contain result data");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ List<Map<String, Object>> components = componentList(instance.get("formComponentValues"));
|
|
|
|
|
+ if (components.isEmpty()) {
|
|
|
|
|
+ components = componentList(instance.get("form_component_values"));
|
|
|
|
|
+ }
|
|
|
|
|
+ if (components.isEmpty()) {
|
|
|
|
|
+ throw new IllegalArgumentException("OA approval instance does not contain formComponentValues");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ TravelApplicationPushRequest application = new TravelApplicationPushRequest();
|
|
|
|
|
+ application.setOaProcessInstanceId(oaProcessInstanceId);
|
|
|
|
|
+ application.setUserId(firstText(instance, "originatorUserId", "originator_userid", "originatorUserid"));
|
|
|
|
|
+ if (isBlank(application.getUserId())) {
|
|
|
|
|
+ List<String> applicantIds = componentUserIds(components, "申请人");
|
|
|
|
|
+ if (!applicantIds.isEmpty()) {
|
|
|
|
|
+ application.setUserId(applicantIds.get(0));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ application.setTitle(firstText(instance, "title"));
|
|
|
|
|
+ application.setCompany(componentText(components, "申请公司", "申请公司名称", "所属公司", "公司"));
|
|
|
|
|
+ application.setDepartmentId(componentDepartment(components, "申请部门", "部门"));
|
|
|
|
|
+ application.setApplyDate(componentText(components, "申请日期", "申请时间", "出差申请日期"));
|
|
|
|
|
+ if (isBlank(application.getApplyDate())) {
|
|
|
|
|
+ application.setApplyDate(firstText(instance, "createTime", "create_time"));
|
|
|
|
|
+ }
|
|
|
|
|
+ application.setPosition(componentText(components, "申请人职位", "申请人职务", "职位", "职务", "岗位"));
|
|
|
|
|
+ application.setMobile(componentText(components, "手机号", "手机号码", "联系电话"));
|
|
|
|
|
+ application.setActualTravelerUserIds(componentUserIds(components, "实际出差人", "实际出行人", "出差人", "出行人"));
|
|
|
|
|
+ application.setEmployeeNo(componentText(components, "工号", "人员ID", "员工编号", "员工工号"));
|
|
|
|
|
+ application.setJobGrade(componentText(components, "职等", "职级"));
|
|
|
|
|
+ application.setTravelType(componentText(components, "出差类型", "出差性质", "出差方式"));
|
|
|
|
|
+ application.setCustomerName(componentText(components, "关联客户", "客户名称", "客户", "客户/项目"));
|
|
|
|
|
+ application.setTravelReason(componentText(components, "出差事由", "出差原因", "事由"));
|
|
|
|
|
+ application.setTransportation(componentText(components, "交通工具"));
|
|
|
|
|
+ application.setTripMode(componentText(components, "单程往返", "是否往返", "往返类型"));
|
|
|
|
|
+ // 钉钉 OA 当前返回的控件标题为“日程”,bizAlias 为 itinerary;同时保留历史标题兼容。
|
|
|
|
|
+ List<Map<String, String>> itineraryRows = componentRows(components,
|
|
|
|
|
+ "行程", "行程明细", "行程数据", "日程", "itinerary");
|
|
|
|
|
+ List<TravelApplicationPushRequest.Itinerary> itineraries = toItineraries(itineraryRows);
|
|
|
|
|
+ if (itineraries.isEmpty()) {
|
|
|
|
|
+ TravelApplicationPushRequest.Itinerary itinerary = flatItinerary(components);
|
|
|
|
|
+ if (hasItineraryValue(itinerary)) {
|
|
|
|
|
+ itineraries.add(itinerary);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (isBlank(application.getTransportation()) && !itineraryRows.isEmpty()) {
|
|
|
|
|
+ application.setTransportation(componentRowsText(itineraryRows, "交通工具", "vehicle"));
|
|
|
|
|
+ }
|
|
|
|
|
+ if (isBlank(application.getTripMode()) && !itineraryRows.isEmpty()) {
|
|
|
|
|
+ application.setTripMode(componentRowsText(itineraryRows, "单程往返", "是否往返", "singleOrReturn"));
|
|
|
|
|
+ }
|
|
|
|
|
+ application.setItineraries(itineraries);
|
|
|
|
|
+
|
|
|
|
|
+ List<Map<String, String>> flightRows = componentRows(components, "飞机航程", "机票航程", "机票信息");
|
|
|
|
|
+ List<TravelApplicationPushRequest.Flight> flights = toFlights(flightRows);
|
|
|
|
|
+ String ticketReserved = componentText(components, "是否预定机票", "是否预订机票");
|
|
|
|
|
+ if (isBlank(ticketReserved)) {
|
|
|
|
|
+ ticketReserved = componentRowsText(flightRows, "是否预定机票", "是否预订机票");
|
|
|
|
|
+ }
|
|
|
|
|
+ applyFlightDefaults(flights, ticketReserved);
|
|
|
|
|
+ application.setFlights(flights);
|
|
|
|
|
+ application.setBudgets(toBudgets(componentRows(components, "费用预算", "预算明细")));
|
|
|
|
|
+ return application;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * OA 的“行程”在当前表单中由主表的多个字段组成,而不是明细控件。
|
|
|
|
|
+ * 当未读取到行程明细行时,将这些字段合成为一条宜搭行程记录。
|
|
|
|
|
+ */
|
|
|
|
|
+ private TravelApplicationPushRequest.Itinerary flatItinerary(List<Map<String, Object>> components) {
|
|
|
|
|
+ TravelApplicationPushRequest.Itinerary value = new TravelApplicationPushRequest.Itinerary();
|
|
|
|
|
+ value.setDepartureCity(componentText(components, "出发地", "出发城市", "出发地点"));
|
|
|
|
|
+ value.setDestinationCity(componentText(components, "目的地", "目的城市", "到达地点"));
|
|
|
|
|
+ String startTime = componentText(components, "开始时间", "出发时间");
|
|
|
|
|
+ String startDate = componentText(components, "开始日期", "出发日期");
|
|
|
|
|
+ value.setStartDate(firstNonBlank(datePart(startTime), datePart(startDate)));
|
|
|
|
|
+ value.setStartPeriod(firstNonBlank(periodPart(startTime), periodPart(startDate)));
|
|
|
|
|
+ String endTime = componentText(components, "结束时间", "返回时间");
|
|
|
|
|
+ String endDate = componentText(components, "结束日期", "返回日期");
|
|
|
|
|
+ value.setEndDate(firstNonBlank(datePart(endTime), datePart(endDate)));
|
|
|
|
|
+ value.setEndPeriod(firstNonBlank(periodPart(endTime), periodPart(endDate)));
|
|
|
|
|
+ value.setDurationDays(decimalValue(componentText(components, "时长(天)", "时长", "时长天数")));
|
|
|
|
|
+ value.setTravelDays(decimalValue(componentText(components, "出差天数")));
|
|
|
|
|
+ value.setRemark(componentText(components, "行程备注", "出差备注", "备注"));
|
|
|
|
|
+ value.setCompanionUserIds(componentUserIds(components, "出行人(同行人)", "出行人", "同行人", "同行人员"));
|
|
|
|
|
+ value.setExternalPersonnel(componentText(components, "外部人员"));
|
|
|
|
|
+ return value;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void applyFlightDefaults(List<TravelApplicationPushRequest.Flight> flights, String ticketReserved) {
|
|
|
|
|
+ if (isBlank(ticketReserved)) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (flights.isEmpty()) {
|
|
|
|
|
+ TravelApplicationPushRequest.Flight flight = new TravelApplicationPushRequest.Flight();
|
|
|
|
|
+ flight.setTicketReserved(ticketReserved);
|
|
|
|
|
+ flights.add(flight);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ for (TravelApplicationPushRequest.Flight flight : flights) {
|
|
|
|
|
+ if (isBlank(flight.getTicketReserved())) {
|
|
|
|
|
+ flight.setTicketReserved(ticketReserved);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String datePart(String value) {
|
|
|
|
|
+ if (isBlank(value)) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ java.util.regex.Matcher matcher = java.util.regex.Pattern.compile("\\d{4}[-/]\\d{2}[-/]\\d{2}").matcher(value);
|
|
|
|
|
+ return matcher.find() ? matcher.group().replace('/', '-') : value;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String periodPart(String value) {
|
|
|
|
|
+ if (isBlank(value)) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (value.contains("上午")) {
|
|
|
|
|
+ return "上午";
|
|
|
|
|
+ }
|
|
|
|
|
+ if (value.contains("下午")) {
|
|
|
|
|
+ return "下午";
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private List<TravelApplicationPushRequest.Itinerary> toItineraries(List<Map<String, String>> rows) {
|
|
|
|
|
+ List<TravelApplicationPushRequest.Itinerary> values = new ArrayList<TravelApplicationPushRequest.Itinerary>();
|
|
|
|
|
+ for (Map<String, String> row : rows) {
|
|
|
|
|
+ TravelApplicationPushRequest.Itinerary value = new TravelApplicationPushRequest.Itinerary();
|
|
|
|
|
+ value.setDepartureCity(rowText(row, "出发城市", "出发地", "出发地点", "departure"));
|
|
|
|
|
+ value.setDestinationCity(rowText(row, "目的城市", "目的地", "到达城市", "到达地点", "arrival"));
|
|
|
|
|
+ String start = rowText(row, "开始时间", "出发时间", "startTime");
|
|
|
|
|
+ String startDate = rowText(row, "开始日期", "出发日期", "startDate");
|
|
|
|
|
+ value.setStartDate(firstNonBlank(datePart(start), datePart(startDate)));
|
|
|
|
|
+ value.setStartPeriod(firstNonBlank(rowText(row, "开始时间上午下午", "开始时段", "startPeriod"), periodPart(start), periodPart(startDate)));
|
|
|
|
|
+ String end = rowText(row, "结束时间", "返回时间", "endTime");
|
|
|
|
|
+ String endDate = rowText(row, "结束日期", "返回日期", "endDate");
|
|
|
|
|
+ value.setEndDate(firstNonBlank(datePart(end), datePart(endDate)));
|
|
|
|
|
+ value.setEndPeriod(firstNonBlank(rowText(row, "结束时间上午下午", "结束时段", "endPeriod"), periodPart(end), periodPart(endDate)));
|
|
|
|
|
+ value.setDurationDays(decimalValue(rowText(row, "时长(天)", "时长", "时长天数", "duration")));
|
|
|
|
|
+ value.setTravelDays(decimalValue(rowText(row, "出差天数", "天数", "travelDays")));
|
|
|
|
|
+ value.setRemark(rowText(row, "出差备注", "行程备注", "备注", "remark"));
|
|
|
|
|
+ value.setCompanionUserIds(rowUserIds(row, "出行人(同行人)", "出行人", "同行人", "同行人员", "companions"));
|
|
|
|
|
+ value.setExternalPersonnel(rowText(row, "外部人员", "externalPersonnel"));
|
|
|
|
|
+ if (hasItineraryValue(value)) {
|
|
|
|
|
+ values.add(value);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return values;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private List<TravelApplicationPushRequest.Flight> toFlights(List<Map<String, String>> rows) {
|
|
|
|
|
+ List<TravelApplicationPushRequest.Flight> values = new ArrayList<TravelApplicationPushRequest.Flight>();
|
|
|
|
|
+ for (Map<String, String> row : rows) {
|
|
|
|
|
+ TravelApplicationPushRequest.Flight value = new TravelApplicationPushRequest.Flight();
|
|
|
|
|
+ value.setTicketReserved(rowText(row, "是否预定机票", "是否预订机票", "是否需要预订机票"));
|
|
|
|
|
+ value.setTravelRegion(rowText(row, "出差区域类型", "出差区域", "区域类型"));
|
|
|
|
|
+ value.setTicketType(rowText(row, "机票类型", "机票单程往返"));
|
|
|
|
|
+ value.setTripCount(integerValue(rowText(row, "次数")));
|
|
|
|
|
+ value.setBudgetAmount(decimalValue(rowText(row, "预算金额")));
|
|
|
|
|
+ value.setUnitPrice(decimalValue(rowText(row, "单价")));
|
|
|
|
|
+ value.setOverStandard(rowText(row, "是否超标"));
|
|
|
|
|
+ value.setStandardAmount(decimalValue(rowText(row, "标准金额")));
|
|
|
|
|
+ value.setRemark(rowText(row, "备注"));
|
|
|
|
|
+ if (hasFlightValue(value)) {
|
|
|
|
|
+ values.add(value);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return values;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private List<TravelApplicationPushRequest.Budget> toBudgets(List<Map<String, String>> rows) {
|
|
|
|
|
+ List<TravelApplicationPushRequest.Budget> values = new ArrayList<TravelApplicationPushRequest.Budget>();
|
|
|
|
|
+ for (Map<String, String> row : rows) {
|
|
|
|
|
+ TravelApplicationPushRequest.Budget value = new TravelApplicationPushRequest.Budget();
|
|
|
|
|
+ value.setDepartureLocation(rowText(row, "出发地点"));
|
|
|
|
|
+ value.setArrivalLocation(rowText(row, "到达地点"));
|
|
|
|
|
+ value.setAccommodationDays(decimalValue(rowText(row, "住宿天数")));
|
|
|
|
|
+ value.setGroundTransportationCost(decimalValue(rowText(row, "交通费用(除飞机外)", "交通费用", "交通费", "地面交通费")));
|
|
|
|
|
+ value.setAccommodationCost(decimalValue(rowText(row, "住宿费")));
|
|
|
|
|
+ value.setAccommodationStandard(decimalValue(rowText(row, "住宿费标准", "住宿标准", "住宿标准金额")));
|
|
|
|
|
+ value.setBusinessEntertainmentCost(decimalValue(rowText(row, "业务招待费")));
|
|
|
|
|
+ value.setTravelAllowance(decimalValue(rowText(row, "出差补贴")));
|
|
|
|
|
+ value.setTotalCost(decimalValue(rowText(row, "费用合计", "合计")));
|
|
|
|
|
+ if (hasBudgetValue(value)) {
|
|
|
|
|
+ values.add(value);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return values;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private List<Map<String, Object>> componentList(Object source) {
|
|
|
|
|
+ List<Map<String, Object>> components = new ArrayList<Map<String, Object>>();
|
|
|
|
|
+ JsonNode node = jsonNode(source);
|
|
|
|
|
+ if (!node.isArray()) {
|
|
|
|
|
+ return components;
|
|
|
|
|
+ }
|
|
|
|
|
+ for (JsonNode item : node) {
|
|
|
|
|
+ Map<String, Object> component = objectMap(item);
|
|
|
|
|
+ if (!component.isEmpty()) {
|
|
|
|
|
+ components.add(component);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return components;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private Map<String, Object> objectMap(Object source) {
|
|
|
|
|
+ JsonNode node = jsonNode(source);
|
|
|
|
|
+ if (!node.isObject()) {
|
|
|
|
|
+ return Collections.emptyMap();
|
|
|
|
|
+ }
|
|
|
|
|
+ return objectMapper.convertValue(node, Map.class);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String firstText(Map<String, Object> source, String... keys) {
|
|
|
|
|
+ for (String key : keys) {
|
|
|
|
|
+ String value = jsonText(source.get(key));
|
|
|
|
|
+ if (!isBlank(value)) {
|
|
|
|
|
+ return value;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String componentText(List<Map<String, Object>> components, String... aliases) {
|
|
|
|
|
+ Map<String, Object> component = findComponent(components, aliases);
|
|
|
|
|
+ if (component == null) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ String value = jsonText(component.get("value"));
|
|
|
|
|
+ return isBlank(value) ? jsonText(component.get("extValue")) : value;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String componentDepartment(List<Map<String, Object>> components, String... aliases) {
|
|
|
|
|
+ Map<String, Object> component = findComponent(components, aliases);
|
|
|
|
|
+ if (component == null) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ String departmentId = firstIdentifier(component.get("extValue"), "id", "deptId", "departmentId", "itemId");
|
|
|
|
|
+ return isBlank(departmentId) ? jsonText(component.get("value")) : departmentId;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private List<String> componentUserIds(List<Map<String, Object>> components, String... aliases) {
|
|
|
|
|
+ Map<String, Object> component = findComponent(components, aliases);
|
|
|
|
|
+ if (component == null) {
|
|
|
|
|
+ return Collections.emptyList();
|
|
|
|
|
+ }
|
|
|
|
|
+ List<String> userIds = identifiers(component.get("extValue"), "userid", "userId", "emplId", "employeeId", "itemId", "id", "user_id", "dingTalkUserId");
|
|
|
|
|
+ return userIds.isEmpty() ? identifiers(component.get("value"), "userid", "userId", "emplId", "employeeId", "itemId", "id", "user_id", "dingTalkUserId") : userIds;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private Map<String, Object> findComponent(List<Map<String, Object>> components, String... aliases) {
|
|
|
|
|
+ // 先精确匹配,避免“实际出差人”被“出行人(同行人)”等相近名称抢先命中。
|
|
|
|
|
+ for (String alias : aliases) {
|
|
|
|
|
+ String normalizedAlias = normalize(alias);
|
|
|
|
|
+ for (Map<String, Object> component : components) {
|
|
|
|
|
+ String name = firstText(component, "name", "label", "title", "componentName", "bizAlias", "biz_alias");
|
|
|
|
|
+ if (normalize(name).equals(normalizedAlias)) {
|
|
|
|
|
+ return component;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ for (Map<String, Object> component : components) {
|
|
|
|
|
+ String name = firstText(component, "name", "label", "title", "componentName", "bizAlias", "biz_alias");
|
|
|
|
|
+ String normalizedName = normalize(name);
|
|
|
|
|
+ boolean matched = false;
|
|
|
|
|
+ for (String alias : aliases) {
|
|
|
|
|
+ if (normalizedName.contains(normalize(alias))) {
|
|
|
|
|
+ matched = true;
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (matched) {
|
|
|
|
|
+ return component;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private List<Map<String, String>> componentRows(List<Map<String, Object>> components, String... aliases) {
|
|
|
|
|
+ Map<String, Object> component = findComponent(components, aliases);
|
|
|
|
|
+ List<Map<String, String>> rows = new ArrayList<Map<String, String>>();
|
|
|
|
|
+ if (component != null) {
|
|
|
|
|
+ collectRows(jsonNode(component.get("value")), rows);
|
|
|
|
|
+ if (rows.isEmpty()) {
|
|
|
|
|
+ collectRows(jsonNode(component.get("extValue")), rows);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ // 钉钉审批详情中明细控件的 name/label 可能是控件标题、空值或版本化名称。
|
|
|
|
|
+ // 如果外层标题没有匹配到,则扫描所有控件,并依据行内 bizAlias/字段名识别目标子表。
|
|
|
|
|
+ if (rows.isEmpty()) {
|
|
|
|
|
+ for (Map<String, Object> candidate : components) {
|
|
|
|
|
+ List<Map<String, String>> candidateRows = new ArrayList<Map<String, String>>();
|
|
|
|
|
+ collectRows(jsonNode(candidate.get("value")), candidateRows);
|
|
|
|
|
+ if (candidateRows.isEmpty()) {
|
|
|
|
|
+ collectRows(jsonNode(candidate.get("extValue")), candidateRows);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (matchesRowsForComponent(candidateRows, aliases)) {
|
|
|
|
|
+ rows.addAll(candidateRows);
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return rows;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private boolean matchesRowsForComponent(List<Map<String, String>> rows, String... aliases) {
|
|
|
|
|
+ if (rows == null || rows.isEmpty()) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ boolean itinerary = containsAlias(aliases, "行程", "行程明细", "行程数据");
|
|
|
|
|
+ boolean flight = containsAlias(aliases, "飞机航程", "机票航程", "机票信息");
|
|
|
|
|
+ boolean budget = containsAlias(aliases, "费用预算", "预算明细");
|
|
|
|
|
+ for (Map<String, String> row : rows) {
|
|
|
|
|
+ // 费用预算也可能包含“出发地点/到达地点”,因此行程识别至少要求
|
|
|
|
|
+ // 开始时间、结束时间或时长等行程专有字段,不能只按城市字段判断。
|
|
|
|
|
+ if (itinerary && (rowText(row, "startTime", "开始时间", "出发时间") != null
|
|
|
|
|
+ || rowText(row, "endTime", "结束时间", "返回时间") != null
|
|
|
|
|
+ || rowText(row, "duration", "时长", "时长(天)", "时长天数") != null)) {
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (flight && (rowText(row, "travelRegion", "出差区域类型", "出差区域") != null
|
|
|
|
|
+ || rowText(row, "ticketType", "机票类型") != null
|
|
|
|
|
+ || rowText(row, "budgetAmount", "预算金额") != null)) {
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (budget && (rowText(row, "departureLocation", "出发地点") != null
|
|
|
|
|
+ || rowText(row, "arrivalLocation", "到达地点") != null
|
|
|
|
|
+ || rowText(row, "accommodationDays", "住宿天数") != null)) {
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private boolean containsAlias(String[] aliases, String... expected) {
|
|
|
|
|
+ if (aliases == null) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ for (String alias : aliases) {
|
|
|
|
|
+ for (String value : expected) {
|
|
|
|
|
+ if (normalize(alias).equals(normalize(value))) {
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void collectRows(JsonNode node, List<Map<String, String>> rows) {
|
|
|
|
|
+ if (node == null || node.isNull()) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ // extendValue/list/rowValue 在部分钉钉版本中会再包一层 JSON 字符串。
|
|
|
|
|
+ if (node.isTextual()) {
|
|
|
|
|
+ String text = node.asText().trim();
|
|
|
|
|
+ if ((text.startsWith("{") && text.endsWith("}")) || (text.startsWith("[") && text.endsWith("]"))) {
|
|
|
|
|
+ collectRows(jsonNode(text), rows);
|
|
|
|
|
+ }
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (node.isValueNode()) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (node.isArray()) {
|
|
|
|
|
+ // 钉钉明细控件可能直接返回 [{name/label,value}, ...],这代表一行而不是多行。
|
|
|
|
|
+ if (isNamedFieldArray(node)) {
|
|
|
|
|
+ Map<String, String> row = namedValues(node);
|
|
|
|
|
+ if (!row.isEmpty()) {
|
|
|
|
|
+ rows.add(row);
|
|
|
|
|
+ }
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ for (JsonNode item : node) {
|
|
|
|
|
+ collectRows(item, rows);
|
|
|
|
|
+ }
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ for (String rowField : Arrays.asList("rowValue", "rowValues")) {
|
|
|
|
|
+ JsonNode rowValues = node.get(rowField);
|
|
|
|
|
+ if (rowValues != null) {
|
|
|
|
|
+ Map<String, String> row = namedValues(jsonNode(rowValues));
|
|
|
|
|
+ if (!row.isEmpty()) {
|
|
|
|
|
+ rows.add(row);
|
|
|
|
|
+ }
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ Map<String, String> row = namedValues(node);
|
|
|
|
|
+ if (!row.isEmpty()) {
|
|
|
|
|
+ rows.add(row);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ node.elements().forEachRemaining(child -> collectRows(child, rows));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private Map<String, String> namedValues(JsonNode node) {
|
|
|
|
|
+ Map<String, String> values = new LinkedHashMap<String, String>();
|
|
|
|
|
+ JsonNode componentValues = node != null && node.isArray()
|
|
|
|
|
+ ? node : firstArray(node, "rowValue", "rowValues", "componentValues", "formComponentValues", "component_values");
|
|
|
|
|
+ if (componentValues != null) {
|
|
|
|
|
+ for (JsonNode component : componentValues) {
|
|
|
|
|
+ String value = jsonText(component.get("value"));
|
|
|
|
|
+ if (isBlank(value)) {
|
|
|
|
|
+ value = rawJsonText(component.get("extValue"));
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!isBlank(value)) {
|
|
|
|
|
+ // 同时保存 label/name 与 bizAlias。不同钉钉接口版本返回的字段名称不一致,
|
|
|
|
|
+ // 业务层可用中文标题或稳定的 bizAlias(如 departure、arrival)读取。
|
|
|
|
|
+ String label = firstNodeText(component, "label", "name", "title", "componentName", "fieldName");
|
|
|
|
|
+ String bizAlias = firstNodeText(component, "bizAlias", "biz_alias");
|
|
|
|
|
+ if (!isBlank(label)) {
|
|
|
|
|
+ values.put(normalize(label), value);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!isBlank(bizAlias)) {
|
|
|
|
|
+ values.put(normalize(bizAlias), value);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return values;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ node.fields().forEachRemaining(entry -> {
|
|
|
|
|
+ if (entry.getValue().isValueNode()) {
|
|
|
|
|
+ String value = jsonText(entry.getValue());
|
|
|
|
|
+ if (!isBlank(value)) {
|
|
|
|
|
+ values.put(normalize(entry.getKey()), value);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ return values;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private boolean isNamedFieldArray(JsonNode node) {
|
|
|
|
|
+ if (node == null || !node.isArray() || node.isEmpty()) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ for (JsonNode item : node) {
|
|
|
|
|
+ if (item == null || !item.isObject()
|
|
|
|
|
+ || isBlank(firstNodeText(item, "name", "label", "title", "componentName", "bizAlias", "biz_alias", "fieldName"))
|
|
|
|
|
+ || (item.get("value") == null && item.get("extValue") == null)) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String rawJsonText(JsonNode node) {
|
|
|
|
|
+ if (node == null || node.isNull() || node.isMissingNode()) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (node.isTextual()) {
|
|
|
|
|
+ return node.asText();
|
|
|
|
|
+ }
|
|
|
|
|
+ return node.toString();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private JsonNode firstArray(JsonNode node, String... names) {
|
|
|
|
|
+ for (String name : names) {
|
|
|
|
|
+ JsonNode value = node.get(name);
|
|
|
|
|
+ if (value != null && value.isArray()) {
|
|
|
|
|
+ return value;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (value != null && value.isTextual()) {
|
|
|
|
|
+ JsonNode parsed = jsonNode(value.asText());
|
|
|
|
|
+ if (parsed.isArray()) {
|
|
|
|
|
+ return parsed;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private boolean isTravelDetailRow(Map<String, String> row) {
|
|
|
|
|
+ return rowText(row, "出发城市", "出发地", "出发地点", "目的城市", "目的地",
|
|
|
|
|
+ "是否预定机票", "是否预订机票", "出差区域类型", "预算金额", "住宿天数",
|
|
|
|
|
+ "交通费用(除飞机外)", "费用合计") != null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String componentRowsText(List<Map<String, String>> rows, String... aliases) {
|
|
|
|
|
+ for (Map<String, String> row : rows) {
|
|
|
|
|
+ String value = rowText(row, aliases);
|
|
|
|
|
+ if (!isBlank(value)) {
|
|
|
|
|
+ return value;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String rowText(Map<String, String> row, String... aliases) {
|
|
|
|
|
+ // 先精确匹配,避免“开始时间”被“开始时间上午下午”这类更长字段误命中。
|
|
|
|
|
+ for (String alias : aliases) {
|
|
|
|
|
+ String normalizedAlias = normalize(alias);
|
|
|
|
|
+ for (Map.Entry<String, String> entry : row.entrySet()) {
|
|
|
|
|
+ if (normalize(entry.getKey()).equals(normalizedAlias) && !isBlank(entry.getValue())) {
|
|
|
|
|
+ return entry.getValue();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ for (Map.Entry<String, String> entry : row.entrySet()) {
|
|
|
|
|
+ String normalizedSource = normalize(entry.getKey());
|
|
|
|
|
+ for (String alias : aliases) {
|
|
|
|
|
+ String normalizedAlias = normalize(alias);
|
|
|
|
|
+ if (!isBlank(entry.getValue()) && normalizedSource.contains(normalizedAlias)) {
|
|
|
|
|
+ return entry.getValue();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private List<String> rowUserIds(Map<String, String> row, String... aliases) {
|
|
|
|
|
+ String value = rowText(row, aliases);
|
|
|
|
|
+ return isBlank(value) ? Collections.<String>emptyList()
|
|
|
|
|
+ : identifiers(value, "userid", "userId", "emplId", "employeeId", "itemId", "id", "user_id", "dingTalkUserId");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private boolean hasItineraryValue(TravelApplicationPushRequest.Itinerary value) {
|
|
|
|
|
+ return !isBlank(value.getDepartureCity()) || !isBlank(value.getDestinationCity()) || !isBlank(value.getStartDate())
|
|
|
|
|
+ || !isBlank(value.getEndDate()) || !isBlank(value.getRemark()) || value.getDurationDays() != null
|
|
|
|
|
+ || value.getTravelDays() != null || !value.getCompanionUserIds().isEmpty()
|
|
|
|
|
+ || !isBlank(value.getExternalPersonnel());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private boolean hasFlightValue(TravelApplicationPushRequest.Flight value) {
|
|
|
|
|
+ return !isBlank(value.getTicketReserved()) || !isBlank(value.getTravelRegion()) || !isBlank(value.getTicketType())
|
|
|
|
|
+ || value.getTripCount() != null || value.getBudgetAmount() != null || value.getUnitPrice() != null
|
|
|
|
|
+ || value.getStandardAmount() != null || !isBlank(value.getOverStandard()) || !isBlank(value.getRemark());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private boolean hasBudgetValue(TravelApplicationPushRequest.Budget value) {
|
|
|
|
|
+ return !isBlank(value.getDepartureLocation()) || !isBlank(value.getArrivalLocation())
|
|
|
|
|
+ || value.getAccommodationDays() != null || value.getGroundTransportationCost() != null
|
|
|
|
|
+ || value.getAccommodationCost() != null || value.getAccommodationStandard() != null
|
|
|
|
|
+ || value.getBusinessEntertainmentCost() != null || value.getTravelAllowance() != null
|
|
|
|
|
+ || value.getTotalCost() != null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private BigDecimal decimalValue(String value) {
|
|
|
|
|
+ if (isBlank(value)) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ return new BigDecimal(value.trim());
|
|
|
|
|
+ } catch (NumberFormatException exception) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private Integer integerValue(String value) {
|
|
|
|
|
+ BigDecimal decimal = decimalValue(value);
|
|
|
|
|
+ return decimal == null ? null : Integer.valueOf(decimal.intValue());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private List<String> identifiers(Object source, String... keys) {
|
|
|
|
|
+ List<String> values = new ArrayList<String>();
|
|
|
|
|
+ collectIdentifiers(jsonNode(source), values, Arrays.asList(keys));
|
|
|
|
|
+ return values;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String firstIdentifier(Object source, String... keys) {
|
|
|
|
|
+ List<String> values = identifiers(source, keys);
|
|
|
|
|
+ return values.isEmpty() ? null : values.get(0);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void collectIdentifiers(JsonNode node, List<String> values, List<String> keys) {
|
|
|
|
|
+ if (node == null || node.isNull()) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (node.isValueNode()) {
|
|
|
|
|
+ if (node.isTextual() && !node.asText().trim().startsWith("{") && !node.asText().trim().startsWith("[")) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (node.isArray()) {
|
|
|
|
|
+ for (JsonNode item : node) {
|
|
|
|
|
+ collectIdentifiers(item, values, keys);
|
|
|
|
|
+ }
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ node.fields().forEachRemaining(entry -> {
|
|
|
|
|
+ if (keys.contains(entry.getKey()) && entry.getValue().isValueNode()) {
|
|
|
|
|
+ String value = jsonText(entry.getValue());
|
|
|
|
|
+ if (!isBlank(value) && !values.contains(value)) {
|
|
|
|
|
+ values.add(value);
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ collectIdentifiers(entry.getValue(), values, keys);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private JsonNode jsonNode(Object source) {
|
|
|
|
|
+ if (source == null) {
|
|
|
|
|
+ return objectMapper.nullNode();
|
|
|
|
|
+ }
|
|
|
|
|
+ if (source instanceof String) {
|
|
|
|
|
+ String text = ((String) source).trim();
|
|
|
|
|
+ if ((text.startsWith("{") && text.endsWith("}")) || (text.startsWith("[") && text.endsWith("]"))) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ return objectMapper.readTree(text);
|
|
|
|
|
+ } catch (Exception ignored) {
|
|
|
|
|
+ // 非 JSON 字符串按普通文本处理。
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return objectMapper.valueToTree(source);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String jsonText(Object source) {
|
|
|
|
|
+ JsonNode node = jsonNode(source);
|
|
|
|
|
+ if (node.isNull() || node.isMissingNode()) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (node.isValueNode()) {
|
|
|
|
|
+ return node.asText();
|
|
|
|
|
+ }
|
|
|
|
|
+ if (node.isArray()) {
|
|
|
|
|
+ for (JsonNode item : node) {
|
|
|
|
|
+ String value = jsonText(item);
|
|
|
|
|
+ if (!isBlank(value)) {
|
|
|
|
|
+ return value;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ return firstNodeText(node, "value", "name", "text", "label");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String firstNodeText(JsonNode node, String... keys) {
|
|
|
|
|
+ if (node == null) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ for (String key : keys) {
|
|
|
|
|
+ JsonNode value = node.get(key);
|
|
|
|
|
+ if (value != null) {
|
|
|
|
|
+ String text = jsonText(value);
|
|
|
|
|
+ if (!isBlank(text)) {
|
|
|
|
|
+ return text;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private boolean matchesField(String source, String... aliases) {
|
|
|
|
|
+ if (isBlank(source)) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ String normalizedSource = normalize(source);
|
|
|
|
|
+ for (String alias : aliases) {
|
|
|
|
|
+ String normalizedAlias = normalize(alias);
|
|
|
|
|
+ if (normalizedSource.equals(normalizedAlias) || normalizedSource.contains(normalizedAlias)
|
|
|
|
|
+ || normalizedAlias.contains(normalizedSource)) {
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String normalize(String value) {
|
|
|
|
|
+ return value == null ? "" : value.replaceAll("[^\\p{IsHan}A-Za-z0-9]", "").toLowerCase();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private Map<String, Object> toYidaFormData(TravelApplicationPushRequest request, String userId, String title) {
|
|
private Map<String, Object> toYidaFormData(TravelApplicationPushRequest request, String userId, String title) {
|
|
|
Map<String, Object> formData = new LinkedHashMap<String, Object>();
|
|
Map<String, Object> formData = new LinkedHashMap<String, Object>();
|
|
|
putIfPresent(formData, TITLE_FIELD_ID, title);
|
|
putIfPresent(formData, TITLE_FIELD_ID, title);
|
|
|
putIfPresent(formData, APPLICANT_FIELD_ID, Collections.singletonList(userId));
|
|
putIfPresent(formData, APPLICANT_FIELD_ID, Collections.singletonList(userId));
|
|
|
putIfPresent(formData, COMPANY_FIELD_ID, request.getCompany());
|
|
putIfPresent(formData, COMPANY_FIELD_ID, request.getCompany());
|
|
|
- putIfPresent(formData, DEPARTMENT_FIELD_ID, Collections.singletonList(requireText(request.getDepartmentId(), "Travel application departmentId must not be blank")));
|
|
|
|
|
|
|
+ putIfPresent(formData, DEPARTMENT_FIELD_ID, resolveDepartmentIds(request.getDepartmentId()));
|
|
|
putIfPresent(formData, APPLY_DATE_FIELD_ID, toDateMillis(request.getApplyDate()));
|
|
putIfPresent(formData, APPLY_DATE_FIELD_ID, toDateMillis(request.getApplyDate()));
|
|
|
putIfPresent(formData, POSITION_FIELD_ID, request.getPosition());
|
|
putIfPresent(formData, POSITION_FIELD_ID, request.getPosition());
|
|
|
putIfPresent(formData, MOBILE_FIELD_ID, request.getMobile());
|
|
putIfPresent(formData, MOBILE_FIELD_ID, request.getMobile());
|
|
@@ -175,6 +891,32 @@ public class TravelApplicationPushServiceImpl implements TravelApplicationPushSe
|
|
|
return formData;
|
|
return formData;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 将 OA 的部门名称或 ID 转换为宜搭部门字段所需的钉钉部门 ID 字符串列表。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param departmentReference OA 传入的部门名称、层级路径或部门 ID
|
|
|
|
|
+ * @return 宜搭部门字段值
|
|
|
|
|
+ */
|
|
|
|
|
+ private List<String> resolveDepartmentIds(String departmentReference) {
|
|
|
|
|
+ String text = requireText(departmentReference, "Travel application departmentId must not be blank");
|
|
|
|
|
+ List<Object> references = new ArrayList<Object>();
|
|
|
|
|
+ references.add(text);
|
|
|
|
|
+ List<Long> departmentIds = employeeRosterService.resolveDingTalkDepartmentIds(references);
|
|
|
|
|
+ if (departmentIds == null || departmentIds.isEmpty()) {
|
|
|
|
|
+ throw new IllegalArgumentException("Travel application departmentId does not resolve to a DingTalk department ID");
|
|
|
|
|
+ }
|
|
|
|
|
+ List<String> result = new ArrayList<String>();
|
|
|
|
|
+ for (Long departmentId : departmentIds) {
|
|
|
|
|
+ if (departmentId != null && departmentId.longValue() > 0) {
|
|
|
|
|
+ result.add(String.valueOf(departmentId));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (result.isEmpty()) {
|
|
|
|
|
+ throw new IllegalArgumentException("Travel application departmentId does not resolve to a valid DingTalk department ID");
|
|
|
|
|
+ }
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private List<Map<String, Object>> toItineraryRows(List<TravelApplicationPushRequest.Itinerary> itineraries) {
|
|
private List<Map<String, Object>> toItineraryRows(List<TravelApplicationPushRequest.Itinerary> itineraries) {
|
|
|
List<Map<String, Object>> rows = new ArrayList<Map<String, Object>>();
|
|
List<Map<String, Object>> rows = new ArrayList<Map<String, Object>>();
|
|
|
if (itineraries == null) {
|
|
if (itineraries == null) {
|
|
@@ -279,7 +1021,8 @@ public class TravelApplicationPushServiceImpl implements TravelApplicationPushSe
|
|
|
return Long.valueOf(text);
|
|
return Long.valueOf(text);
|
|
|
}
|
|
}
|
|
|
try {
|
|
try {
|
|
|
- return LocalDate.parse(text.replace('/', '-')).atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli();
|
|
|
|
|
|
|
+ String dateText = text.length() >= 10 ? text.substring(0, 10) : text;
|
|
|
|
|
+ return LocalDate.parse(dateText.replace('/', '-')).atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli();
|
|
|
} catch (RuntimeException exception) {
|
|
} catch (RuntimeException exception) {
|
|
|
throw new IllegalArgumentException("Date must be yyyy-MM-dd or epoch milliseconds: " + value);
|
|
throw new IllegalArgumentException("Date must be yyyy-MM-dd or epoch milliseconds: " + value);
|
|
|
}
|
|
}
|
|
@@ -317,6 +1060,14 @@ public class TravelApplicationPushServiceImpl implements TravelApplicationPushSe
|
|
|
return isBlank(first) ? second : first;
|
|
return isBlank(first) ? second : first;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private String firstNonBlank(String first, String second) {
|
|
|
|
|
+ return isBlank(first) ? second : first;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String firstNonBlank(String first, String second, String third) {
|
|
|
|
|
+ return !isBlank(first) ? first : (!isBlank(second) ? second : third);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private boolean isBlank(Object value) {
|
|
private boolean isBlank(Object value) {
|
|
|
return value == null || String.valueOf(value).trim().isEmpty();
|
|
return value == null || String.valueOf(value).trim().isEmpty();
|
|
|
}
|
|
}
|