- Added geocoding functionality:

- Implemented address verification and geolocation in `CreateNewNode`.
  - Integrated `GeoApiService` with error handling using `GeocodingException`.
  - Updated `NodeController` for geocoding and user node creation.
  - Refactored related components and store for enhanced geolocation support.
This commit is contained in:
Jan 2025-10-19 12:20:28 +02:00
parent d7bc62c713
commit f657ff2c80
9 changed files with 250 additions and 39 deletions

View file

@ -99,8 +99,8 @@ function handleErrorResponse(data, requestingStore, request) {
error.errorObj = errorObj; error.errorObj = errorObj;
if (request.expectedException === null || Array.isArray(request.expectResponse) && !request.expectedException.includes(data.error.title) || (typeof request.expectedException === 'string' && !data.error.title !== request.expectedException)) { if (request.expectedException === null || (Array.isArray(request.expectResponse) && !request.expectedException.includes(data.error.title)) || (typeof request.expectedException === 'string' && data.error.title !== request.expectedException)) {
logger.error(errorObj); logger.error(errorObj, request.expectedException);
const errorStore = useErrorStore(); const errorStore = useErrorStore();
void errorStore.addError(errorObj, {store: requestingStore, request: request}); void errorStore.addError(errorObj, {store: requestingStore, request: request});
} }

View file

@ -1,24 +1,36 @@
<template> <template>
<div class="create-new-node-container"> <div class="create-new-node-container">
<h3 class="sub-header">Create new supplier</h3> <h3 class="sub-header">Create new supplier</h3>
<form @submit.prevent="send"> <form @submit.prevent>
<div class="create-new-node-form-container"> <div class="create-new-node-form-container">
<div class="input-field-caption">Name:</div> <div class="input-field-caption">Name:</div>
<div><div class="text-container"><input class="input-field" v-model="nodeName"/></div></div> <div>
<div class="text-container"><input class="input-field" v-model="nodeName"/></div>
</div>
<div></div> <div></div>
<div class="input-field-caption">Address:</div> <div class="input-field-caption">Address:</div>
<div><div class="text-container"><input class="input-field" v-model="nodeAddress"/></div></div>
<div> <div>
<basic-button icon="SealCheck">Verify address</basic-button> <div class="text-container"><input class="input-field" v-model="nodeAddress" @input="checkChange"/></div>
</div> </div>
<div class="input-field-caption">Coordinates:</div> <div>
<div>{{ nodeCoordinates }}</div> <basic-button icon="SealCheck" @click.prevent="verifyAddress" :disabled="addressVerified" @submit.prevent>Check address</basic-button>
<Toast ref="toast"/>
</div>
<div class="input-field-caption" v-if="addressVerified">Country:</div>
<div class="country-field" v-if="addressVerified">
<flag :iso="nodeIso"/>
{{ nodeCountry }}
</div>
<div v-if="addressVerified"></div>
<div class="input-field-caption" v-if="nodeCoordinates && addressVerified">Coordinates:</div>
<div class="coordinate-field" v-if="nodeCoordinates && addressVerified">{{ coordinatesDMS }}</div>
</div> </div>
<div class="create-new-node-map"> <div class="supplier-map" v-if="nodeCoordinates && addressVerified">
<img width="300px" src="https://www.galerie-braunbehrens.de/wp-content/uploads/2020/06/placeholder-google-maps.jpg" alt="map"> <open-street-map-embed :coordinates="nodeCoordinates" :zoom="15" width="100%" height="300px"
custom-filter="grayscale(0.8) sepia(0.5) hue-rotate(180deg) saturate(0.5) brightness(1.0)"></open-street-map-embed>
</div> </div>
<div class="create-new-node-footer"> <div class="create-new-node-footer">
<basic-button variant="primary" :show-icon="false" :disabled="unverified">OK</basic-button> <basic-button @click.prevent="send" variant="primary" :show-icon="false" :disabled="unverified">Create</basic-button>
<basic-button @click.prevent="cancel" variant="secondary" :show-icon="false">Cancel</basic-button> <basic-button @click.prevent="cancel" variant="secondary" :show-icon="false">Cancel</basic-button>
</div> </div>
</form> </form>
@ -28,27 +40,105 @@
<script> <script>
import BasicButton from "@/components/UI/BasicButton.vue"; import BasicButton from "@/components/UI/BasicButton.vue";
import {mapStores} from "pinia";
import {useNodeStore} from "@/store/node.js";
import OpenStreetMapEmbed from "@/components/UI/OpenStreetMapEmbed.vue";
import Toast from "@/components/UI/Toast.vue";
import Flag from "@/components/UI/Flag.vue";
export default { export default {
name: "CreateNewNode", name: "CreateNewNode",
components: {BasicButton}, components: {Flag, Toast, OpenStreetMapEmbed, BasicButton},
emits: ['created', 'close'],
data() { data() {
return { return {
nodeName: "", nodeName: "",
nodeAddress: "", nodeAddress: "",
nodeCoordinates: "", nodeCoordinates: null,
addressVerified: false nodeCountry: null,
nodeIso: null,
addressVerified: false,
verifiedAddress: null,
node: null
} }
}, },
computed: { computed: {
coordinatesDMS() {
if (this.nodeCoordinates != null && typeof this.nodeCoordinates === 'object') {
return `${this.convertToDMS(this.nodeCoordinates?.latitude, 'lat')}, ${this.convertToDMS(this.nodeCoordinates?.longitude, 'lng')}`;
}
return '';
},
...mapStores(useNodeStore),
unverified() { unverified() {
return !this.addressVerified; return !this.addressVerified || !this.nodeName;
} },
}, },
methods: { methods: {
send() { convertToDMS(coordinate, type) {
console.log("Sending...");
if (!coordinate)
return '';
let direction;
if (type === 'lat') {
direction = coordinate >= 0 ? 'N' : 'S';
} else {
direction = coordinate >= 0 ? 'E' : 'W';
}
// Arbeite mit Absolutwert
const abs = Math.abs(coordinate);
// Grad (ganzzahliger Teil)
const degrees = Math.floor(abs);
// Minuten
const minutesFloat = (abs - degrees) * 60;
const minutes = minutesFloat.toFixed(4);
return `${degrees}° ${minutes}' ${direction}`;
},
checkChange() {
this.addressVerified = this.nodeAddress === this.verifiedAddress;
},
async verifyAddress() {
const {node: node, error: error} = await this.nodeStore.locate(this.nodeAddress);
this.nodeCoordinates = null;
this.nodeIso = null;
this.nodeCountry = null;
this.addressVerified = false;
this.verifiedAddress = null;
if (error !== null) {
this.$refs.toast.addToast({
icon: 'warning',
message: error.message,
title: "Cannot locate address.",
variant: 'exception',
duration: 8000
})
} else if (node) {
this.nodeCoordinates = node.location;
this.nodeAddress = node.address;
this.nodeIso = node.country.iso_code;
this.nodeCountry = node.country.name;
this.addressVerified = true;
this.verifiedAddress = node.address;
this.node = node;
} else {
}
},
async send() {
const node = await this.nodeStore.addNode(this.nodeName, this.nodeAddress, this.nodeCoordinates, this.node.country);
this.$emit("created", node);
}, },
cancel() { cancel() {
this.$emit("close"); this.$emit("close");
@ -64,6 +154,14 @@ export default {
justify-content: center; justify-content: center;
} }
.supplier-map {
display: flex;
justify-content: center;
flex: 1;
padding: 1.6rem;
}
.sub-header { .sub-header {
font-weight: normal; font-weight: normal;
font-size: 1.4rem; font-size: 1.4rem;
@ -71,7 +169,7 @@ export default {
margin-bottom: 1.6rem; margin-bottom: 1.6rem;
} }
.input-field-caption{ .input-field-caption {
font-size: 1.2rem; font-size: 1.2rem;
font-weight: 500; font-weight: 500;
align-self: center; align-self: center;
@ -79,6 +177,30 @@ export default {
color: #001D33 color: #001D33
} }
.coordinate-field {
border: none;
outline: none;
background: none;
resize: none;
font-family: inherit;
font-size: 1.4rem;
color: #6b7280;
}
.country-field {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.8rem;
border: none;
outline: none;
background: none;
resize: none;
font-family: inherit;
font-size: 1.4rem;
color: #6b7280;
}
.input-field { .input-field {
border: none; border: none;
outline: none; outline: none;
@ -87,6 +209,8 @@ export default {
font-family: inherit; font-family: inherit;
font-size: 1.4rem; font-size: 1.4rem;
color: #002F54; color: #002F54;
width: 100%;
min-width: 5rem;
} }
.text-container { .text-container {

View file

@ -49,7 +49,7 @@
</ul> </ul>
<modal :close-on-backdrop="false" :state="newSupplierModalState" @close="closeModal('newSupplier')"> <modal :close-on-backdrop="false" :state="newSupplierModalState" @close="closeModal('newSupplier')">
<create-new-node @close="closeModal('newSupplier')"></create-new-node> <create-new-node @created="addCreatedNode" @close="closeModal('newSupplier')"></create-new-node>
</modal> </modal>
@ -153,6 +153,10 @@ export default {
this.assistantStore.getMaterialsAndSuppliers(this.partNumberField); this.assistantStore.getMaterialsAndSuppliers(this.partNumberField);
this.partNumberField = ''; this.partNumberField = '';
}, },
addCreatedNode(supplier) {
this.closeModal('newSupplier')
this.assistantStore.addSupplier(supplier);
}
}, },
created() { created() {
this.nodeStore.loadNodes(); this.nodeStore.loadNodes();

View file

@ -2,6 +2,7 @@ import {defineStore} from 'pinia'
import {config} from '@/config' import {config} from '@/config'
import {useErrorStore} from "@/store/error.js"; import {useErrorStore} from "@/store/error.js";
import performRequest from "@/backend.js"; import performRequest from "@/backend.js";
import logger from "@/logger.js";
export const useNodeStore = defineStore('node', { export const useNodeStore = defineStore('node', {
@ -29,6 +30,28 @@ export const useNodeStore = defineStore('node', {
this.query.type = 'list'; this.query.type = 'list';
await this.loadNodes(); await this.loadNodes();
}, },
async locate(address) {
const params = new URLSearchParams();
let error = null;
params.append('address', address);
const data = await performRequest(this, "GET", `${config.backendUrl}/nodes/locate/${params.size === 0 ? '' : '?'}${params.toString()}`, null, true, 'Geocoding error').catch(e => {
logger.log("geo locate exception", e.errorObj);
error = e.errorObj;
});
return {node: data?.data, error: error};
},
async addNode(name, address, location, country ) {
const body = {
name: name,
address: address,
location: location,
country: country
}
const data = await performRequest(this, "PUT", `${config.backendUrl}/nodes/`, body, true);
return data.data;
},
async loadNodes() { async loadNodes() {
const params = new URLSearchParams(); const params = new URLSearchParams();
@ -42,10 +65,10 @@ export const useNodeStore = defineStore('node', {
if (this.query.includeUserNode) if (this.query.includeUserNode)
params.append('include_user_node', this.query.includeUserNode); params.append('include_user_node', this.query.includeUserNode);
if(this.query?.page) if (this.query?.page)
params.append('page', this.query.page); params.append('page', this.query.page);
if(this.query?.pageSize) if (this.query?.pageSize)
params.append('limit', this.query.pageSize); params.append('limit', this.query.pageSize);

View file

@ -4,6 +4,7 @@ import de.avatic.lcc.dto.error.ErrorDTO;
import de.avatic.lcc.dto.error.ErrorResponseDTO; import de.avatic.lcc.dto.error.ErrorResponseDTO;
import de.avatic.lcc.util.exception.base.BadRequestException; import de.avatic.lcc.util.exception.base.BadRequestException;
import de.avatic.lcc.util.exception.base.ForbiddenException; import de.avatic.lcc.util.exception.base.ForbiddenException;
import de.avatic.lcc.util.exception.internalerror.GeocodingException;
import de.avatic.lcc.util.exception.internalerror.PremiseValidationError; import de.avatic.lcc.util.exception.internalerror.PremiseValidationError;
import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException; import jakarta.validation.ConstraintViolationException;
@ -14,7 +15,6 @@ import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import java.util.Arrays; import java.util.Arrays;
@ -130,7 +130,7 @@ public class GlobalExceptionHandler {
} }
@ExceptionHandler(PremiseValidationError.class) @ExceptionHandler(PremiseValidationError.class)
public ResponseEntity<ErrorResponseDTO> handleGenericException(PremiseValidationError exception) { public ResponseEntity<ErrorResponseDTO> handlePremiseValidationException(PremiseValidationError exception) {
ErrorDTO error = new ErrorDTO( ErrorDTO error = new ErrorDTO(
exception.getClass().getName(), exception.getClass().getName(),
"Premiss validation error", "Premiss validation error",
@ -141,8 +141,20 @@ public class GlobalExceptionHandler {
return new ResponseEntity<>(new ErrorResponseDTO(error), HttpStatus.INTERNAL_SERVER_ERROR); return new ResponseEntity<>(new ErrorResponseDTO(error), HttpStatus.INTERNAL_SERVER_ERROR);
} }
@ExceptionHandler(GeocodingException.class)
public ResponseEntity<ErrorResponseDTO> handleGeocodingException(GeocodingException exception) {
ErrorDTO error = new ErrorDTO(
exception.getClass().getName(),
"Geocoding error",
exception.getMessage(),
Arrays.asList(exception.getStackTrace())
);
return new ResponseEntity<>(new ErrorResponseDTO(error), HttpStatus.INTERNAL_SERVER_ERROR);
}
@ExceptionHandler(Exception.class) @ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponseDTO> handleGenericException(Exception exception) { public ResponseEntity<ErrorResponseDTO> handlePremiseValidationException(Exception exception) {
ErrorDTO error = new ErrorDTO( ErrorDTO error = new ErrorDTO(
exception.getClass().getName(), exception.getClass().getName(),
"Internal Server Error", "Internal Server Error",

View file

@ -55,35 +55,34 @@ public class NodeController {
return ResponseEntity.ok(nodeService.searchNode(filter, limit, nodeType, includeUserNode)); return ResponseEntity.ok(nodeService.searchNode(filter, limit, nodeType, includeUserNode));
} }
@GetMapping("/{id}") @GetMapping({"/{id}","/{id}/"})
@PreAuthorize("hasAnyRole('SUPER', 'FREIGHT', 'PACKAGING')") @PreAuthorize("hasAnyRole('SUPER', 'FREIGHT', 'PACKAGING')")
public ResponseEntity<NodeDetailDTO> getNode(@PathVariable Integer id) { public ResponseEntity<NodeDetailDTO> getNode(@PathVariable Integer id) {
return ResponseEntity.ok(nodeService.getNode(id)); return ResponseEntity.ok(nodeService.getNode(id));
} }
@DeleteMapping("/{id}") @DeleteMapping({"/{id}","/{id}/"})
@PreAuthorize("hasRole('SUPER')") @PreAuthorize("hasRole('SUPER')")
public ResponseEntity<Integer> deleteNode(@PathVariable Integer id) { public ResponseEntity<Integer> deleteNode(@PathVariable Integer id) {
return ResponseEntity.ok(nodeService.deleteNode(id)); return ResponseEntity.ok(nodeService.deleteNode(id));
} }
@PutMapping("/{id}") @PutMapping({"/{id}","/{id}/"})
@PreAuthorize("hasRole('SUPER')") @PreAuthorize("hasRole('SUPER')")
public ResponseEntity<Integer> updateNode(@PathVariable Integer id, @RequestBody NodeUpdateDTO node) { public ResponseEntity<Integer> updateNode(@PathVariable Integer id, @RequestBody NodeUpdateDTO node) {
Check.equals(id, node.getId()); Check.equals(id, node.getId());
return ResponseEntity.ok(nodeService.updateNode(node)); return ResponseEntity.ok(nodeService.updateNode(node));
} }
@GetMapping("/locate") @GetMapping({"/locate", "/locate/"})
@PreAuthorize("hasAnyRole('SUPER', 'FREIGHT', 'PACKAGING')") @PreAuthorize("hasAnyRole('SUPER', 'FREIGHT', 'PACKAGING', 'CALCULATION')")
public ResponseEntity<Location> locateNode(@RequestParam String address) { public ResponseEntity<NodeDTO> locateNode(@RequestParam String address) {
return ResponseEntity.ok(geoApiService.locate(address)); return ResponseEntity.ok(geoApiService.locateNode(address));
} }
@PutMapping("/") @PutMapping({"","/"})
@PreAuthorize("hasAnyRole('SUPER', 'CALCULATION')") @PreAuthorize("hasAnyRole('SUPER', 'CALCULATION')")
public ResponseEntity<Void> addUserNode(@RequestBody AddUserNodeDTO node) { public ResponseEntity<NodeDTO> addUserNode(@RequestBody AddUserNodeDTO node) {
userNodeService.addUserNode(node); return ResponseEntity.ok(userNodeService.addUserNode(node));
return ResponseEntity.ok().build();
} }
} }

View file

@ -1,9 +1,16 @@
package de.avatic.lcc.service.api; package de.avatic.lcc.service.api;
import de.avatic.lcc.dto.generic.NodeDTO;
import de.avatic.lcc.dto.generic.NodeType;
import de.avatic.lcc.model.azuremaps.geocoding.Feature;
import de.avatic.lcc.model.azuremaps.geocoding.GeocodingResponse; import de.avatic.lcc.model.azuremaps.geocoding.GeocodingResponse;
import de.avatic.lcc.model.azuremaps.geocoding.GeocodingResult; import de.avatic.lcc.model.azuremaps.geocoding.GeocodingResult;
import de.avatic.lcc.model.azuremaps.geocoding.Feature; import de.avatic.lcc.model.country.IsoCode;
import de.avatic.lcc.model.nodes.Location; import de.avatic.lcc.model.nodes.Location;
import de.avatic.lcc.repositories.country.CountryRepository;
import de.avatic.lcc.service.transformer.generic.CountryTransformer;
import de.avatic.lcc.service.transformer.generic.LocationTransformer;
import de.avatic.lcc.util.exception.internalerror.GeocodingException;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@ -12,6 +19,7 @@ import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder; import org.springframework.web.util.UriComponentsBuilder;
import java.net.URI; import java.net.URI;
import java.util.List;
@Service @Service
public class GeoApiService { public class GeoApiService {
@ -21,11 +29,17 @@ public class GeoApiService {
private final RestTemplate restTemplate; private final RestTemplate restTemplate;
private final String subscriptionKey; private final String subscriptionKey;
private final LocationTransformer locationTransformer;
private final CountryRepository countryRepository;
private final CountryTransformer countryTransformer;
public GeoApiService(RestTemplate restTemplate, public GeoApiService(RestTemplate restTemplate,
@Value("${azure.maps.subscription.key}") String subscriptionKey) { @Value("${azure.maps.subscription.key}") String subscriptionKey, LocationTransformer locationTransformer, CountryRepository countryRepository, CountryTransformer countryTransformer) {
this.restTemplate = restTemplate; this.restTemplate = restTemplate;
this.subscriptionKey = subscriptionKey; this.subscriptionKey = subscriptionKey;
this.locationTransformer = locationTransformer;
this.countryRepository = countryRepository;
this.countryTransformer = countryTransformer;
} }
public GeocodingResult geocode(String address) { public GeocodingResult geocode(String address) {
@ -57,7 +71,7 @@ public class GeoApiService {
} catch (Exception e) { } catch (Exception e) {
logger.error("Failed to geocode address: {}", address, e); logger.error("Failed to geocode address: {}", address, e);
throw new RuntimeException("Geocoding failed for: " + address, e); throw new GeocodingException();
} }
} }
@ -65,4 +79,22 @@ public class GeoApiService {
GeocodingResult result = geocode(address); GeocodingResult result = geocode(address);
return result != null ? result.getLocation() : null; return result != null ? result.getLocation() : null;
} }
public NodeDTO locateNode(String address) {
NodeDTO node = new NodeDTO();
GeocodingResult result = geocode(address);
if (result == null || result.getAddress() == null || result.getLocation() == null) throw new GeocodingException();
node.setUserNode(true);
node.setDeprecated(false);
node.setTypes(List.of(NodeType.SOURCE));
node.setAddress(result.getAddress().getFormattedAddress());
node.setCountry(countryTransformer.toCountryDTO(countryRepository.getByIsoCode(IsoCode.valueOf(result.getAddress().getCountryRegion().getIso())).orElseThrow()));
node.setLocation(locationTransformer.toLocationDTO(result.getLocation()));
return node;
}
} }

View file

@ -1,6 +1,7 @@
package de.avatic.lcc.service.transformer.generic; package de.avatic.lcc.service.transformer.generic;
import de.avatic.lcc.dto.generic.LocationDTO; import de.avatic.lcc.dto.generic.LocationDTO;
import de.avatic.lcc.model.nodes.Location;
import de.avatic.lcc.model.nodes.Node; import de.avatic.lcc.model.nodes.Node;
import de.avatic.lcc.model.premises.route.RouteNode; import de.avatic.lcc.model.premises.route.RouteNode;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -11,6 +12,10 @@ public class LocationTransformer {
return new LocationDTO(entity.getGeoLat().doubleValue(), entity.getGeoLng().doubleValue()); return new LocationDTO(entity.getGeoLat().doubleValue(), entity.getGeoLng().doubleValue());
} }
public LocationDTO toLocationDTO(Location entity) {
return new LocationDTO(entity.getLatitude(), entity.getLongitude());
}
public LocationDTO toLocationDTO(RouteNode entity) { public LocationDTO toLocationDTO(RouteNode entity) {
return new LocationDTO(entity.getGeoLat().doubleValue(), entity.getGeoLng().doubleValue()); return new LocationDTO(entity.getGeoLat().doubleValue(), entity.getGeoLng().doubleValue());
} }

View file

@ -0,0 +1,12 @@
package de.avatic.lcc.util.exception.internalerror;
import de.avatic.lcc.util.exception.base.InternalErrorException;
public class GeocodingException extends InternalErrorException {
public GeocodingException() {
super("Geocoding failed", "Unable to locate the entered address");
}
}