BACKEND: fixed near_by routing bug

FRONTEND: added bulk menu
This commit is contained in:
Jan 2025-09-13 20:09:01 +02:00
parent 02249d2da4
commit a2822666a6
8 changed files with 358 additions and 75 deletions

View file

@ -14,10 +14,71 @@ const performRequest = async (requestingStore, method, url, body, expectResponse
params.body = JSON.stringify(body);
}
const request = {url: url, params: params};
const request = {url: url, params: params, expectResponse: expectResponse, expectedException: expectedException};
logger.info("Request:", request);
const response = await fetch(url, params
const data = await executeRequest(requestingStore, request);
logger.info("Response:", data);
return data;
}
const performDownload = async (requestingStore, url, expectResponse = true, expectedException = null) => {
const params = {
method: 'GET',
};
const request = {url: url, params: params, expectResponse: expectResponse, expectedException: expectedException};
logger.info("Request:", request);
const processId = await executeRequest(null, request);
logger.info("Response:", processId);
return processId;
}
const performUpload = async (url, file, expectResponse = true, expectedException = null) => {
const formData = new FormData();
formData.append('file', file);
const params = {
method: 'POST',
body: formData
};
const request = {url: url, params: params, expectResponse: expectResponse, expectedException: expectedException};
logger.info("Request:", request);
const processId = await executeRequest(null, request);
logger.info("Response:", processId);
return processId;
}
function handleErrorResponse(data, requestingStore, request) {
const errorObj = {
code: data.error.code,
title: data.error.title,
message: data.error.message,
trace: data.error.trace
}
const error = new Error('Internal backend error');
error.errorObj = errorObj;
if (request.expectedException === null || data.error.title !== request.expectedException) {
logger.error(errorObj);
const errorStore = useErrorStore();
void errorStore.addError(errorObj, {store: requestingStore, request: request});
}
throw error;
}
const executeRequest = async (requestingStore, request) => {
const response = await fetch(request.url, request.params
).catch(e => {
const error = {
code: 'Network error.',
@ -33,7 +94,7 @@ const performRequest = async (requestingStore, method, url, body, expectResponse
});
let data = null;
if (expectResponse) {
if (request.expectResponse) {
data = await response.json().catch(e => {
const error = {
code: 'Malformed response',
@ -48,23 +109,7 @@ const performRequest = async (requestingStore, method, url, body, expectResponse
});
if (!response.ok) {
const errorObj = {
code: data.error.code,
title: data.error.title,
message: data.error.message,
trace: data.error.trace
}
const error = new Error('Internal backend error');
error.errorObj = errorObj;
if (expectedException === null || data.error.title !== expectedException) {
logger.error(errorObj);
const errorStore = useErrorStore();
void errorStore.addError(errorObj, {store: requestingStore, request: request});
}
throw error;
handleErrorResponse(data, requestingStore, request);
}
} else {
if (!response.ok) {
@ -82,28 +127,12 @@ const performRequest = async (requestingStore, method, url, body, expectResponse
});
const errorObj = {
code: data.error.code,
title: data.error.title,
message: data.error.message,
trace: data.error.trace
}
const error = new Error('Internal backend error');
error.errorObj = errorObj;
if (expectedException === null || data.error.title !== expectedException) {
logger.error(errorObj);
const errorStore = useErrorStore();
void errorStore.addError(errorObj, {store: requestingStore, request: request});
}
throw error;
handleErrorResponse(data, requestingStore, request);
}
}
logger.info("Response:", data);
return data;
}
export default performRequest;
export {performUpload, performDownload};

View file

@ -0,0 +1,227 @@
<template>
<div>
<div class="bulk-operations-container">
<box variant="border" class="bulk-operations-box-container">
<div class="bulk-operations-sub-container">
<div class="bulk-operation-header">Export</div>
<div class="bulk-operation-caption">type</div>
<div class="bulk-operation-data">
<radio-option name="export-type" value="empty" v-model="exportType">empty template</radio-option>
<radio-option name="export-type" value="full" v-model="exportType">full data export</radio-option>
</div>
<div class="bulk-operation-caption">dataset</div>
<div class="bulk-operation-data">
<radio-option name="export-dataset" value="NODE" v-model="exportDataset">nodes</radio-option>
<radio-option name="export-dataset" value="COUNTRY_MATRIX" v-model="exportDataset">kilometer rates</radio-option>
<radio-option name="export-dataset" value="CONTAINER_RATE" v-model="exportDataset">container rates</radio-option>
<radio-option name="export-dataset" value="MATERIAL" v-model="exportDataset">materials</radio-option>
<radio-option name="export-dataset" value="PACKAGING" v-model="exportDataset">packaging</radio-option>
</div>
<div class="bulk-operation-action-footer">
<basic-button @click="downloadFile" icon="download">Export</basic-button>
</div>
</div>
</box>
<box variant="border" class="bulk-operations-box-container">
<div class="bulk-operations-sub-container">
<div class="bulk-operation-header">Import</div>
<div class="bulk-operation-caption">dataset</div>
<div class="bulk-operation-data">
<radio-option name="import-dataset" value="NODE" v-model="importDataset">nodes</radio-option>
<radio-option name="import-dataset" value="COUNTRY_MATRIX" v-model="importDataset">kilometer rates</radio-option>
<radio-option name="import-dataset" value="CONTAINER_RATE" v-model="importDataset">container rates</radio-option>
<radio-option name="import-dataset" value="MATERIAL" v-model="importDataset">materials</radio-option>
<radio-option name="import-dataset" value="PACKAGING" v-model="importDataset">packaging</radio-option>
</div>
<div class="bulk-operation-caption">import mode</div>
<div class="bulk-operation-data">
<radio-option name="import-type" value="APPEND" v-model="importType">append existing data</radio-option>
<radio-option name="import-type" value="FULL" v-model="importType">fully replace data</radio-option>
</div>
<div class="bulk-operation-caption">file</div>
<div class="bulk-operation-data">
<div class="file-input-container">
<label for="upload" class="file-button-label">
Choose file
</label>
<input @change="inputFile" class="file-select-button" type="file" id="upload" name="upload" accept="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" />
</div>
<div id="selectedFile" class="selected-file" v-if="selectedFileName">{{ selectedFileName }}</div>
</div>
<div class="bulk-operation-action-footer">
<basic-button @click="uploadFile" icon="upload" :disabled="!selectedFile">Import</basic-button>
</div>
</div>
</box>
</div>
</div>
</template>
<script>
import Box from "@/components/UI/Box.vue";
import BasicButton from "@/components/UI/BasicButton.vue";
import RadioOption from "@/components/UI/RadioOption.vue";
import performRequest, {performUpload, performDownload} from "@/backend.js";
import {config} from "@/config.js";
export default {
name: "BulkOperations",
components: {RadioOption, BasicButton, Box},
data() {
return {
exportType: "empty",
exportDataset: "NODE",
importDataset: "NODE",
importType: "APPEND",
selectedFileName: null,
selectedFile: null,
uploading: false,
processId: null,
}
},
methods: {
async downloadFile() {
const url = `${config.backendUrl}/bulk/upload/${this.exportDataset}/${this.exportType}/`
this.processId = await performDownload(url);
},
inputFile(event) {
const file = event.target.files[0];
if (file) {
this.selectedFile = file;
this.selectedFileName = file.name;
} else {
this.selectedFile = null;
this.selectedFileName = null;
}
},
async uploadFile() {
if (!this.selectedFile)
return;
const url = `${config.backendUrl}/bulk/upload/${this.importDataset}/${this.importType}/`
this.processId = await performUpload(url, this.selectedFile);
}
}
}
</script>
<style scoped>
.bulk-operations-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.bulk-operations-box-container {
flex: 1;
}
.bulk-operations-sub-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
row-gap: 3.2rem;
width: 100%;
}
.bulk-operation-header {
font-weight: 500;
font-size: 1.4rem;
grid-column: 1 / -1;
}
.bulk-operation-action-footer {
grid-column: 1 / -1;
justify-self: end;
align-self: end;
}
.bulk-operation-caption {
font-size: 1.2rem;
color: #6B869C;
font-weight: 400;
}
.bulk-operation-data {
display: flex;
flex-direction: column;
gap: 1.2rem;
align-items: flex-start;
font-size: 1.2rem;
}
/* File input styling */
.file-input-container {
position: relative;
display: inline-block;
}
.file-select-button {
position: absolute;
opacity: 0;
width: 100%;
height: 100%;
cursor: pointer;
}
.file-button-label {
display: inline-flex;
align-items: center;
gap: 1.2rem;
padding: 0.6rem 1.2rem;
border: 0.2rem solid transparent;
border-radius: 0.8rem;
font-size: 1.4rem;
cursor: pointer;
transition: all 0.3s ease-in-out;
outline: none;
user-select: none;
font-family: 'Poppins', sans-serif;
font-weight: 500;
background-color: #002F54;
color: #ffffff;
}
.file-button-label:hover {
background-color: #ffffff;
color: #002F54;
border-color: #002F54;
font-weight: 500;
}
/* Icon for file upload */
.upload-icon {
width: 1.6rem;
height: 1.6rem;
fill: currentColor;
}
/* Selected file display */
.selected-file {
margin-top: 1rem;
padding: 1rem;
background-color: #c3cfdf;
border-radius: 0.8rem;
font-size: 1.2rem;
color: #002F54;
}
</style>

View file

@ -1,15 +0,0 @@
<template>
<h3>Bulk Operations</h3>
</template>
<script>
export default {
name: "BulkUpload"
}
</script>
<style scoped>
</style>

View file

@ -22,6 +22,7 @@ import Properties from "@/components/layout/config/Properties.vue";
import Box from "@/components/UI/Box.vue";
import CountryProperties from "@/components/layout/config/CountryProperties.vue";
import StagedChanges from "@/components/layout/config/StagedChanges.vue";
import BulkOperations from "@/components/layout/config/BulkOperations.vue";
export default {
name: "Config",
@ -38,25 +39,9 @@ export default {
title: 'Countries',
component: markRaw(CountryProperties),
},
{
title: 'Nodes',
component: (null),
},
{
title: 'Kilometer rates',
component: (null),
},
{
title: 'Container rates',
component: (null),
},
{
title: 'Materials & packaging',
component: (null),
},
{
title: 'Bulk operations',
component: (null),
component: markRaw(BulkOperations),
}
]
}

View file

@ -8,6 +8,11 @@
</div>
</div>
<div v-if="showComparableWarning && hasReport" class="destination-differ-warning">
<ph-warning size="18px"></ph-warning>
Reports may not be comparable! Calculations differ in destinations and/or their annual quantities.
</div>
<div v-if="loading" class="report-spinner-container">
<div class="report-spinner">
<spinner></spinner>
@ -54,6 +59,9 @@ export default {
},
computed: {
...mapStores(useReportsStore),
showComparableWarning() {
return this.reportsStore.getShowComparableWarning;
},
hasReport() {
return this.reportsStore.reports?.length > 0;
},
@ -160,4 +168,17 @@ export default {
align-items: center;
}
.destination-differ-warning {
display: flex;
align-items: center;
font-size: 1.4rem;
gap: 1.6rem;
background-color: #c3cfdf;
color: #002F54;
border-radius: 0.8rem;
padding: 1.6rem;
margin-bottom: 1.6rem;
}
</style>

View file

@ -7,10 +7,14 @@ export const useReportsStore = defineStore('reports', {
state() {
return {
reports: [],
showComparableWarning: false,
loading: false,
}
},
getters: {
getShowComparableWarning(state) {
return state.showComparableWarning;
},
getChartScale(state) {
let max = 0;
@ -43,6 +47,38 @@ export const useReportsStore = defineStore('reports', {
this.loading = false;
});
this.showComparableWarning = false;
for (const [idx, report] of this.reports.entries()) {
for (const otherReport of this.reports.slice(idx + 1)) {
if (report.premises.length !== otherReport.premises.length) {
this.showComparableWarning = true;
break;
}
for (const premise of report.premises) {
const otherPremise = otherReport.premises.find(otherPremise => otherPremise.destination.external_mapping_id === premise.destination.external_mapping_id);
if((otherPremise ?? null) == null) {
this.showComparableWarning = true;
break;
}
if(otherPremise.annual_quantity !== premise.annual_quantity) {
this.showComparableWarning = true;
break;
}
}
}
if(this.showComparableWarning)
break;
}
this.loading = false;
}
}

View file

@ -54,7 +54,7 @@ public class BulkOperationController {
* @param file The file to be uploaded, provided as a multipart file.
* @return A ResponseEntity indicating whether the upload was processed successfully.
*/
@PostMapping("/upload/{type}/{processing_type}")
@PostMapping({"/upload/{type}/{processing_type}","/upload/{type}/{processing_type}/"})
public ResponseEntity<Integer> uploadFile(@PathVariable("processing_type") BulkProcessingType processingType, @PathVariable BulkFileType type, @RequestParam("file") MultipartFile file) {
return ResponseEntity.ok(bulkProcessingService.processFile(type, processingType, file));
}

View file

@ -40,8 +40,8 @@ public class RouteRepository {
return Optional.empty();
}
/* if(1 < route.size())
TODO throw something */
if(1 < route.size())
throw new DatabaseException("Multiple selected routes for destination with id " + id);
return Optional.of(route.getFirst());