完善定位卡位置与告警类型查询

master
shenzhidan 2026-08-13 18:17:12 +08:00
parent 03fa6f56c7
commit de78e0c26a
8 changed files with 435 additions and 52 deletions

View File

@ -0,0 +1,38 @@
package com.zcloud.personnel.positioning.plan;
import com.jjb.saas.framework.job.Job;
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.TerminalCardSyncExe;
import com.zcloud.personnel.positioning.dto.clientobject.TerminalCardSyncCO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
/** 每五分钟同步一次 FindS 定位卡及其当前绑定人员快照。 */
@Component
@RequiredArgsConstructor
public class FindsTerminalCardSyncJob implements Job {
private final TerminalCardSyncExe terminalCardSyncExe;
@Override
@JobRegister(cron = "0 */5 * * * ?", jobDesc = "FindS定位卡快照同步",
author = "system", triggerStatus = 1)
@XxlJob("com.zcloud.personnel.positioning.plan.FindsTerminalCardSyncJob")
public ReturnT<String> execute(String param) {
try {
TerminalCardSyncCO result = terminalCardSyncExe.syncCards(null);
String message = "FindS定位卡同步完成拉取="
+ result.getPulledCount() + ",新增=" + result.getInsertedCount()
+ ",更新=" + result.getUpdatedCount() + ",跳过=" + result.getSkippedCount()
+ ",绑定=" + result.getBindingCount();
XxlJobHelper.log(message);
return new ReturnT<>(ReturnT.SUCCESS_CODE, message);
} catch (Exception e) {
String message = "FindS定位卡同步失败" + e.getMessage();
XxlJobHelper.log(message);
return new ReturnT<>(ReturnT.FAIL_CODE, message);
}
}
}

View File

@ -397,13 +397,14 @@ public class TerminalCardSyncExe {
return null;
}
PersonSnapshot person = new PersonSnapshot();
person.findsTrackId = longValue(firstPresentAny(child(node, "id"), child(node, "trackId"),
// FindS终端列表的trackNo是人员工号后续用于关联personnel_match.finds_staff_no。
person.findsTrackId = longValue(firstPresentAny(child(node, "trackId"), child(node, "id"),
child(node, "staffId"), child(node, "findsTrackId")));
person.idCardNo = textValue(firstPresentAny(child(node, "idCardNo"), child(node, "identityNo"),
child(node, "certificateNo"), child(node, "cardNo")));
person.staffNo = textValue(firstPresentAny(child(node, "staffNo"), child(node, "code"),
person.staffNo = textValue(firstPresentAny(child(node, "trackNo"), child(node, "staffNo"), child(node, "code"),
child(node, "employeeNo"), child(node, "workNo")));
person.staffName = textValue(firstPresentAny(child(node, "staffName"), child(node, "name"),
person.staffName = textValue(firstPresentAny(child(node, "trackName"), child(node, "staffName"), child(node, "name"),
child(node, "realName"), child(node, "userName")));
person.mobileNo = textValue(firstPresentAny(child(node, "mobileNo"), child(node, "phone"),
child(node, "phoneNo")));

View File

@ -26,7 +26,6 @@ import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
@ -129,20 +128,19 @@ public class AlarmRecordQueryExe {
}
public List<OptionCO> listAlarmTypeOptions() {
return Arrays.asList(
new OptionCO("禁入围栏告警", "CROSS_IN"),
new OptionCO("离开围栏告警", "CROSS_OUT"),
new OptionCO("超时停留告警", "RETENTION"),
new OptionCO("脱岗告警", "LACKED"),
new OptionCO("停留告警", "STAY"),
new OptionCO("聚集告警", "CLUSTER"),
new OptionCO("超员告警", "CROWDED"),
new OptionCO("静止告警", "STATIC"),
new OptionCO("超速告警", "OVER_SPEED"),
new OptionCO("碰撞告警", "CRASH"),
new OptionCO("低电量告警", "LOW_BATTERY"),
new OptionCO("SOS告警", "SOS")
);
SSOUser user = AuthContext.getCurrentUser();
Long corpinfoId = user == null ? null : user.getCompanyId();
// 只返回当前企业告警记录中实际出现过的编码名称使用FindS确定的编码映射。
List<AlarmTypeStatDO> rows = alarmRecordRepository.listBiAlarmType(corpinfoId);
if (rows == null || rows.isEmpty()) {
return Collections.emptyList();
}
return rows.stream()
.filter(Objects::nonNull)
.filter(row -> StringUtils.hasText(row.getAlarmCode()))
.map(row -> new OptionCO(alarmNameOf(row.getAlarmCode()),
row.getAlarmCode()))
.collect(Collectors.toList());
}
private OptionCO mapCompanyOption(CompanyOptionDO dataObject) {
@ -234,40 +232,41 @@ public class AlarmRecordQueryExe {
return null;
}
String code = value.trim();
if ("进入围栏告警".equals(code) || "禁入围栏告警".equals(code)) {
if ("闯入报警".equals(code) || "进入围栏告警".equals(code) || "禁入围栏告警".equals(code)) {
return "CROSS_IN";
}
if ("离开围栏".equals(code) || "离开围栏告警".equals(code) || "区域越界告警".equals(code)) {
if ("离开报警".equals(code) || "离开围栏".equals(code)
|| "离开围栏告警".equals(code) || "区域越界告警".equals(code)) {
return "CROSS_OUT";
}
if ("超时停留告警".equals(code)) {
if ("滞留报警".equals(code) || "超时停留告警".equals(code)) {
return "RETENTION";
}
if ("脱岗告警".equals(code)) {
if ("缺员报警".equals(code) || "脱岗告警".equals(code)) {
return "LACKED";
}
if ("停留告警".equals(code)) {
if ("停留报警".equals(code) || "停留告警".equals(code)) {
return "STAY";
}
if ("聚集告警".equals(code)) {
if ("聚集报警".equals(code) || "聚集告警".equals(code)) {
return "CLUSTER";
}
if ("超员告警".equals(code)) {
if ("超员报警".equals(code) || "超员告警".equals(code)) {
return "CROWDED";
}
if ("静止告警".equals(code)) {
if ("静止报警".equals(code) || "静止告警".equals(code)) {
return "STATIC";
}
if ("超速告警".equals(code)) {
if ("超速报警".equals(code) || "超速告警".equals(code)) {
return "OVER_SPEED";
}
if ("碰撞告警".equals(code)) {
if ("碰撞报警".equals(code) || "碰撞告警".equals(code)) {
return "CRASH";
}
if ("低电量告警".equals(code)) {
if ("低电量报警".equals(code) || "低电量告警".equals(code)) {
return "LOW_BATTERY";
}
if ("SOS告警".equalsIgnoreCase(code)) {
if ("SOS报警".equalsIgnoreCase(code) || "SOS告警".equalsIgnoreCase(code)) {
return "SOS";
}
return code;
@ -275,50 +274,46 @@ public class AlarmRecordQueryExe {
private String alarmNameOf(String alarmCode) {
if ("CROSS_IN".equals(alarmCode)) {
return "禁入围栏告警";
return "闯入报警";
}
if ("CROSS_OUT".equals(alarmCode)) {
return "离开围栏告警";
return "离开警";
}
if ("RETENTION".equals(alarmCode)) {
return "超时停留告警";
return "滞留报警";
}
if ("LACKED".equals(alarmCode)) {
return "脱岗告警";
return "缺员报警";
}
if ("STAY".equals(alarmCode)) {
return "停留警";
return "停留警";
}
if ("CLUSTER".equals(alarmCode)) {
return "聚集警";
return "聚集警";
}
if ("CROWDED".equals(alarmCode)) {
return "超员警";
return "超员警";
}
if ("STATIC".equals(alarmCode)) {
return "静止警";
return "静止警";
}
if ("OVER_SPEED".equals(alarmCode)) {
return "超速警";
return "超速警";
}
if ("CRASH".equals(alarmCode)) {
return "碰撞警";
return "碰撞警";
}
if ("LOW_BATTERY".equals(alarmCode)) {
return "低电量警";
return "低电量警";
}
if ("SOS".equals(alarmCode)) {
return "SOS警";
return "SOS警";
}
return alarmCode;
}
private String alarmTypeNameOf(String alarmCode, String alarmName) {
String name = defaultIfBlank(alarmName, alarmNameOf(alarmCode));
if (name != null && name.contains("-")) {
return name.substring(name.lastIndexOf('-') + 1);
}
return name;
return alarmNameOf(alarmCode);
}
private String defaultIfBlank(String value, String defaultValue) {

View File

@ -0,0 +1,133 @@
package com.zcloud.personnel.positioning.command.query;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.zcloud.personnel.positioning.persistence.dataobject.FenceDO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.io.IOException;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
/** 根据终端坐标匹配本地电子围栏,不依赖 area 表的企业关联数据。 */
@Component
@RequiredArgsConstructor
class FenceLocationMatcher {
private final ObjectMapper objectMapper;
List<FencePolygon> prepare(List<FenceDO> fences) {
if (fences == null || fences.isEmpty()) {
return Collections.emptyList();
}
List<FencePolygon> result = new ArrayList<>(fences.size());
for (FenceDO fence : fences) {
List<BigDecimal[]> points = parsePolygon(fence.getPolygonJson());
if (points.size() >= 3) {
result.add(new FencePolygon(fence, points, polygonArea(points)));
}
}
// 重叠时优先显示范围更精确的小围栏,避免依赖数据库返回顺序。
result.sort(Comparator.comparing(FencePolygon::getArea));
return result;
}
FenceDO match(BigDecimal lon, BigDecimal lat, List<FencePolygon> fences) {
if (lon == null || lat == null || fences == null) {
return null;
}
for (FencePolygon fence : fences) {
if (contains(lon, lat, fence.points)) {
return fence.fence;
}
}
return null;
}
String displayName(FenceDO fence) {
return fence == null ? null : fence.getFenceName();
}
private List<BigDecimal[]> parsePolygon(String polygonJson) {
if (!StringUtils.hasText(polygonJson)) {
return Collections.emptyList();
}
try {
List<BigDecimal[]> points = new ArrayList<>();
collectPoints(objectMapper.readTree(polygonJson), points);
return points;
} catch (IOException ignored) {
return Collections.emptyList();
}
}
private void collectPoints(JsonNode node, List<BigDecimal[]> points) {
if (node == null || node.isNull() || node.isMissingNode()) {
return;
}
if (node.isArray() && node.size() >= 2 && node.get(0).isNumber() && node.get(1).isNumber()) {
points.add(new BigDecimal[]{node.get(0).decimalValue(), node.get(1).decimalValue()});
return;
}
if (node.isContainerNode()) {
node.elements().forEachRemaining(child -> collectPoints(child, points));
}
}
private BigDecimal polygonArea(List<BigDecimal[]> points) {
double area = 0D;
for (int i = 0, j = points.size() - 1; i < points.size(); j = i++) {
area += points.get(j)[0].doubleValue() * points.get(i)[1].doubleValue()
- points.get(i)[0].doubleValue() * points.get(j)[1].doubleValue();
}
return BigDecimal.valueOf(Math.abs(area / 2D));
}
private boolean contains(BigDecimal lon, BigDecimal lat, List<BigDecimal[]> points) {
boolean inside = false;
double x = lon.doubleValue();
double y = lat.doubleValue();
for (int i = 0, j = points.size() - 1; i < points.size(); j = i++) {
double xi = points.get(i)[0].doubleValue();
double yi = points.get(i)[1].doubleValue();
double xj = points.get(j)[0].doubleValue();
double yj = points.get(j)[1].doubleValue();
if (onSegment(x, y, xi, yi, xj, yj)) {
return true;
}
if ((yi > y) != (yj > y) && x < (xj - xi) * (y - yi) / (yj - yi) + xi) {
inside = !inside;
}
}
return inside;
}
private boolean onSegment(double x, double y, double x1, double y1, double x2, double y2) {
double cross = (x - x1) * (y2 - y1) - (y - y1) * (x2 - x1);
if (Math.abs(cross) > 1e-10) {
return false;
}
return x >= Math.min(x1, x2) && x <= Math.max(x1, x2)
&& y >= Math.min(y1, y2) && y <= Math.max(y1, y2);
}
static class FencePolygon {
private final FenceDO fence;
private final List<BigDecimal[]> points;
private final BigDecimal area;
private FencePolygon(FenceDO fence, List<BigDecimal[]> points, BigDecimal area) {
this.fence = fence;
this.points = points;
this.area = area;
}
private BigDecimal getArea() {
return area;
}
}
}

View File

@ -0,0 +1,191 @@
package com.zcloud.personnel.positioning.command.query;
import com.alibaba.cola.exception.BizException;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.fasterxml.jackson.databind.JsonNode;
import com.zcloud.personnel.positioning.dto.clientobject.TerminalCardCO;
import com.zcloud.personnel.positioning.integration.finds.FindsOpenApiClient;
import com.zcloud.personnel.positioning.persistence.dataobject.FenceDO;
import com.zcloud.personnel.positioning.persistence.repository.FenceRepository;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** 使用 FindS 最新坐标补全定位卡本次查询结果中的定位时间和围栏名称。 */
@Component
@RequiredArgsConstructor
class TerminalCardFenceLocationEnricher {
private static final Logger LOGGER = LoggerFactory.getLogger(TerminalCardFenceLocationEnricher.class);
private static final String POINT_LOCATE_API = "finds.point.locate";
private static final int BATCH_SIZE = 100;
private final FindsOpenApiClient findsOpenApiClient;
private final FenceRepository fenceRepository;
private final FenceLocationMatcher fenceLocationMatcher;
void enrich(List<TerminalCardCO> cards) {
Set<String> terminalNos = terminalNos(cards);
if (terminalNos.isEmpty()) {
return;
}
final Map<String, JsonNode> locations;
try {
locations = locate(terminalNos);
} catch (RuntimeException e) {
LOGGER.warn("从FindS补全定位卡围栏位置失败{}", e.getMessage());
return;
}
List<FenceLocationMatcher.FencePolygon> fences = fenceLocationMatcher.prepare(loadActiveFences());
for (TerminalCardCO card : cards) {
if (card == null || !StringUtils.hasText(card.getTerminalNo())) {
continue;
}
JsonNode location = locations.get(card.getTerminalNo().trim());
LocalDateTime time = locationTime(location);
if (time != null) {
card.setLastLocationTime(time);
}
BigDecimal lon = decimal(first(location, "lon", "lng", "longitude"));
BigDecimal lat = decimal(first(location, "lat", "latitude"));
if (lon == null || lat == null) {
continue;
}
FenceDO fence = fenceLocationMatcher.match(lon, lat, fences);
card.setLastLocationName(fenceLocationMatcher.displayName(fence));
}
}
private List<FenceDO> loadActiveFences() {
LocalDateTime now = LocalDateTime.now();
QueryWrapper<FenceDO> wrapper = new QueryWrapper<>();
wrapper.and(value -> value.isNull("delete_enum").or().eq("delete_enum", "FALSE"));
wrapper.eq("status", "ENABLED");
wrapper.in("fence_scope", "ELECTRONIC", "WORK_AREA");
wrapper.and(value -> value.isNull("effect_start").or().le("effect_start", now));
wrapper.and(value -> value.isNull("effect_end").or().ge("effect_end", now));
wrapper.isNotNull("polygon_json");
return fenceRepository.list(wrapper);
}
private Set<String> terminalNos(List<TerminalCardCO> cards) {
if (cards == null || cards.isEmpty()) {
return Collections.emptySet();
}
Set<String> result = new LinkedHashSet<>();
for (TerminalCardCO card : cards) {
if (card != null && StringUtils.hasText(card.getTerminalNo())) {
result.add(card.getTerminalNo().trim());
}
}
return result;
}
private Map<String, JsonNode> locate(Set<String> terminalNos) {
Map<String, JsonNode> result = new LinkedHashMap<>();
List<String> batch = new ArrayList<>(BATCH_SIZE);
for (String terminalNo : terminalNos) {
batch.add(terminalNo);
if (batch.size() == BATCH_SIZE) {
locateBatch(batch, result);
batch.clear();
}
}
if (!batch.isEmpty()) {
locateBatch(batch, result);
}
return result;
}
private void locateBatch(List<String> terminalNos, Map<String, JsonNode> result) {
JsonNode root = findsOpenApiClient.postFailFast(POINT_LOCATE_API,
Collections.singletonMap("terminalNoList", new ArrayList<>(terminalNos)));
int code = root == null ? -1 : root.path("code").asInt(-1);
if (code != 0 && code != 200) {
throw new BizException("FindS定位查询失败响应码=" + code);
}
JsonNode rows = rows(root.path("data"));
if (rows == null) {
return;
}
for (JsonNode row : rows) {
String terminalNo = text(first(row, "terminalNo"));
if (!StringUtils.hasText(terminalNo) && terminalNos.size() == 1) {
terminalNo = terminalNos.get(0);
}
if (StringUtils.hasText(terminalNo)) {
result.put(terminalNo.trim(), row);
}
}
}
private JsonNode rows(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 JsonNode first(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.isValueNode() ? null : node.asText();
}
private BigDecimal decimal(JsonNode node) {
try {
return node == null || !node.isValueNode() ? null : new BigDecimal(node.asText());
} catch (NumberFormatException ignored) {
return null;
}
}
private LocalDateTime locationTime(JsonNode location) {
JsonNode node = first(location, "gt", "time", "locateTime", "locationTime", "timestamp");
if (node == null || !node.isValueNode()) {
return null;
}
try {
long epoch = Long.parseLong(node.asText());
if (epoch < 100000000000L) {
epoch *= 1000L;
}
return LocalDateTime.ofInstant(Instant.ofEpochMilli(epoch), ZoneId.systemDefault());
} catch (NumberFormatException ignored) {
return null;
}
}
}

View File

@ -31,6 +31,7 @@ import java.util.Map;
public class TerminalCardQueryExe {
private final TerminalRepository terminalRepository;
private final TerminalCardCoConvertor terminalCardCoConvertor;
private final TerminalCardFenceLocationEnricher fenceLocationEnricher;
public PageResponse<TerminalCardCompanyCO> listCompanyPage(TerminalCardCompanyPageQry qry) {
TerminalCardCompanyPageQry query = qry == null ? new TerminalCardCompanyPageQry() : qry;
@ -54,7 +55,9 @@ public class TerminalCardQueryExe {
"eqCorpinfoName", "likeCorpinfoName", "orgCode", "orgName", "deviceStatus");
applyCurrentCompany(params, "eqCorpinfoId", "eqCorpinfoName", "likeCorpinfoName");
PageResponse<TerminalCardDetailDO> page = terminalRepository.listCards(params);
return PageResponse.of(terminalCardCoConvertor.convertCards(page.getData()),
List<TerminalCardCO> rows = terminalCardCoConvertor.convertCards(page.getData());
fenceLocationEnricher.enrich(rows);
return PageResponse.of(rows,
page.getTotalCount(), page.getPageSize(), page.getPageIndex());
}
@ -65,7 +68,9 @@ public class TerminalCardQueryExe {
Map<String, Object> params = new HashMap<>();
params.put("id", id);
TerminalCardDetailDO dataObject = terminalRepository.getCardDetail(params);
return terminalCardCoConvertor.convertCard(dataObject);
TerminalCardCO card = terminalCardCoConvertor.convertCard(dataObject);
fenceLocationEnricher.enrich(card == null ? null : java.util.Collections.singletonList(card));
return card;
}
public PageResponse<TerminalCardPersonOptionCO> listPersonOptions(TerminalCardPersonOptionQry qry) {

View File

@ -85,6 +85,8 @@
<include refid="mappedCompanyJoin"/>
WHERE (ar.delete_enum IS NULL OR ar.delete_enum = 'FALSE')
AND <include refid="mappedCompanyId"/> IS NOT NULL
AND ar.alarm_code IS NOT NULL
AND TRIM(ar.alarm_code) != ''
<if test="corpinfoId != null and corpinfoId != ''">
AND <include refid="mappedCompanyId"/> = CAST(#{corpinfoId} AS CHAR)
</if>

View File

@ -61,7 +61,7 @@
t.org_code, t.org_name, t.enable, t.device_status,
COALESCE(tb.id_card_no, t.bind_id_card_no) AS active_id_card_no,
COALESCE(tb.staff_no_snapshot, u.username) AS staff_no,
COALESCE(u.name, tb.staff_name_snapshot) AS staff_name,
COALESCE(mu.name, u.name, tb.staff_name_snapshot) AS staff_name,
u.phone AS staff_phone, tb.bind_status, t.finds_terminal_id,
t.last_location_time, t.last_location_name, t.last_sync_time,
t.sync_status, t.remarks
@ -81,6 +81,21 @@
LEFT JOIN user_scope_v u
ON u.user_id_card = COALESCE(tb.id_card_no, t.bind_id_card_no)
AND (u.delete_enum IS NULL OR u.delete_enum = 'FALSE')
<!-- FindS终端的trackNo对应personnel_match.finds_staff_no优先展示已匹配的本平台人员姓名。 -->
LEFT JOIN personnel_match pm ON pm.id = (
SELECT pm2.id
FROM personnel_match pm2
WHERE pm2.source_config_code = 'FINDS'
AND pm2.finds_staff_no = tb.staff_no_snapshot
AND pm2.match_status = 'MATCHED'
AND pm2.local_user_id IS NOT NULL
AND (pm2.delete_enum IS NULL OR pm2.delete_enum = 'FALSE')
ORDER BY pm2.last_sync_time DESC, pm2.id DESC
LIMIT 1
)
LEFT JOIN user_scope_v mu
ON mu.id = pm.local_user_id
AND (mu.delete_enum IS NULL OR mu.delete_enum = 'FALSE')
<include refid="mappedCompanyJoins"/>
WHERE (t.delete_enum IS NULL OR t.delete_enum = 'FALSE')
AND t.device_type = 'CARD'
@ -92,11 +107,14 @@
AND t.terminal_no LIKE CONCAT('%', #{params.terminalNo}, '%')
</if>
<if test="params.staffName != null and params.staffName != ''">
AND (u.name LIKE CONCAT('%', #{params.staffName}, '%')
AND (mu.name LIKE CONCAT('%', #{params.staffName}, '%')
OR u.name LIKE CONCAT('%', #{params.staffName}, '%')
OR tb.staff_name_snapshot LIKE CONCAT('%', #{params.staffName}, '%'))
</if>
<if test="params.staffNo != null and params.staffNo != ''">
AND (u.username = #{params.staffNo} OR tb.staff_no_snapshot = #{params.staffNo})
AND (mu.username = #{params.staffNo}
OR u.username = #{params.staffNo}
OR tb.staff_no_snapshot = #{params.staffNo})
</if>
<if test="params.idCardNo != null and params.idCardNo != ''">
AND COALESCE(tb.id_card_no, t.bind_id_card_no) = #{params.idCardNo}