- 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:
parent
d7bc62c713
commit
f657ff2c80
9 changed files with 250 additions and 39 deletions
|
|
@ -99,8 +99,8 @@ function handleErrorResponse(data, requestingStore, request) {
|
|||
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)) {
|
||||
logger.error(errorObj);
|
||||
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, request.expectedException);
|
||||
const errorStore = useErrorStore();
|
||||
void errorStore.addError(errorObj, {store: requestingStore, request: request});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,36 @@
|
|||
<template>
|
||||
<div class="create-new-node-container">
|
||||
<h3 class="sub-header">Create new supplier</h3>
|
||||
<form @submit.prevent="send">
|
||||
<form @submit.prevent>
|
||||
<div class="create-new-node-form-container">
|
||||
<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 class="input-field-caption">Address:</div>
|
||||
<div><div class="text-container"><input class="input-field" v-model="nodeAddress"/></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 class="input-field-caption">Coordinates:</div>
|
||||
<div>{{ nodeCoordinates }}</div>
|
||||
<div>
|
||||
<basic-button icon="SealCheck" @click.prevent="verifyAddress" :disabled="addressVerified" @submit.prevent>Check address</basic-button>
|
||||
<Toast ref="toast"/>
|
||||
</div>
|
||||
<div class="create-new-node-map">
|
||||
<img width="300px" src="https://www.galerie-braunbehrens.de/wp-content/uploads/2020/06/placeholder-google-maps.jpg" alt="map">
|
||||
<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 class="supplier-map" v-if="nodeCoordinates && addressVerified">
|
||||
<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 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>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -28,27 +40,105 @@
|
|||
<script>
|
||||
|
||||
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 {
|
||||
name: "CreateNewNode",
|
||||
components: {BasicButton},
|
||||
components: {Flag, Toast, OpenStreetMapEmbed, BasicButton},
|
||||
emits: ['created', 'close'],
|
||||
data() {
|
||||
return {
|
||||
nodeName: "",
|
||||
nodeAddress: "",
|
||||
nodeCoordinates: "",
|
||||
addressVerified: false
|
||||
nodeCoordinates: null,
|
||||
nodeCountry: null,
|
||||
nodeIso: null,
|
||||
addressVerified: false,
|
||||
verifiedAddress: null,
|
||||
node: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
unverified() {
|
||||
return !this.addressVerified;
|
||||
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() {
|
||||
return !this.addressVerified || !this.nodeName;
|
||||
},
|
||||
|
||||
},
|
||||
methods: {
|
||||
send() {
|
||||
console.log("Sending...");
|
||||
convertToDMS(coordinate, type) {
|
||||
|
||||
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() {
|
||||
this.$emit("close");
|
||||
|
|
@ -64,6 +154,14 @@ export default {
|
|||
justify-content: center;
|
||||
}
|
||||
|
||||
.supplier-map {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
padding: 1.6rem;
|
||||
}
|
||||
|
||||
|
||||
.sub-header {
|
||||
font-weight: normal;
|
||||
font-size: 1.4rem;
|
||||
|
|
@ -79,6 +177,30 @@ export default {
|
|||
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 {
|
||||
border: none;
|
||||
outline: none;
|
||||
|
|
@ -87,6 +209,8 @@ export default {
|
|||
font-family: inherit;
|
||||
font-size: 1.4rem;
|
||||
color: #002F54;
|
||||
width: 100%;
|
||||
min-width: 5rem;
|
||||
}
|
||||
|
||||
.text-container {
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@
|
|||
</ul>
|
||||
|
||||
<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>
|
||||
|
||||
|
||||
|
|
@ -153,6 +153,10 @@ export default {
|
|||
this.assistantStore.getMaterialsAndSuppliers(this.partNumberField);
|
||||
this.partNumberField = '';
|
||||
},
|
||||
addCreatedNode(supplier) {
|
||||
this.closeModal('newSupplier')
|
||||
this.assistantStore.addSupplier(supplier);
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.nodeStore.loadNodes();
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import {defineStore} from 'pinia'
|
|||
import {config} from '@/config'
|
||||
import {useErrorStore} from "@/store/error.js";
|
||||
import performRequest from "@/backend.js";
|
||||
import logger from "@/logger.js";
|
||||
|
||||
|
||||
export const useNodeStore = defineStore('node', {
|
||||
|
|
@ -29,6 +30,28 @@ export const useNodeStore = defineStore('node', {
|
|||
this.query.type = 'list';
|
||||
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() {
|
||||
|
||||
const params = new URLSearchParams();
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import de.avatic.lcc.dto.error.ErrorDTO;
|
|||
import de.avatic.lcc.dto.error.ErrorResponseDTO;
|
||||
import de.avatic.lcc.util.exception.base.BadRequestException;
|
||||
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 jakarta.validation.ConstraintViolation;
|
||||
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.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.method.annotation.HandlerMethodValidationException;
|
||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
|
@ -130,7 +130,7 @@ public class GlobalExceptionHandler {
|
|||
}
|
||||
|
||||
@ExceptionHandler(PremiseValidationError.class)
|
||||
public ResponseEntity<ErrorResponseDTO> handleGenericException(PremiseValidationError exception) {
|
||||
public ResponseEntity<ErrorResponseDTO> handlePremiseValidationException(PremiseValidationError exception) {
|
||||
ErrorDTO error = new ErrorDTO(
|
||||
exception.getClass().getName(),
|
||||
"Premiss validation error",
|
||||
|
|
@ -141,8 +141,20 @@ public class GlobalExceptionHandler {
|
|||
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)
|
||||
public ResponseEntity<ErrorResponseDTO> handleGenericException(Exception exception) {
|
||||
public ResponseEntity<ErrorResponseDTO> handlePremiseValidationException(Exception exception) {
|
||||
ErrorDTO error = new ErrorDTO(
|
||||
exception.getClass().getName(),
|
||||
"Internal Server Error",
|
||||
|
|
|
|||
|
|
@ -55,35 +55,34 @@ public class NodeController {
|
|||
return ResponseEntity.ok(nodeService.searchNode(filter, limit, nodeType, includeUserNode));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@GetMapping({"/{id}","/{id}/"})
|
||||
@PreAuthorize("hasAnyRole('SUPER', 'FREIGHT', 'PACKAGING')")
|
||||
public ResponseEntity<NodeDetailDTO> getNode(@PathVariable Integer id) {
|
||||
return ResponseEntity.ok(nodeService.getNode(id));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@DeleteMapping({"/{id}","/{id}/"})
|
||||
@PreAuthorize("hasRole('SUPER')")
|
||||
public ResponseEntity<Integer> deleteNode(@PathVariable Integer id) {
|
||||
return ResponseEntity.ok(nodeService.deleteNode(id));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@PutMapping({"/{id}","/{id}/"})
|
||||
@PreAuthorize("hasRole('SUPER')")
|
||||
public ResponseEntity<Integer> updateNode(@PathVariable Integer id, @RequestBody NodeUpdateDTO node) {
|
||||
Check.equals(id, node.getId());
|
||||
return ResponseEntity.ok(nodeService.updateNode(node));
|
||||
}
|
||||
|
||||
@GetMapping("/locate")
|
||||
@PreAuthorize("hasAnyRole('SUPER', 'FREIGHT', 'PACKAGING')")
|
||||
public ResponseEntity<Location> locateNode(@RequestParam String address) {
|
||||
return ResponseEntity.ok(geoApiService.locate(address));
|
||||
@GetMapping({"/locate", "/locate/"})
|
||||
@PreAuthorize("hasAnyRole('SUPER', 'FREIGHT', 'PACKAGING', 'CALCULATION')")
|
||||
public ResponseEntity<NodeDTO> locateNode(@RequestParam String address) {
|
||||
return ResponseEntity.ok(geoApiService.locateNode(address));
|
||||
}
|
||||
|
||||
@PutMapping("/")
|
||||
@PutMapping({"","/"})
|
||||
@PreAuthorize("hasAnyRole('SUPER', 'CALCULATION')")
|
||||
public ResponseEntity<Void> addUserNode(@RequestBody AddUserNodeDTO node) {
|
||||
userNodeService.addUserNode(node);
|
||||
return ResponseEntity.ok().build();
|
||||
public ResponseEntity<NodeDTO> addUserNode(@RequestBody AddUserNodeDTO node) {
|
||||
return ResponseEntity.ok(userNodeService.addUserNode(node));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,16 @@
|
|||
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.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.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.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
|
|
@ -12,6 +19,7 @@ import org.springframework.web.client.RestTemplate;
|
|||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class GeoApiService {
|
||||
|
|
@ -21,11 +29,17 @@ public class GeoApiService {
|
|||
|
||||
private final RestTemplate restTemplate;
|
||||
private final String subscriptionKey;
|
||||
private final LocationTransformer locationTransformer;
|
||||
private final CountryRepository countryRepository;
|
||||
private final CountryTransformer countryTransformer;
|
||||
|
||||
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.subscriptionKey = subscriptionKey;
|
||||
this.locationTransformer = locationTransformer;
|
||||
this.countryRepository = countryRepository;
|
||||
this.countryTransformer = countryTransformer;
|
||||
}
|
||||
|
||||
public GeocodingResult geocode(String address) {
|
||||
|
|
@ -57,7 +71,7 @@ public class GeoApiService {
|
|||
|
||||
} catch (Exception 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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package de.avatic.lcc.service.transformer.generic;
|
||||
|
||||
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.premises.route.RouteNode;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
|
@ -11,6 +12,10 @@ public class LocationTransformer {
|
|||
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) {
|
||||
return new LocationDTO(entity.getGeoLat().doubleValue(), entity.getGeoLng().doubleValue());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue