优化人员在线状态增量同步

dev
shenzhidan 2026-07-23 18:35:48 +08:00
parent 7bfab8159f
commit 5d9927b6e4
9 changed files with 613 additions and 21 deletions

View File

@ -5,26 +5,30 @@ import com.jjb.saas.framework.job.annotation.JobRegister;
import com.xxl.job.core.biz.model.ReturnT; import com.xxl.job.core.biz.model.ReturnT;
import com.xxl.job.core.context.XxlJobHelper; import com.xxl.job.core.context.XxlJobHelper;
import com.xxl.job.core.handler.annotation.XxlJob; import com.xxl.job.core.handler.annotation.XxlJob;
import com.zcloud.personnel.positioning.command.query.FindsStaffMappingSyncService; import com.zcloud.personnel.positioning.command.query.FindsStaffLocationStatusSyncService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
public class FindsStaffMappingSyncJob implements Job { public class FindsStaffMappingSyncJob implements Job {
private final FindsStaffMappingSyncService syncService; private final FindsStaffLocationStatusSyncService locationStatusSyncService;
@Override @Override
@JobRegister(cron = "0 */5 * * * ?", jobDesc = "FindS人员映射同步", author = "系统", triggerStatus = 1) @JobRegister(cron = "0 */5 * * * ?", jobDesc = "FindS人员映射和定位状态同步", author = "系统", triggerStatus = 1)
@XxlJob("com.zcloud.personnel.positioning.plan.FindsStaffMappingSyncJob") @XxlJob("com.zcloud.personnel.positioning.plan.FindsStaffMappingSyncJob")
public ReturnT<String> execute(String param) { public ReturnT<String> execute(String param) {
try { try {
int count = syncService.syncAll(); FindsStaffLocationStatusSyncService.SyncSummary summary = locationStatusSyncService.synchronize();
String message = "FindS人员映射同步完成本次处理 " + count + " 条"; String message = "FindS人员映射和定位状态同步完成人员总数="
+ summary.getRemoteStaffCount()
+ ",全量同步=" + summary.isFullSync()
+ ",本次补充=" + summary.getFullSyncCount()
+ ",在线终端=" + summary.getOnlineTerminalCount();
XxlJobHelper.log(message); XxlJobHelper.log(message);
return new ReturnT<>(ReturnT.SUCCESS_CODE, message); return new ReturnT<>(ReturnT.SUCCESS_CODE, message);
} catch (Exception e) { } catch (Exception e) {
String message = "FindS人员映射同步失败" + e.getMessage(); String message = "FindS人员映射和定位状态同步失败:" + e.getMessage();
XxlJobHelper.log(message); XxlJobHelper.log(message);
return new ReturnT<>(ReturnT.FAIL_CODE, message); return new ReturnT<>(ReturnT.FAIL_CODE, message);
} }

View File

@ -0,0 +1,324 @@
package com.zcloud.personnel.positioning.command.query;
import com.alibaba.cola.exception.BizException;
import com.fasterxml.jackson.databind.JsonNode;
import com.zcloud.personnel.positioning.integration.finds.FindsOpenApiClient;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Refreshes the persisted online/offline state for FindS personnel.
*
* <p>Only device status is stored. Coordinates remain outside MySQL.</p>
*/
@Component
@RequiredArgsConstructor
public class FindsStaffLocationStatusSyncService {
private static final Logger LOGGER = LoggerFactory.getLogger(FindsStaffLocationStatusSyncService.class);
private static final String STAFF_LIST_API = "finds.staff.list";
private static final String TERMINAL_LIST_API = "finds.terminal.list";
private static final String SOURCE_CONFIG_CODE = "FINDS";
private static final int STAFF_COUNT_PAGE_SIZE = 1;
private static final int TERMINAL_PAGE_SIZE = 2000;
private static final int MAX_TERMINAL_PAGE_COUNT = 100;
private static final int BATCH_CONCURRENCY = 4;
private static final int FULL_SYNC_INTERVAL_HOURS = 24;
private static final List<String> ONLINE_DEVICE_STATUSES =
Arrays.asList("STATIC", "MOVING");
private final FindsOpenApiClient findsOpenApiClient;
private final FindsStaffMappingSyncService mappingSyncService;
private final JdbcTemplate jdbcTemplate;
public SyncSummary synchronize() {
LocalDateTime syncTime = LocalDateTime.now().withNano(0);
long remoteStaffCount = queryRemoteStaffCount();
long localStaffCount = queryLocalStaffCount();
boolean fullSync = localStaffCount == 0
|| remoteStaffCount != localStaffCount
|| fullSyncDue(syncTime);
int fullSyncCount = fullSync ? mappingSyncService.syncAll() : 0;
Map<String, OnlineTerminal> onlineTerminals = loadOnlineTerminals();
int onlineMappings = updateLocationStatuses(onlineTerminals, syncTime);
LOGGER.info("FindS personnel location status synchronization completed, remoteStaffCount={}, "
+ "localStaffCount={}, fullSync={}, fullSyncCount={}, onlineTerminals={}, onlineMappings={}",
remoteStaffCount, localStaffCount, fullSync, fullSyncCount,
onlineTerminals.size(), onlineMappings);
return new SyncSummary(remoteStaffCount, localStaffCount, fullSync, fullSyncCount,
onlineTerminals.size(), onlineMappings);
}
private long queryRemoteStaffCount() {
JsonNode data = successData(findsOpenApiClient.postFailFast(STAFF_LIST_API,
staffCountRequest()));
return requiredTotal(data, "FindS staff count response");
}
private Map<String, Object> staffCountRequest() {
Map<String, Object> request = new LinkedHashMap<>();
request.put("pageNo", 1);
request.put("pageSize", STAFF_COUNT_PAGE_SIZE);
return request;
}
private long queryLocalStaffCount() {
Long count = jdbcTemplate.queryForObject(
"SELECT COUNT(1) FROM personnel_match "
+ "WHERE source_config_code = ? "
+ "AND (delete_enum IS NULL OR delete_enum = 'FALSE') "
+ "AND match_status <> 'DISABLED'",
Long.class, SOURCE_CONFIG_CODE);
return count == null ? 0 : count;
}
private boolean fullSyncDue(LocalDateTime syncTime) {
LocalDateTime lastSyncTime = jdbcTemplate.queryForObject(
"SELECT MAX(last_sync_time) FROM personnel_match "
+ "WHERE source_config_code = ? "
+ "AND (delete_enum IS NULL OR delete_enum = 'FALSE') "
+ "AND match_status <> 'DISABLED'",
LocalDateTime.class, SOURCE_CONFIG_CODE);
return lastSyncTime == null
|| lastSyncTime.isBefore(syncTime.minusHours(FULL_SYNC_INTERVAL_HOURS));
}
private Map<String, OnlineTerminal> loadOnlineTerminals() {
Map<String, OnlineTerminal> result = new LinkedHashMap<>();
for (String status : ONLINE_DEVICE_STATUSES) {
loadOnlineTerminals(status, result);
}
return result;
}
private void loadOnlineTerminals(String deviceStatus, Map<String, OnlineTerminal> result) {
JsonNode firstData = successData(findsOpenApiClient.postFailFast(
TERMINAL_LIST_API, terminalPageRequest(deviceStatus, 1)));
JsonNode firstRows = extractRows(firstData);
long total = requiredTotal(firstData, "FindS " + deviceStatus + " terminal response");
if (total > 0 && (firstRows == null || firstRows.size() == 0)) {
throw new BizException("FindS terminal status page is empty while total is " + total);
}
appendOnlineTerminals(result, firstRows, deviceStatus);
int effectivePageSize = firstRows == null || firstRows.size() == 0
? TERMINAL_PAGE_SIZE : firstRows.size();
int pageCount = (int) ((total + effectivePageSize - 1) / effectivePageSize);
if (pageCount > MAX_TERMINAL_PAGE_COUNT) {
throw new BizException("FindS online terminal pages exceed the configured safety limit: " + pageCount);
}
if (pageCount <= 1) {
return;
}
List<Map<String, Object>> requests = new ArrayList<>(pageCount - 1);
for (int pageNo = 2; pageNo <= pageCount; pageNo++) {
requests.add(terminalPageRequest(deviceStatus, pageNo));
}
long receivedCount = firstRows == null ? 0 : firstRows.size();
for (JsonNode root : findsOpenApiClient.postFailFastBatch(
TERMINAL_LIST_API, requests, BATCH_CONCURRENCY)) {
JsonNode rows = extractRows(successData(root));
receivedCount += rows == null ? 0 : rows.size();
appendOnlineTerminals(result, rows, deviceStatus);
}
if (receivedCount < total) {
throw new BizException("FindS online terminal result is incomplete: received="
+ receivedCount + ", total=" + total);
}
}
private Map<String, Object> terminalPageRequest(String deviceStatus, int pageNo) {
Map<String, Object> request = new LinkedHashMap<>();
request.put("deviceStatus", deviceStatus);
request.put("pageNo", pageNo);
request.put("pageSize", TERMINAL_PAGE_SIZE);
return request;
}
private void appendOnlineTerminals(Map<String, OnlineTerminal> result,
JsonNode rows,
String requestedStatus) {
if (rows == null || !rows.isArray()) {
return;
}
for (JsonNode row : rows) {
String terminalNo = firstText(
text(firstPresent(row, "terminalNo", "no", "terminalCode", "imei")));
if (!StringUtils.hasText(terminalNo)) {
continue;
}
String trackType = firstText(
text(firstPresent(row, "trackType")),
text(firstPresent(row.path("track"), "trackType")));
if (StringUtils.hasText(trackType) && !"STAFF".equalsIgnoreCase(trackType)) {
continue;
}
String actualStatus = firstText(
text(firstPresent(row, "deviceStatus", "status", "onlineStatus")), requestedStatus);
result.put(terminalNo, new OnlineTerminal(terminalNo, actualStatus));
}
}
private int updateLocationStatuses(Map<String, OnlineTerminal> onlineTerminals,
LocalDateTime syncTime) {
String updateOnlineSql = "UPDATE personnel_match SET location_status = 'ONLINE', "
+ "finds_device_status = ?, location_status_time = ?, version = version + 1 "
+ "WHERE source_config_code = ? AND (delete_enum IS NULL OR delete_enum = 'FALSE') "
+ "AND finds_terminal_no = ?";
List<Object[]> args = new ArrayList<>(onlineTerminals.size());
for (OnlineTerminal terminal : onlineTerminals.values()) {
args.add(new Object[]{terminal.deviceStatus, syncTime, SOURCE_CONFIG_CODE, terminal.terminalNo});
}
int matched = 0;
if (!args.isEmpty()) {
int[] updated = jdbcTemplate.batchUpdate(updateOnlineSql, args);
for (int count : updated) {
matched += count;
}
}
jdbcTemplate.update("UPDATE personnel_match SET location_status = 'OFFLINE', "
+ "finds_device_status = 'OFFLINE', location_status_time = ?, version = version + 1 "
+ "WHERE source_config_code = ? AND (delete_enum IS NULL OR delete_enum = 'FALSE') "
+ "AND location_status = 'ONLINE' "
+ "AND (location_status_time IS NULL OR location_status_time < ?)",
syncTime, SOURCE_CONFIG_CODE, syncTime);
return matched;
}
private JsonNode successData(JsonNode root) {
int code = root == null ? -1 : root.path("code").asInt(-1);
if (code != 200 && code != 0) {
throw new BizException("FindS personnel status synchronization failed, code=" + code);
}
return root.path("data");
}
private JsonNode extractRows(JsonNode data) {
if (data == null || data.isNull() || data.isMissingNode()) {
return null;
}
if (data.isArray()) {
return data;
}
for (String field : new String[]{"data", "rows", "list", "records"}) {
JsonNode value = data.get(field);
if (value != null && value.isArray()) {
return value;
}
}
return null;
}
private long requiredTotal(JsonNode data, String responseName) {
Long total = longValue(firstPresent(data, "total", "totalCount", "count", "totalRecords"));
if (total == null || total < 0) {
throw new BizException(responseName + " is missing a valid total");
}
return total;
}
private JsonNode firstPresent(JsonNode node, String... fields) {
if (node == null || node.isNull() || node.isMissingNode()) {
return null;
}
for (String field : fields) {
JsonNode value = node.get(field);
if (value != null && !value.isNull() && !value.isMissingNode()) {
return value;
}
}
return null;
}
private String text(JsonNode node) {
return node == null || node.isNull() || node.isMissingNode() || !node.isValueNode()
? null : node.asText();
}
private String firstText(String... values) {
for (String value : values) {
if (StringUtils.hasText(value)) {
return value.trim();
}
}
return null;
}
private Long longValue(JsonNode node) {
if (node == null || node.isNull() || node.isMissingNode()) {
return null;
}
try {
return node.isNumber() ? node.asLong() : Long.valueOf(node.asText());
} catch (NumberFormatException ignored) {
return null;
}
}
private static class OnlineTerminal {
private final String terminalNo;
private final String deviceStatus;
private OnlineTerminal(String terminalNo, String deviceStatus) {
this.terminalNo = terminalNo;
this.deviceStatus = deviceStatus;
}
}
public static class SyncSummary {
private final long remoteStaffCount;
private final long localStaffCount;
private final boolean fullSync;
private final int fullSyncCount;
private final int onlineTerminalCount;
private final int onlineMappingCount;
private SyncSummary(long remoteStaffCount, long localStaffCount, boolean fullSync,
int fullSyncCount, int onlineTerminalCount, int onlineMappingCount) {
this.remoteStaffCount = remoteStaffCount;
this.localStaffCount = localStaffCount;
this.fullSync = fullSync;
this.fullSyncCount = fullSyncCount;
this.onlineTerminalCount = onlineTerminalCount;
this.onlineMappingCount = onlineMappingCount;
}
public long getRemoteStaffCount() {
return remoteStaffCount;
}
public long getLocalStaffCount() {
return localStaffCount;
}
public boolean isFullSync() {
return fullSync;
}
public int getFullSyncCount() {
return fullSyncCount;
}
public int getOnlineTerminalCount() {
return onlineTerminalCount;
}
public int getOnlineMappingCount() {
return onlineMappingCount;
}
}
}

View File

@ -62,11 +62,14 @@ public class FindsStaffMappingSyncService {
JsonNode firstData = successData( JsonNode firstData = successData(
findsOpenApiClient.postFailFast(STAFF_LIST_API, pageRequest(1))); findsOpenApiClient.postFailFast(STAFF_LIST_API, pageRequest(1)));
int firstPageSize = appendRows(result, extractRows(firstData)); int firstPageSize = appendRows(result, extractRows(firstData));
if (firstPageSize == 0) { long total = requiredTotal(firstData);
return result; if (total > 0 && firstPageSize == 0) {
throw new BizException("FindS staff page is empty while total is " + total);
}
int pageCount = (int) ((total + PAGE_SIZE - 1) / PAGE_SIZE);
if (pageCount > MAX_PAGE_COUNT) {
throw new BizException("FindS staff pages exceed the configured safety limit: " + pageCount);
} }
long total = totalValue(firstData, firstPageSize);
int pageCount = (int) Math.min((total + PAGE_SIZE - 1) / PAGE_SIZE, MAX_PAGE_COUNT);
if (pageCount <= 1) { if (pageCount <= 1) {
return result; return result;
} }
@ -74,9 +77,14 @@ public class FindsStaffMappingSyncService {
for (int pageNo = 2; pageNo <= pageCount; pageNo++) { for (int pageNo = 2; pageNo <= pageCount; pageNo++) {
requests.add(pageRequest(pageNo)); requests.add(pageRequest(pageNo));
} }
long receivedCount = firstPageSize;
for (JsonNode root : findsOpenApiClient.postFailFastBatch( for (JsonNode root : findsOpenApiClient.postFailFastBatch(
STAFF_LIST_API, requests, BATCH_CONCURRENCY)) { STAFF_LIST_API, requests, BATCH_CONCURRENCY)) {
appendRows(result, extractRows(successData(root))); receivedCount += appendRows(result, extractRows(successData(root)));
}
if (receivedCount < total) {
throw new BizException("FindS staff result is incomplete: received="
+ receivedCount + ", total=" + total);
} }
return result; return result;
} }
@ -218,9 +226,12 @@ public class FindsStaffMappingSyncService {
return null; return null;
} }
private long totalValue(JsonNode data, long defaultValue) { private long requiredTotal(JsonNode data) {
Long total = longValue(firstPresent(data, "total", "totalCount", "count", "totalRecords")); Long total = longValue(firstPresent(data, "total", "totalCount", "count", "totalRecords"));
return total == null ? defaultValue : total; if (total == null || total < 0) {
throw new BizException("FindS staff response is missing a valid total");
}
return total;
} }
private JsonNode firstPresent(JsonNode node, String... fields) { private JsonNode firstPresent(JsonNode node, String... fields) {

View File

@ -41,7 +41,8 @@ class PositionPersonLocalLookupService {
StringBuilder sql = new StringBuilder( StringBuilder sql = new StringBuilder(
"SELECT pm.finds_staff_no, pm.finds_staff_no_type, pm.finds_id_card_no, " "SELECT pm.finds_staff_no, pm.finds_staff_no_type, pm.finds_id_card_no, "
+ "pm.finds_staff_name, pm.finds_corpinfo_id, pm.finds_corpinfo_name, " + "pm.finds_staff_name, pm.finds_corpinfo_id, pm.finds_corpinfo_name, "
+ "pm.finds_department_name, pm.finds_terminal_no, " + "pm.finds_department_name, pm.finds_terminal_no, pm.location_status, "
+ "pm.finds_device_status, "
+ "u.id AS user_id, u.username AS local_staff_no, u.name AS staff_name, " + "u.id AS user_id, u.username AS local_staff_no, u.name AS staff_name, "
+ "TRIM(u.phone) AS mobile_no, u.user_id_card AS id_card_no, u.corpinfo_id, " + "TRIM(u.phone) AS mobile_no, u.user_id_card AS id_card_no, u.corpinfo_id, "
+ "ci.corp_name AS corpinfo_name, ci.code AS credit_code, ci.port_area, " + "ci.corp_name AS corpinfo_name, ci.code AS credit_code, ci.port_area, "
@ -103,6 +104,8 @@ class PositionPersonLocalLookupService {
mapping.setFindsCorpinfoName(rs.getString("finds_corpinfo_name")); mapping.setFindsCorpinfoName(rs.getString("finds_corpinfo_name"));
mapping.setFindsDepartmentName(rs.getString("finds_department_name")); mapping.setFindsDepartmentName(rs.getString("finds_department_name"));
mapping.setFindsTerminalNo(normalize(rs.getString("finds_terminal_no"))); mapping.setFindsTerminalNo(normalize(rs.getString("finds_terminal_no")));
mapping.setLocationStatus(normalize(rs.getString("location_status")));
mapping.setFindsDeviceStatus(normalize(rs.getString("finds_device_status")));
mapping.setLocal(local); mapping.setLocal(local);
return mapping; return mapping;
}); });
@ -228,13 +231,14 @@ class PositionPersonLocalLookupService {
Map<String, TerminalSnapshot> result = new LinkedHashMap<>(); Map<String, TerminalSnapshot> result = new LinkedHashMap<>();
for (List<String> batch : batches(terminalNos)) { for (List<String> batch : batches(terminalNos)) {
String placeholders = String.join(",", Collections.nCopies(batch.size(), "?")); String placeholders = String.join(",", Collections.nCopies(batch.size(), "?"));
String sql = "SELECT terminal_no, device_type, last_location_time, last_location_name " String sql = "SELECT terminal_no, device_type, device_status, last_location_time, last_location_name "
+ "FROM terminal WHERE (delete_enum IS NULL OR delete_enum = 'FALSE') " + "FROM terminal WHERE (delete_enum IS NULL OR delete_enum = 'FALSE') "
+ "AND terminal_no IN (" + placeholders + ") ORDER BY update_time DESC, id DESC"; + "AND terminal_no IN (" + placeholders + ") ORDER BY update_time DESC, id DESC";
jdbcTemplate.query(sql, batch.toArray(), (rs, rowNum) -> { jdbcTemplate.query(sql, batch.toArray(), (rs, rowNum) -> {
TerminalSnapshot terminal = new TerminalSnapshot(); TerminalSnapshot terminal = new TerminalSnapshot();
terminal.setTerminalNo(rs.getString("terminal_no")); terminal.setTerminalNo(rs.getString("terminal_no"));
terminal.setDeviceType(rs.getString("device_type")); terminal.setDeviceType(rs.getString("device_type"));
terminal.setDeviceStatus(normalize(rs.getString("device_status")));
Timestamp time = rs.getTimestamp("last_location_time"); Timestamp time = rs.getTimestamp("last_location_time");
terminal.setLastLocationTime(time == null ? null : time.getTime()); terminal.setLastLocationTime(time == null ? null : time.getTime());
terminal.setLastLocationName(rs.getString("last_location_name")); terminal.setLastLocationName(rs.getString("last_location_name"));
@ -288,6 +292,7 @@ class PositionPersonLocalLookupService {
static class TerminalSnapshot { static class TerminalSnapshot {
private String terminalNo; private String terminalNo;
private String deviceType; private String deviceType;
private String deviceStatus;
private Long lastLocationTime; private Long lastLocationTime;
private String lastLocationName; private String lastLocationName;
} }
@ -302,6 +307,8 @@ class PositionPersonLocalLookupService {
private String findsCorpinfoName; private String findsCorpinfoName;
private String findsDepartmentName; private String findsDepartmentName;
private String findsTerminalNo; private String findsTerminalNo;
private String locationStatus;
private String findsDeviceStatus;
private LocalPerson local; private LocalPerson local;
} }

View File

@ -41,7 +41,8 @@ public class PositionPersonQueryExe {
public PageResponse<PositionPersonCO> list(PositionPersonPageQry qry) { public PageResponse<PositionPersonCO> list(PositionPersonPageQry qry) {
PositionPersonPageQry query = qry == null ? new PositionPersonPageQry() : qry; PositionPersonPageQry query = qry == null ? new PositionPersonPageQry() : qry;
List<MatchedPerson> matched = loadMatchedPeople(query); boolean persistedMappings = localLookupService.hasPersonnelMappings();
List<MatchedPerson> matched = loadMatchedPeople(query, persistedMappings);
Set<String> idCardNos = new LinkedHashSet<>(); Set<String> idCardNos = new LinkedHashSet<>();
for (MatchedPerson person : matched) { for (MatchedPerson person : matched) {
if (StringUtils.hasText(person.local.getIdCardNo())) { if (StringUtils.hasText(person.local.getIdCardNo())) {
@ -63,7 +64,8 @@ public class PositionPersonQueryExe {
} }
} }
Map<String, JsonNode> currentLocations = locateTerminals(terminalNos); Map<String, JsonNode> currentLocations = persistedMappings
? Collections.emptyMap() : locateTerminals(terminalNos);
Map<String, PositionPersonLocalLookupService.TerminalSnapshot> localTerminals = Map<String, PositionPersonLocalLookupService.TerminalSnapshot> localTerminals =
localLookupService.findTerminals(terminalNos); localLookupService.findTerminals(terminalNos);
List<PositionPersonCO> rows = new ArrayList<>(); List<PositionPersonCO> rows = new ArrayList<>();
@ -71,8 +73,9 @@ public class PositionPersonQueryExe {
JsonNode currentLocation = currentLocations.get(person.finds.terminalNo); JsonNode currentLocation = currentLocations.get(person.finds.terminalNo);
PositionPersonLocalLookupService.TerminalSnapshot terminal = PositionPersonLocalLookupService.TerminalSnapshot terminal =
localTerminals.get(person.finds.terminalNo); localTerminals.get(person.finds.terminalNo);
boolean online = hasLocation(currentLocation); boolean online = persistedMappings
boolean locatedBefore = online || hasHistoricalLocation(person.finds.lastLocation) ? isStoredOnline(person.finds, terminal) : hasLocation(currentLocation);
boolean locatedBefore = persistedMappings || online || hasHistoricalLocation(person.finds.lastLocation)
|| person.finds.lastLocationTime != null || person.finds.lastLocationTime != null
|| hasHistoricalLocation(terminal); || hasHistoricalLocation(terminal);
if (!locatedBefore) { if (!locatedBefore) {
@ -94,8 +97,8 @@ public class PositionPersonQueryExe {
return PageResponse.of(rows.subList(fromIndex, toIndex), rows.size(), pageSize, pageIndex); return PageResponse.of(rows.subList(fromIndex, toIndex), rows.size(), pageSize, pageIndex);
} }
private List<MatchedPerson> loadMatchedPeople(PositionPersonPageQry query) { private List<MatchedPerson> loadMatchedPeople(PositionPersonPageQry query, boolean persistedMappings) {
if (localLookupService.hasPersonnelMappings()) { if (persistedMappings) {
return loadPersistedMatches(query); return loadPersistedMatches(query);
} }
List<FindsStaff> findsStaff = loadAllFindsStaff(); List<FindsStaff> findsStaff = loadAllFindsStaff();
@ -137,6 +140,8 @@ public class PositionPersonQueryExe {
staff.sourceCompanyName = mapping.getFindsCorpinfoName(); staff.sourceCompanyName = mapping.getFindsCorpinfoName();
staff.sourceDepartmentName = mapping.getFindsDepartmentName(); staff.sourceDepartmentName = mapping.getFindsDepartmentName();
staff.terminalNo = mapping.getFindsTerminalNo(); staff.terminalNo = mapping.getFindsTerminalNo();
staff.locationStatus = mapping.getLocationStatus();
staff.deviceStatus = mapping.getFindsDeviceStatus();
result.add(new MatchedPerson(staff, local)); result.add(new MatchedPerson(staff, local));
} }
return result; return result;
@ -343,6 +348,19 @@ public class PositionPersonQueryExe {
|| StringUtils.hasText(terminal.getLastLocationName())); || StringUtils.hasText(terminal.getLastLocationName()));
} }
private boolean isStoredOnline(FindsStaff staff,
PositionPersonLocalLookupService.TerminalSnapshot terminal) {
if (staff != null && StringUtils.hasText(staff.locationStatus)) {
return STATUS_ONLINE.equalsIgnoreCase(staff.locationStatus);
}
String deviceStatus = firstText(
staff == null ? null : staff.deviceStatus,
terminal == null ? null : terminal.getDeviceStatus());
return STATUS_ONLINE.equalsIgnoreCase(deviceStatus)
|| "STATIC".equalsIgnoreCase(deviceStatus)
|| "MOVING".equalsIgnoreCase(deviceStatus);
}
private String positionSource(PositionPersonLocalLookupService.TerminalSnapshot terminal) { private String positionSource(PositionPersonLocalLookupService.TerminalSnapshot terminal) {
String type = terminal == null ? null : normalize(terminal.getDeviceType()); String type = terminal == null ? null : normalize(terminal.getDeviceType());
if ("CARD".equalsIgnoreCase(type)) { if ("CARD".equalsIgnoreCase(type)) {
@ -542,6 +560,8 @@ public class PositionPersonQueryExe {
private String sourceCompanyName; private String sourceCompanyName;
private String sourceDepartmentName; private String sourceDepartmentName;
private String terminalNo; private String terminalNo;
private String locationStatus;
private String deviceStatus;
private Long lastLocationTime; private Long lastLocationTime;
private JsonNode lastLocation; private JsonNode lastLocation;
} }

View File

@ -0,0 +1,117 @@
package com.zcloud.personnel.positioning.command.query;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.zcloud.personnel.positioning.integration.finds.FindsOpenApiClient;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.LocalDateTime;
import java.util.Collections;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class FindsStaffLocationStatusSyncServiceTest {
private final ObjectMapper objectMapper = new ObjectMapper();
private FindsOpenApiClient findsOpenApiClient;
private FindsStaffMappingSyncService mappingSyncService;
private JdbcTemplate jdbcTemplate;
@BeforeEach
void setUp() {
findsOpenApiClient = mock(FindsOpenApiClient.class);
mappingSyncService = mock(FindsStaffMappingSyncService.class);
jdbcTemplate = mock(JdbcTemplate.class);
}
@Test
void unchangedStaffCountSkipsFullSyncAndRefreshesStatus() throws Exception {
stubCount(2L);
stubTerminalStatusPages();
FindsStaffLocationStatusSyncService service = new FindsStaffLocationStatusSyncService(
findsOpenApiClient, mappingSyncService, jdbcTemplate);
FindsStaffLocationStatusSyncService.SyncSummary summary = service.synchronize();
assertFalse(summary.isFullSync());
verify(mappingSyncService, never()).syncAll();
verify(jdbcTemplate).update(anyString(), any(), eq("FINDS"), any());
}
@Test
void changedStaffCountRunsFullSyncBeforeStatusRefresh() throws Exception {
stubCount(2L);
when(jdbcTemplate.queryForObject(anyString(), eq(Long.class), eq("FINDS"))).thenReturn(1L);
stubTerminalStatusPages();
when(mappingSyncService.syncAll()).thenReturn(2);
FindsStaffLocationStatusSyncService service = new FindsStaffLocationStatusSyncService(
findsOpenApiClient, mappingSyncService, jdbcTemplate);
FindsStaffLocationStatusSyncService.SyncSummary summary = service.synchronize();
assertTrue(summary.isFullSync());
verify(mappingSyncService).syncAll();
}
@Test
void failedOnlineStatusPageDoesNotMarkExistingRowsOffline() throws Exception {
stubCount(2L);
when(findsOpenApiClient.postFailFast(eq("finds.terminal.list"), anyMap()))
.thenReturn(json("{\"code\":500,\"data\":{\"total\":0,\"data\":[]}}"));
FindsStaffLocationStatusSyncService service = new FindsStaffLocationStatusSyncService(
findsOpenApiClient, mappingSyncService, jdbcTemplate);
try {
service.synchronize();
} catch (RuntimeException expected) {
// The status cycle must fail before any database status update.
}
verify(jdbcTemplate, never()).batchUpdate(anyString(), any(java.util.List.class));
verify(jdbcTemplate, never()).update(anyString(), any(), any(), any());
}
@Test
void missingRemoteStaffTotalStopsBeforeDatabaseStatusUpdate() throws Exception {
when(findsOpenApiClient.postFailFast(eq("finds.staff.list"), anyMap()))
.thenReturn(json("{\"code\":200,\"data\":{\"data\":[]}}"));
FindsStaffLocationStatusSyncService service = new FindsStaffLocationStatusSyncService(
findsOpenApiClient, mappingSyncService, jdbcTemplate);
assertThrows(RuntimeException.class, service::synchronize);
verify(mappingSyncService, never()).syncAll();
verify(jdbcTemplate, never()).batchUpdate(anyString(), any(java.util.List.class));
verify(jdbcTemplate, never()).update(anyString(), any(), any(), any());
}
private void stubCount(long remoteCount) throws Exception {
when(findsOpenApiClient.postFailFast(eq("finds.staff.list"), anyMap()))
.thenReturn(json("{\"code\":200,\"data\":{\"total\":" + remoteCount
+ ",\"data\":[]}}"));
when(jdbcTemplate.queryForObject(anyString(), eq(Long.class), eq("FINDS")))
.thenReturn(remoteCount);
when(jdbcTemplate.queryForObject(anyString(), eq(LocalDateTime.class), eq("FINDS")))
.thenReturn(LocalDateTime.now());
}
private void stubTerminalStatusPages() throws Exception {
when(findsOpenApiClient.postFailFast(eq("finds.terminal.list"), anyMap()))
.thenReturn(json("{\"code\":200,\"data\":{\"total\":0,\"data\":[]}}"));
}
private JsonNode json(String value) throws Exception {
return objectMapper.readTree(value);
}
}

View File

@ -0,0 +1,65 @@
package com.zcloud.personnel.positioning.command.query;
import com.alibaba.cola.dto.PageResponse;
import com.zcloud.personnel.positioning.dto.PositionPersonPageQry;
import com.zcloud.personnel.positioning.dto.clientobject.PositionPersonCO;
import com.zcloud.personnel.positioning.integration.finds.FindsOpenApiClient;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.Collections;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anySet;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class PositionPersonStoredStatusTest {
private FindsOpenApiClient findsOpenApiClient;
private PositionPersonLocalLookupService localLookupService;
private PositionPersonQueryExe queryExe;
@BeforeEach
void setUp() {
findsOpenApiClient = mock(FindsOpenApiClient.class);
localLookupService = mock(PositionPersonLocalLookupService.class);
queryExe = new PositionPersonQueryExe(findsOpenApiClient, localLookupService);
}
@Test
void persistedStatusIsUsedWithoutCallingFindsLocate() {
PositionPersonLocalLookupService.LocalPerson local = new PositionPersonLocalLookupService.LocalPerson();
local.setUserId(1L);
local.setStaffName("Stored User");
local.setMobileNo("13800000001");
PositionPersonLocalLookupService.PersonMapping mapping =
new PositionPersonLocalLookupService.PersonMapping();
mapping.setFindsStaffNo("13800000001");
mapping.setFindsTerminalNo("T-1");
mapping.setLocationStatus("ONLINE");
mapping.setLocal(local);
when(localLookupService.hasPersonnelMappings()).thenReturn(true);
when(localLookupService.findMatchedPersonnel(any(PositionPersonPageQry.class)))
.thenReturn(Collections.singletonList(mapping));
when(localLookupService.findTerminalNosByIdCardNos(anySet()))
.thenReturn(Collections.emptyMap());
when(localLookupService.findTerminals(eq(Collections.singleton("T-1"))))
.thenReturn(Collections.emptyMap());
PositionPersonPageQry qry = new PositionPersonPageQry();
qry.setPageIndex(1);
qry.setPageSize(20);
PageResponse<PositionPersonCO> response = queryExe.list(qry);
assertEquals(1, response.getTotalCount());
assertEquals("ONLINE", response.getData().get(0).getLocationStatus());
verify(findsOpenApiClient, never()).postFailFast(eq("finds.point.locate"), anyMap());
}
}

View File

@ -189,6 +189,9 @@ CREATE TABLE IF NOT EXISTS `personnel_match` (
`local_id_card_no` varchar(64) DEFAULT NULL COMMENT 'matched local id card snapshot', `local_id_card_no` varchar(64) DEFAULT NULL COMMENT 'matched local id card snapshot',
`match_status` varchar(32) NOT NULL DEFAULT 'UNMATCHED' COMMENT 'MATCHED/UNMATCHED/AMBIGUOUS/DISABLED', `match_status` varchar(32) NOT NULL DEFAULT 'UNMATCHED' COMMENT 'MATCHED/UNMATCHED/AMBIGUOUS/DISABLED',
`match_type` varchar(32) DEFAULT NULL COMMENT 'MOBILE/ID_CARD/MOBILE_AND_ID_CARD', `match_type` varchar(32) DEFAULT NULL COMMENT 'MOBILE/ID_CARD/MOBILE_AND_ID_CARD',
`location_status` varchar(32) NOT NULL DEFAULT 'OFFLINE' COMMENT 'ONLINE/OFFLINE',
`finds_device_status` varchar(32) DEFAULT NULL COMMENT 'FindS STATIC/MOVING/OFFLINE/UN_LOCATION/NONE',
`location_status_time` datetime DEFAULT NULL COMMENT 'location status sync time',
`last_seen_time` datetime DEFAULT NULL COMMENT 'last time observed from FindS', `last_seen_time` datetime DEFAULT NULL COMMENT 'last time observed from FindS',
`last_sync_time` datetime DEFAULT NULL COMMENT 'last mapping synchronization time', `last_sync_time` datetime DEFAULT NULL COMMENT 'last mapping synchronization time',
`sync_status` varchar(32) NOT NULL DEFAULT 'SUCCESS' COMMENT 'SUCCESS/FAIL', `sync_status` varchar(32) NOT NULL DEFAULT 'SUCCESS' COMMENT 'SUCCESS/FAIL',
@ -211,6 +214,7 @@ CREATE TABLE IF NOT EXISTS `personnel_match` (
KEY `idx_personnel_match_terminal_no` (`finds_terminal_no`), KEY `idx_personnel_match_terminal_no` (`finds_terminal_no`),
KEY `idx_personnel_match_local_id_card` (`local_id_card_no`), KEY `idx_personnel_match_local_id_card` (`local_id_card_no`),
KEY `idx_personnel_match_scope_status` (`finds_corpinfo_id`, `match_status`), KEY `idx_personnel_match_scope_status` (`finds_corpinfo_id`, `match_status`),
KEY `idx_personnel_match_location_status` (`location_status`, `match_status`),
KEY `idx_personnel_match_last_seen` (`last_seen_time`), KEY `idx_personnel_match_last_seen` (`last_seen_time`),
KEY `idx_personnel_match_tenant_org` (`tenant_id`, `org_id`) KEY `idx_personnel_match_tenant_org` (`tenant_id`, `org_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='FindS to local personnel mapping'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='FindS to local personnel mapping';

View File

@ -0,0 +1,40 @@
-- Store the latest online/offline state without persisting realtime coordinates.
SET @ddl = IF(
(SELECT COUNT(1) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'personnel_match' AND COLUMN_NAME = 'location_status') = 0,
'ALTER TABLE `personnel_match` ADD COLUMN `location_status` varchar(32) NOT NULL DEFAULT ''OFFLINE'' COMMENT ''ONLINE/OFFLINE'' AFTER `match_type`',
'DO 0'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl = IF(
(SELECT COUNT(1) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'personnel_match' AND COLUMN_NAME = 'finds_device_status') = 0,
'ALTER TABLE `personnel_match` ADD COLUMN `finds_device_status` varchar(32) DEFAULT NULL COMMENT ''FindS device status'' AFTER `location_status`',
'DO 0'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl = IF(
(SELECT COUNT(1) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'personnel_match' AND COLUMN_NAME = 'location_status_time') = 0,
'ALTER TABLE `personnel_match` ADD COLUMN `location_status_time` datetime DEFAULT NULL COMMENT ''location status sync time'' AFTER `finds_device_status`',
'DO 0'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl = IF(
(SELECT COUNT(1) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'personnel_match' AND INDEX_NAME = 'idx_personnel_match_location_status') = 0,
'ALTER TABLE `personnel_match` ADD KEY `idx_personnel_match_location_status` (`location_status`, `match_status`)',
'DO 0'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;