From 5d9927b6e48226a0418d290985e8f8ab104ecce7 Mon Sep 17 00:00:00 2001 From: shenzhidan Date: Thu, 23 Jul 2026 18:35:48 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BA=BA=E5=91=98=E5=9C=A8?= =?UTF-8?q?=E7=BA=BF=E7=8A=B6=E6=80=81=E5=A2=9E=E9=87=8F=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plan/FindsStaffMappingSyncJob.java | 16 +- .../FindsStaffLocationStatusSyncService.java | 324 ++++++++++++++++++ .../query/FindsStaffMappingSyncService.java | 25 +- .../PositionPersonLocalLookupService.java | 11 +- .../command/query/PositionPersonQueryExe.java | 32 +- ...ndsStaffLocationStatusSyncServiceTest.java | 117 +++++++ .../query/PositionPersonStoredStatusTest.java | 65 ++++ .../src/main/resources/TableCreationDDL.sql | 4 + ...20260723_02__personnel_location_status.sql | 40 +++ 9 files changed, 613 insertions(+), 21 deletions(-) create mode 100644 web-app/src/main/java/com/zcloud/personnel/positioning/command/query/FindsStaffLocationStatusSyncService.java create mode 100644 web-app/src/test/java/com/zcloud/personnel/positioning/command/query/FindsStaffLocationStatusSyncServiceTest.java create mode 100644 web-app/src/test/java/com/zcloud/personnel/positioning/command/query/PositionPersonStoredStatusTest.java create mode 100644 web-infrastructure/src/main/resources/db/migration/mysql/V20260723_02__personnel_location_status.sql diff --git a/web-adapter/src/main/java/com/zcloud/personnel/positioning/plan/FindsStaffMappingSyncJob.java b/web-adapter/src/main/java/com/zcloud/personnel/positioning/plan/FindsStaffMappingSyncJob.java index 869fb37..4f100c9 100644 --- a/web-adapter/src/main/java/com/zcloud/personnel/positioning/plan/FindsStaffMappingSyncJob.java +++ b/web-adapter/src/main/java/com/zcloud/personnel/positioning/plan/FindsStaffMappingSyncJob.java @@ -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.context.XxlJobHelper; 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 org.springframework.stereotype.Component; @Component @RequiredArgsConstructor public class FindsStaffMappingSyncJob implements Job { - private final FindsStaffMappingSyncService syncService; + private final FindsStaffLocationStatusSyncService locationStatusSyncService; @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") public ReturnT execute(String param) { try { - int count = syncService.syncAll(); - String message = "FindS人员映射同步完成,本次处理 " + count + " 条"; + FindsStaffLocationStatusSyncService.SyncSummary summary = locationStatusSyncService.synchronize(); + String message = "FindS人员映射和定位状态同步完成,人员总数=" + + summary.getRemoteStaffCount() + + ",全量同步=" + summary.isFullSync() + + ",本次补充=" + summary.getFullSyncCount() + + ",在线终端=" + summary.getOnlineTerminalCount(); XxlJobHelper.log(message); return new ReturnT<>(ReturnT.SUCCESS_CODE, message); } catch (Exception e) { - String message = "FindS人员映射同步失败:" + e.getMessage(); + String message = "FindS人员映射和定位状态同步失败:" + e.getMessage(); XxlJobHelper.log(message); return new ReturnT<>(ReturnT.FAIL_CODE, message); } diff --git a/web-app/src/main/java/com/zcloud/personnel/positioning/command/query/FindsStaffLocationStatusSyncService.java b/web-app/src/main/java/com/zcloud/personnel/positioning/command/query/FindsStaffLocationStatusSyncService.java new file mode 100644 index 0000000..f25f354 --- /dev/null +++ b/web-app/src/main/java/com/zcloud/personnel/positioning/command/query/FindsStaffLocationStatusSyncService.java @@ -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. + * + *

Only device status is stored. Coordinates remain outside MySQL.

+ */ +@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 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 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 staffCountRequest() { + Map 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 loadOnlineTerminals() { + Map result = new LinkedHashMap<>(); + for (String status : ONLINE_DEVICE_STATUSES) { + loadOnlineTerminals(status, result); + } + return result; + } + + private void loadOnlineTerminals(String deviceStatus, Map 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> 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 terminalPageRequest(String deviceStatus, int pageNo) { + Map request = new LinkedHashMap<>(); + request.put("deviceStatus", deviceStatus); + request.put("pageNo", pageNo); + request.put("pageSize", TERMINAL_PAGE_SIZE); + return request; + } + + private void appendOnlineTerminals(Map 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 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 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; + } + } +} diff --git a/web-app/src/main/java/com/zcloud/personnel/positioning/command/query/FindsStaffMappingSyncService.java b/web-app/src/main/java/com/zcloud/personnel/positioning/command/query/FindsStaffMappingSyncService.java index fd48ded..5c79777 100644 --- a/web-app/src/main/java/com/zcloud/personnel/positioning/command/query/FindsStaffMappingSyncService.java +++ b/web-app/src/main/java/com/zcloud/personnel/positioning/command/query/FindsStaffMappingSyncService.java @@ -62,11 +62,14 @@ public class FindsStaffMappingSyncService { JsonNode firstData = successData( findsOpenApiClient.postFailFast(STAFF_LIST_API, pageRequest(1))); int firstPageSize = appendRows(result, extractRows(firstData)); - if (firstPageSize == 0) { - return result; + long total = requiredTotal(firstData); + 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) { return result; } @@ -74,9 +77,14 @@ public class FindsStaffMappingSyncService { for (int pageNo = 2; pageNo <= pageCount; pageNo++) { requests.add(pageRequest(pageNo)); } + long receivedCount = firstPageSize; for (JsonNode root : findsOpenApiClient.postFailFastBatch( 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; } @@ -218,9 +226,12 @@ public class FindsStaffMappingSyncService { return null; } - private long totalValue(JsonNode data, long defaultValue) { + private long requiredTotal(JsonNode data) { 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) { diff --git a/web-app/src/main/java/com/zcloud/personnel/positioning/command/query/PositionPersonLocalLookupService.java b/web-app/src/main/java/com/zcloud/personnel/positioning/command/query/PositionPersonLocalLookupService.java index 4ca580d..decedb0 100644 --- a/web-app/src/main/java/com/zcloud/personnel/positioning/command/query/PositionPersonLocalLookupService.java +++ b/web-app/src/main/java/com/zcloud/personnel/positioning/command/query/PositionPersonLocalLookupService.java @@ -41,7 +41,8 @@ class PositionPersonLocalLookupService { StringBuilder sql = new StringBuilder( "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_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, " + "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, " @@ -103,6 +104,8 @@ class PositionPersonLocalLookupService { mapping.setFindsCorpinfoName(rs.getString("finds_corpinfo_name")); mapping.setFindsDepartmentName(rs.getString("finds_department_name")); 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); return mapping; }); @@ -228,13 +231,14 @@ class PositionPersonLocalLookupService { Map result = new LinkedHashMap<>(); for (List batch : batches(terminalNos)) { 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') " + "AND terminal_no IN (" + placeholders + ") ORDER BY update_time DESC, id DESC"; jdbcTemplate.query(sql, batch.toArray(), (rs, rowNum) -> { TerminalSnapshot terminal = new TerminalSnapshot(); terminal.setTerminalNo(rs.getString("terminal_no")); terminal.setDeviceType(rs.getString("device_type")); + terminal.setDeviceStatus(normalize(rs.getString("device_status"))); Timestamp time = rs.getTimestamp("last_location_time"); terminal.setLastLocationTime(time == null ? null : time.getTime()); terminal.setLastLocationName(rs.getString("last_location_name")); @@ -288,6 +292,7 @@ class PositionPersonLocalLookupService { static class TerminalSnapshot { private String terminalNo; private String deviceType; + private String deviceStatus; private Long lastLocationTime; private String lastLocationName; } @@ -302,6 +307,8 @@ class PositionPersonLocalLookupService { private String findsCorpinfoName; private String findsDepartmentName; private String findsTerminalNo; + private String locationStatus; + private String findsDeviceStatus; private LocalPerson local; } diff --git a/web-app/src/main/java/com/zcloud/personnel/positioning/command/query/PositionPersonQueryExe.java b/web-app/src/main/java/com/zcloud/personnel/positioning/command/query/PositionPersonQueryExe.java index 97b06d8..25611b0 100644 --- a/web-app/src/main/java/com/zcloud/personnel/positioning/command/query/PositionPersonQueryExe.java +++ b/web-app/src/main/java/com/zcloud/personnel/positioning/command/query/PositionPersonQueryExe.java @@ -41,7 +41,8 @@ public class PositionPersonQueryExe { public PageResponse list(PositionPersonPageQry qry) { PositionPersonPageQry query = qry == null ? new PositionPersonPageQry() : qry; - List matched = loadMatchedPeople(query); + boolean persistedMappings = localLookupService.hasPersonnelMappings(); + List matched = loadMatchedPeople(query, persistedMappings); Set idCardNos = new LinkedHashSet<>(); for (MatchedPerson person : matched) { if (StringUtils.hasText(person.local.getIdCardNo())) { @@ -63,7 +64,8 @@ public class PositionPersonQueryExe { } } - Map currentLocations = locateTerminals(terminalNos); + Map currentLocations = persistedMappings + ? Collections.emptyMap() : locateTerminals(terminalNos); Map localTerminals = localLookupService.findTerminals(terminalNos); List rows = new ArrayList<>(); @@ -71,8 +73,9 @@ public class PositionPersonQueryExe { JsonNode currentLocation = currentLocations.get(person.finds.terminalNo); PositionPersonLocalLookupService.TerminalSnapshot terminal = localTerminals.get(person.finds.terminalNo); - boolean online = hasLocation(currentLocation); - boolean locatedBefore = online || hasHistoricalLocation(person.finds.lastLocation) + boolean online = persistedMappings + ? isStoredOnline(person.finds, terminal) : hasLocation(currentLocation); + boolean locatedBefore = persistedMappings || online || hasHistoricalLocation(person.finds.lastLocation) || person.finds.lastLocationTime != null || hasHistoricalLocation(terminal); if (!locatedBefore) { @@ -94,8 +97,8 @@ public class PositionPersonQueryExe { return PageResponse.of(rows.subList(fromIndex, toIndex), rows.size(), pageSize, pageIndex); } - private List loadMatchedPeople(PositionPersonPageQry query) { - if (localLookupService.hasPersonnelMappings()) { + private List loadMatchedPeople(PositionPersonPageQry query, boolean persistedMappings) { + if (persistedMappings) { return loadPersistedMatches(query); } List findsStaff = loadAllFindsStaff(); @@ -137,6 +140,8 @@ public class PositionPersonQueryExe { staff.sourceCompanyName = mapping.getFindsCorpinfoName(); staff.sourceDepartmentName = mapping.getFindsDepartmentName(); staff.terminalNo = mapping.getFindsTerminalNo(); + staff.locationStatus = mapping.getLocationStatus(); + staff.deviceStatus = mapping.getFindsDeviceStatus(); result.add(new MatchedPerson(staff, local)); } return result; @@ -343,6 +348,19 @@ public class PositionPersonQueryExe { || 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) { String type = terminal == null ? null : normalize(terminal.getDeviceType()); if ("CARD".equalsIgnoreCase(type)) { @@ -542,6 +560,8 @@ public class PositionPersonQueryExe { private String sourceCompanyName; private String sourceDepartmentName; private String terminalNo; + private String locationStatus; + private String deviceStatus; private Long lastLocationTime; private JsonNode lastLocation; } diff --git a/web-app/src/test/java/com/zcloud/personnel/positioning/command/query/FindsStaffLocationStatusSyncServiceTest.java b/web-app/src/test/java/com/zcloud/personnel/positioning/command/query/FindsStaffLocationStatusSyncServiceTest.java new file mode 100644 index 0000000..9a3eb45 --- /dev/null +++ b/web-app/src/test/java/com/zcloud/personnel/positioning/command/query/FindsStaffLocationStatusSyncServiceTest.java @@ -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); + } +} diff --git a/web-app/src/test/java/com/zcloud/personnel/positioning/command/query/PositionPersonStoredStatusTest.java b/web-app/src/test/java/com/zcloud/personnel/positioning/command/query/PositionPersonStoredStatusTest.java new file mode 100644 index 0000000..e7bf1a2 --- /dev/null +++ b/web-app/src/test/java/com/zcloud/personnel/positioning/command/query/PositionPersonStoredStatusTest.java @@ -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 response = queryExe.list(qry); + + assertEquals(1, response.getTotalCount()); + assertEquals("ONLINE", response.getData().get(0).getLocationStatus()); + verify(findsOpenApiClient, never()).postFailFast(eq("finds.point.locate"), anyMap()); + } +} diff --git a/web-infrastructure/src/main/resources/TableCreationDDL.sql b/web-infrastructure/src/main/resources/TableCreationDDL.sql index d7553d2..c26deb5 100644 --- a/web-infrastructure/src/main/resources/TableCreationDDL.sql +++ b/web-infrastructure/src/main/resources/TableCreationDDL.sql @@ -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', `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', + `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_sync_time` datetime DEFAULT NULL COMMENT 'last mapping synchronization time', `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_local_id_card` (`local_id_card_no`), 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_tenant_org` (`tenant_id`, `org_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='FindS to local personnel mapping'; diff --git a/web-infrastructure/src/main/resources/db/migration/mysql/V20260723_02__personnel_location_status.sql b/web-infrastructure/src/main/resources/db/migration/mysql/V20260723_02__personnel_location_status.sql new file mode 100644 index 0000000..37e92a3 --- /dev/null +++ b/web-infrastructure/src/main/resources/db/migration/mysql/V20260723_02__personnel_location_status.sql @@ -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;