Compare commits
37 commits
feature/re
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 85f660665a | |||
| 605bcfe0fc | |||
| 03cd1274e9 | |||
| 8e01ef055a | |||
| 462a960c68 | |||
| 8be5f34137 | |||
| b66ac66b54 | |||
| 11d32a665e | |||
| b5f2df8be7 | |||
| c1e136f914 | |||
| bdfaef3365 | |||
| ac23dc4728 | |||
| bde397e509 | |||
| 8742d24b62 | |||
| 22051135ad | |||
| 23bc00d33c | |||
| 8ef279e735 | |||
| 81233db437 | |||
| 8d85e4c692 | |||
| 9b13261a20 | |||
| eb5aecb1b5 | |||
| d606e3e33a | |||
| 1ceca3f2f1 | |||
| 3a203d1c7e | |||
| f8d2745d32 | |||
| b473e34809 | |||
| 63e1574d2f | |||
|
|
d079971ec9 | ||
|
|
6c237a83ce | ||
|
|
d3e14fa8f0 | ||
|
|
4cfee73704 | ||
| 6add528c02 | |||
| 1788a7ef1c | |||
| 9ac3cb7815 | |||
| 1be35b5a8d | |||
| a83c49bc70 | |||
| 8f0986c7d8 |
74 changed files with 3687 additions and 435 deletions
2
.mvn/wrapper/maven-wrapper.properties
vendored
2
.mvn/wrapper/maven-wrapper.properties
vendored
|
|
@ -16,4 +16,4 @@
|
||||||
# under the License.
|
# under the License.
|
||||||
wrapperVersion=3.3.4
|
wrapperVersion=3.3.4
|
||||||
distributionType=only-script
|
distributionType=only-script
|
||||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip
|
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.12/apache-maven-3.9.12-bin.zip
|
||||||
|
|
|
||||||
7
pom.xml
7
pom.xml
|
|
@ -5,7 +5,7 @@
|
||||||
<parent>
|
<parent>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-parent</artifactId>
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
<version>3.5.8</version>
|
<version>3.5.9</version>
|
||||||
<relativePath/> <!-- lookup parent from repository -->
|
<relativePath/> <!-- lookup parent from repository -->
|
||||||
</parent>
|
</parent>
|
||||||
<groupId>de.avatic</groupId>
|
<groupId>de.avatic</groupId>
|
||||||
|
|
@ -130,6 +130,11 @@
|
||||||
<artifactId>fastexcel</artifactId>
|
<artifactId>fastexcel</artifactId>
|
||||||
<version>0.19.0</version>
|
<version>0.19.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.commonmark</groupId>
|
||||||
|
<artifactId>commonmark</artifactId>
|
||||||
|
<version>0.22.0</version>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-devtools</artifactId>
|
<artifactId>spring-boot-devtools</artifactId>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<the-notification-system />
|
<the-notification-system />
|
||||||
|
<the-help-system />
|
||||||
<the-header></the-header>
|
<the-header></the-header>
|
||||||
<router-view v-slot="slotProps">
|
<router-view v-slot="slotProps">
|
||||||
<transition name="route" mode="out-in">
|
<transition name="route" mode="out-in">
|
||||||
|
|
@ -13,9 +14,10 @@
|
||||||
|
|
||||||
import TheHeader from "@/components/layout/TheHeader.vue";
|
import TheHeader from "@/components/layout/TheHeader.vue";
|
||||||
import TheNotificationSystem from "@/components/UI/TheNotificationSystem.vue";
|
import TheNotificationSystem from "@/components/UI/TheNotificationSystem.vue";
|
||||||
|
import TheHelpSystem from "@/components/layout/help/TheHelpSystem.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {TheNotificationSystem, TheHeader},
|
components: {TheHelpSystem, TheNotificationSystem, TheHeader},
|
||||||
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,20 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="app-list-item">
|
<div class="app-list-item">
|
||||||
<div class="app-name-container"><div class="app-name-name">{{ app.name }}</div><div class="app-name-id">{{ app.client_id}}</div></div>
|
<div class="app-name-container">
|
||||||
|
<div class="app-name-name">{{ app.name }}</div>
|
||||||
|
<div class="app-name-id">{{ app.client_id }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="badge-list">
|
||||||
|
<basic-badge variant="secondary" icon="lock" v-for="group in groups" :key="group">{{ group }}</basic-badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-container">
|
||||||
|
<icon-button icon="download" @click="exportClick"></icon-button>
|
||||||
|
<icon-button icon="trash" @click="deleteClick"></icon-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="badge-list"> <basic-badge variant="secondary" icon="lock" v-for="group in groups" :key="group">{{group}}</basic-badge></div>
|
|
||||||
<div class="action-container"> <icon-button icon="trash" @click="deleteClick"></icon-button></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -18,7 +28,7 @@ import BasicBadge from "@/components/UI/BasicBadge.vue";
|
||||||
export default {
|
export default {
|
||||||
name: "AppListItem",
|
name: "AppListItem",
|
||||||
components: {BasicBadge, IconButton, Box},
|
components: {BasicBadge, IconButton, Box},
|
||||||
emits: ["deleteApp"],
|
emits: ["deleteApp", "exportApp"],
|
||||||
props: {
|
props: {
|
||||||
app: {
|
app: {
|
||||||
type: Object,
|
type: Object,
|
||||||
|
|
@ -33,6 +43,9 @@ export default {
|
||||||
methods: {
|
methods: {
|
||||||
deleteClick() {
|
deleteClick() {
|
||||||
this.$emit("deleteApp", this.app.id);
|
this.$emit("deleteApp", this.app.id);
|
||||||
|
},
|
||||||
|
exportClick() {
|
||||||
|
this.$emit("exportApp", this.app.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -76,7 +89,10 @@ export default {
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-container{
|
.action-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 1.2rem;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,7 @@ export default {
|
||||||
return; // Allow keyboard scrolling inside modal
|
return; // Allow keyboard scrolling inside modal
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent scrolling via keyboard (arrow keys, space, page up/down)
|
// Prevent scrolling via keyboard (arrow keys, space, helppages up/down)
|
||||||
const scrollKeys = [32, 33, 34, 35, 36, 37, 38, 39, 40];
|
const scrollKeys = [32, 33, 34, 35, 36, 37, 38, 39, 40];
|
||||||
if (scrollKeys.includes(e.keyCode)) {
|
if (scrollKeys.includes(e.keyCode)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
<PhCaretLeft :size="18" /> Previous
|
<PhCaretLeft :size="18" /> Previous
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- First page -->
|
<!-- First pages -->
|
||||||
<button
|
<button
|
||||||
v-if="showFirstPage"
|
v-if="showFirstPage"
|
||||||
class="pagination-btn page-number"
|
class="pagination-btn page-number"
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
<!-- First ellipsis -->
|
<!-- First ellipsis -->
|
||||||
<span v-if="showFirstEllipsis" class="ellipsis">...</span>
|
<span v-if="showFirstEllipsis" class="ellipsis">...</span>
|
||||||
|
|
||||||
<!-- Page numbers around current page -->
|
<!-- Page numbers around current pages -->
|
||||||
<button
|
<button
|
||||||
v-for="pageNum in visiblePages"
|
v-for="pageNum in visiblePages"
|
||||||
:key="pageNum"
|
:key="pageNum"
|
||||||
|
|
@ -37,7 +37,7 @@
|
||||||
<!-- Last ellipsis -->
|
<!-- Last ellipsis -->
|
||||||
<span v-if="showLastEllipsis" class="ellipsis">...</span>
|
<span v-if="showLastEllipsis" class="ellipsis">...</span>
|
||||||
|
|
||||||
<!-- Last page -->
|
<!-- Last pages -->
|
||||||
<button
|
<button
|
||||||
v-if="showLastPage"
|
v-if="showLastPage"
|
||||||
class="pagination-btn page-number"
|
class="pagination-btn page-number"
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,16 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="item-container" :class="{'selected-item': selected}">
|
<div class="item-container" :class="{'selected-item': selected}">
|
||||||
<flag :iso="isoCode" size="l"></flag>
|
<div class="supplier-content">
|
||||||
<div class="supplier-item-text">
|
<flag :iso="isoCode" size="l"></flag>
|
||||||
<div class="supplier-item-name"> <span class="user-icon" v-if="isUserSupplier"><ph-user weight="fill" ></ph-user></span> {{name}}</div>
|
<div class="supplier-item-text">
|
||||||
<div class="supplier-item-address">{{ address }}</div>
|
<div class="supplier-item-name">
|
||||||
|
<span class="user-icon" v-if="isUserSupplier">
|
||||||
|
<ph-user weight="fill"></ph-user>
|
||||||
|
</span>
|
||||||
|
{{name}}
|
||||||
|
</div>
|
||||||
|
<div class="supplier-item-address">{{ address }}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<icon-button v-if="showTrash" icon="trash" @click="deleteClick"></icon-button>
|
<icon-button v-if="showTrash" icon="trash" @click="deleteClick"></icon-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -65,14 +71,13 @@ export default {
|
||||||
|
|
||||||
.item-container {
|
.item-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: flex-start; /* Statt space-between */
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 3.6rem 3.6rem;
|
padding: 3.6rem 3.6rem;
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 0.8rem;
|
border-radius: 0.8rem;
|
||||||
box-shadow: 0 0.4rem 0.6rem -0.1rem rgba(0, 0, 0, 0.1);
|
box-shadow: 0 0.4rem 0.6rem -0.1rem rgba(0, 0, 0, 0.1);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
gap: 2.4rem;
|
|
||||||
flex: 0 0 50rem;
|
flex: 0 0 50rem;
|
||||||
transition: background-color 0.3s ease;
|
transition: background-color 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
@ -97,6 +102,20 @@ export default {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.supplier-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2.4rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.supplier-item-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.supplier-item-name {
|
.supplier-item-name {
|
||||||
font-size: 1.6rem;
|
font-size: 1.6rem;
|
||||||
|
|
@ -112,4 +131,8 @@ export default {
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.item-container > .icon-button {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="apps-container">
|
<div class="apps-container">
|
||||||
|
<div class="app-list-actions">
|
||||||
|
|
||||||
|
</div>
|
||||||
<div class="app-list-header">
|
<div class="app-list-header">
|
||||||
<div>App</div>
|
<div>App</div>
|
||||||
<div>Groups</div>
|
<div>Groups</div>
|
||||||
|
|
@ -8,13 +10,20 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="app-list">
|
<div class="app-list">
|
||||||
|
|
||||||
<app-list-item v-for="app in apps" :app="app" @delete-app="deleteApp"></app-list-item>
|
<app-list-item v-for="app in apps" :app="app" @delete-app="deleteApp" @export-app="exportApp"></app-list-item>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<modal :state="modalState">
|
<modal :state="modalState">
|
||||||
<add-app @close="closeModal"></add-app>
|
<add-app @close="closeModal"></add-app>
|
||||||
</modal>
|
</modal>
|
||||||
<basic-button icon="Plus" @click="modalState = true">New App</basic-button>
|
|
||||||
|
|
||||||
|
<div class="app-list-actions">
|
||||||
|
|
||||||
|
<basic-button icon="Upload" @click="importApp">Import</basic-button>
|
||||||
|
<basic-button icon="Plus" @click="modalState = true">New App</basic-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -27,6 +36,8 @@ import {mapStores} from "pinia";
|
||||||
import {useAppsStore} from "@/store/apps.js";
|
import {useAppsStore} from "@/store/apps.js";
|
||||||
import Modal from "@/components/UI/Modal.vue";
|
import Modal from "@/components/UI/Modal.vue";
|
||||||
import AddApp from "@/components/layout/config/AddApp.vue";
|
import AddApp from "@/components/layout/config/AddApp.vue";
|
||||||
|
import Dropdown from "@/components/UI/Dropdown.vue";
|
||||||
|
import IconButton from "@/components/UI/IconButton.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "Apps",
|
name: "Apps",
|
||||||
|
|
@ -36,7 +47,7 @@ export default {
|
||||||
default: false
|
default: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
components: {AddApp, Modal, AppListItem, BasicButton},
|
components: {IconButton, Dropdown, AddApp, Modal, AppListItem, BasicButton},
|
||||||
computed: {
|
computed: {
|
||||||
...mapStores(useAppsStore),
|
...mapStores(useAppsStore),
|
||||||
apps() {
|
apps() {
|
||||||
|
|
@ -45,7 +56,8 @@ export default {
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
modalState: false
|
modalState: false,
|
||||||
|
exportedApp: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
@ -57,6 +69,62 @@ export default {
|
||||||
},
|
},
|
||||||
deleteApp(id) {
|
deleteApp(id) {
|
||||||
this.appsStore.deleteApp(id);
|
this.appsStore.deleteApp(id);
|
||||||
|
},
|
||||||
|
async exportApp(id) {
|
||||||
|
const response = await this.appsStore.exportApp(id);
|
||||||
|
const app = this.appsStore.getById(id);
|
||||||
|
|
||||||
|
if(response?.data) {
|
||||||
|
const base64String = response.data;
|
||||||
|
|
||||||
|
const blob = new Blob([base64String], { type: 'text/plain' });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `${app.name}.app`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
async importApp() {
|
||||||
|
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = '.app';
|
||||||
|
|
||||||
|
input.onchange = async (event) => {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
try {
|
||||||
|
const fileContent = await this.readFileContent(file);
|
||||||
|
await this.appsStore.importApp(fileContent);
|
||||||
|
} catch (error) {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// File Dialog öffnen
|
||||||
|
input.click();
|
||||||
|
},
|
||||||
|
|
||||||
|
readFileContent(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
reader.onload = (e) => {
|
||||||
|
resolve(e.target.result);
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.onerror = (error) => {
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsText(file);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async created() {
|
async created() {
|
||||||
|
|
@ -85,6 +153,13 @@ export default {
|
||||||
border-bottom: 0.1rem solid #E3EDFF;
|
border-bottom: 0.1rem solid #E3EDFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-list-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 2rem;
|
||||||
|
gap: 1.6rem
|
||||||
|
}
|
||||||
|
|
||||||
.app-list {
|
.app-list {
|
||||||
margin-bottom: 2.4rem;
|
margin-bottom: 2.4rem;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,26 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="nodes-container">
|
<div class="nodes-container">
|
||||||
|
|
||||||
|
<modal :state="showModal">
|
||||||
|
<div class="node-modal-container">
|
||||||
|
<div class="node-header">
|
||||||
|
<h3 class="sub-header"> {{ node.name }}
|
||||||
|
</h3>
|
||||||
|
<icon-button icon="x" @click="showModal = false"></icon-button>
|
||||||
|
</div>
|
||||||
|
<div class="node-body">
|
||||||
|
<div class="node-address"><flag :iso="node.country.iso_code" />{{ node.address }}</div>
|
||||||
|
<div class="supplier-map" v-if="node.location">
|
||||||
|
<open-street-map-embed :coordinates="node.location" :zoom="5" 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>
|
||||||
|
</modal>
|
||||||
|
|
||||||
<table-view ref="tableViewRef" :data-source="fetch" :columns="nodeColumns" :page="pagination.page"
|
<table-view ref="tableViewRef" :data-source="fetch" :columns="nodeColumns" :page="pagination.page"
|
||||||
:page-size="pageSize" :page-count="pagination.pageCount"
|
:page-size="pageSize" :page-count="pagination.pageCount"
|
||||||
:total-count="pagination.totalCount"></table-view>
|
:total-count="pagination.totalCount" @row-click="showDetails" :mouse-over="true"></table-view>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -12,6 +30,12 @@
|
||||||
import TableView from "@/components/UI/TableView.vue";
|
import TableView from "@/components/UI/TableView.vue";
|
||||||
import {mapStores} from "pinia";
|
import {mapStores} from "pinia";
|
||||||
import {useNodeStore} from "@/store/node.js";
|
import {useNodeStore} from "@/store/node.js";
|
||||||
|
import Modal from "@/components/UI/Modal.vue";
|
||||||
|
import ErrorModal from "@/components/layout/error/ErrorModal.vue";
|
||||||
|
import TabContainer from "@/components/UI/TabContainer.vue";
|
||||||
|
import IconButton from "@/components/UI/IconButton.vue";
|
||||||
|
import OpenStreetMapEmbed from "@/components/UI/OpenStreetMapEmbed.vue";
|
||||||
|
import Flag from "@/components/UI/Flag.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "Nodes",
|
name: "Nodes",
|
||||||
|
|
@ -28,7 +52,7 @@ export default {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
components: {TableView},
|
components: {Flag, OpenStreetMapEmbed, IconButton, TabContainer, ErrorModal, Modal, TableView},
|
||||||
computed: {
|
computed: {
|
||||||
...mapStores(useNodeStore),
|
...mapStores(useNodeStore),
|
||||||
},
|
},
|
||||||
|
|
@ -40,10 +64,16 @@ export default {
|
||||||
await this.nodeStore.setQuery(query);
|
await this.nodeStore.setQuery(query);
|
||||||
this.pagination = this.nodeStore.pagination;
|
this.pagination = this.nodeStore.pagination;
|
||||||
return this.nodeStore.nodes;
|
return this.nodeStore.nodes;
|
||||||
|
},
|
||||||
|
showDetails(node) {
|
||||||
|
this.node = node;
|
||||||
|
this.showModal = true;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
showModal: false,
|
||||||
|
node: null,
|
||||||
nodeColumns: [
|
nodeColumns: [
|
||||||
{
|
{
|
||||||
key: 'external_mapping_id',
|
key: 'external_mapping_id',
|
||||||
|
|
@ -95,4 +125,39 @@ export default {
|
||||||
padding: 2.4rem;
|
padding: 2.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.node-modal-container {
|
||||||
|
height: 40rem;
|
||||||
|
width: 60rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden; /* Verhindert Overflow */
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0; /* Header soll nicht schrumpfen */
|
||||||
|
padding-bottom: 1.6rem; /* Optional: etwas Abstand */
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-body {
|
||||||
|
flex: 1; /* Nimmt den restlichen Platz ein */
|
||||||
|
min-height: 0; /* Wichtig für Flexbox-Scrolling */
|
||||||
|
overflow: hidden; /* Container selbst soll nicht scrollen */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-address {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.8rem;
|
||||||
|
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field-group">
|
<div class="field-group">
|
||||||
<div class="caption-column">Oversea share [%]</div>
|
<div class="caption-column">Overseas share [%]</div>
|
||||||
<div class="input-column">
|
<div class="input-column">
|
||||||
<div class="text-container">
|
<div class="text-container">
|
||||||
<input ref="overseaShareInput" @keydown.enter="handleEnter('overseaShareInput', $event)" :value="overSeaSharePercent" @blur="validateOverSeaShare" class="input-field"
|
<input ref="overseaShareInput" @keydown.enter="handleEnter('overseaShareInput', $event)" :value="overSeaSharePercent" @blur="validateOverSeaShare" class="input-field"
|
||||||
|
|
|
||||||
38
src/frontend/src/components/layout/help/Help.vue
Normal file
38
src/frontend/src/components/layout/help/Help.vue
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<video controls width="100%">
|
||||||
|
<source :src="videoUrl" type="video/mp4">
|
||||||
|
</video>
|
||||||
|
<div v-html="text"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
import {mapStores} from "pinia";
|
||||||
|
import {useHelpStore} from "@/store/help.js";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "Help",
|
||||||
|
data() {
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapStores(useHelpStore),
|
||||||
|
currentPage() {
|
||||||
|
return this.helpStore.currentPage;
|
||||||
|
},
|
||||||
|
videoUrl() {
|
||||||
|
return this.helpStore.videoUrl;
|
||||||
|
},
|
||||||
|
text() {
|
||||||
|
return this.helpStore.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
107
src/frontend/src/components/layout/help/HelpText.vue
Normal file
107
src/frontend/src/components/layout/help/HelpText.vue
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
<template>
|
||||||
|
<div class="help">
|
||||||
|
<div class="help-text-content" v-html="text"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import {mapStores} from "pinia";
|
||||||
|
import {useHelpStore} from "@/store/help.js";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "HelpText",
|
||||||
|
computed: {
|
||||||
|
...mapStores(useHelpStore),
|
||||||
|
text() {
|
||||||
|
return this.helpStore.text;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.help {
|
||||||
|
margin-top: 2rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Text content styling */
|
||||||
|
.help-text-content :deep(blockquote) {
|
||||||
|
margin: 0 0 2rem 0;
|
||||||
|
padding: 1.6rem;
|
||||||
|
background: #c3cfdf;
|
||||||
|
color: #002F54;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text-content :deep(blockquote p) {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #002F54;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text-content :deep(h3) {
|
||||||
|
margin: 3rem 0 1.6rem 0;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #6B869C;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text-content :deep(h3:first-child) {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text-content :deep(p) {
|
||||||
|
margin: 0 0 1.6rem 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text-content :deep(strong) {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text-content :deep(img) {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
margin: 1.6rem 0;
|
||||||
|
border-radius: 0.8rem;
|
||||||
|
box-shadow: 0 0.2rem 0.8rem rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text-content :deep(ul) {
|
||||||
|
margin: 0 0 1.6rem 0;
|
||||||
|
padding-left: 2rem;
|
||||||
|
list-style-type: disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text-content :deep(li) {
|
||||||
|
margin: 0.8rem 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text-content :deep(li::marker) {
|
||||||
|
color: #c3cfdf;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
32
src/frontend/src/components/layout/help/HelpVideo.vue
Normal file
32
src/frontend/src/components/layout/help/HelpVideo.vue
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
<template>
|
||||||
|
<div class="help-content-container">
|
||||||
|
<video controls width="80%" :key="videoUrl">
|
||||||
|
<source :src="videoUrl" type="video/mp4">
|
||||||
|
</video>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
import {mapStores} from "pinia";
|
||||||
|
import {useHelpStore} from "@/store/help.js";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "HelpVideo",
|
||||||
|
computed: {
|
||||||
|
...mapStores(useHelpStore),
|
||||||
|
videoUrl() {
|
||||||
|
return this.helpStore.videoUrl;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.help-content-container {
|
||||||
|
margin-top: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
57
src/frontend/src/components/layout/help/TheHelpMenu.vue
Normal file
57
src/frontend/src/components/layout/help/TheHelpMenu.vue
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
<template>
|
||||||
|
<div >
|
||||||
|
<ul class="help-menu">
|
||||||
|
<li v-for="page in pages" class="help-menu-element-container" @click="$emit('changePage', page.page)"><div :class="{'help-menu-element-active': isActive(page.page)}" class="help-menu-element">{{ page.title }}</div></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: "TheHelpMenu",
|
||||||
|
emits: ['changePage'],
|
||||||
|
props: {
|
||||||
|
currentPage: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
pages: {
|
||||||
|
type: Array,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
isActive(page) {
|
||||||
|
return page === this.currentPage;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
.help-menu-element {
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
color: #6B869C;
|
||||||
|
height: 25px;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.help-menu-element:hover, .help-menu-element-active {
|
||||||
|
color: #002F54;
|
||||||
|
border-bottom: 5px solid #5AF0B4;
|
||||||
|
/* AE0055 */
|
||||||
|
height: 25px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-menu-element-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
list-style-type: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
124
src/frontend/src/components/layout/help/TheHelpSystem.vue
Normal file
124
src/frontend/src/components/layout/help/TheHelpSystem.vue
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
<template>
|
||||||
|
<teleport to="body">
|
||||||
|
<modal :z-index="9001" :state="showHelp">
|
||||||
|
<div class="help-modal-container">
|
||||||
|
<div class="help-modal-header">
|
||||||
|
<icon-button icon="x" @click="helpStore.closeHelp()"/>
|
||||||
|
</div>
|
||||||
|
<div class="help-container">
|
||||||
|
<div class="help-menu-container">
|
||||||
|
<the-help-menu @changePage="updatePage" :currentPage="currentPage" :pages="pages"/>
|
||||||
|
</div>
|
||||||
|
<div class="help-content-container">
|
||||||
|
<h2 class="page-header">{{ title }}</h2>
|
||||||
|
<tab-container :tabs="tabsConfig"></tab-container>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</modal>
|
||||||
|
</teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Modal from "@/components/UI/Modal.vue";
|
||||||
|
import {mapStores} from "pinia";
|
||||||
|
import Help from "@/components/layout/help/Help.vue";
|
||||||
|
import {useHelpStore} from "@/store/help.js";
|
||||||
|
import TheHelpMenu from "@/components/layout/help/TheHelpMenu.vue";
|
||||||
|
import BasicButton from "@/components/UI/BasicButton.vue";
|
||||||
|
import IconButton from "@/components/UI/IconButton.vue";
|
||||||
|
import TabContainer from "@/components/UI/TabContainer.vue";
|
||||||
|
import {markRaw} from "vue";
|
||||||
|
import Nodes from "@/components/layout/config/Nodes.vue";
|
||||||
|
import HelpVideo from "@/components/layout/help/HelpVideo.vue";
|
||||||
|
import HelpText from "@/components/layout/help/HelpText.vue";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "TheHelpSystem",
|
||||||
|
components: {TabContainer, IconButton, BasicButton, TheHelpMenu, Help, Modal},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
tabsConfig: [
|
||||||
|
{
|
||||||
|
title: 'Video',
|
||||||
|
component: markRaw(HelpVideo),
|
||||||
|
props: {isSelected: false},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Text',
|
||||||
|
component: markRaw(HelpText),
|
||||||
|
props: {isSelected: false},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapStores(useHelpStore),
|
||||||
|
showHelp() {
|
||||||
|
return this.helpStore.showHelp;
|
||||||
|
},
|
||||||
|
currentPage() {
|
||||||
|
return this.helpStore.currentPage;
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return this.helpStore.title;
|
||||||
|
},
|
||||||
|
pages() {
|
||||||
|
return this.helpStore.pages;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async updatePage(page) {
|
||||||
|
await this.helpStore.getContent(page);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.helpStore.loadPages();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
|
||||||
|
.help-modal-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.6rem;
|
||||||
|
width: min(80vw, 180rem);
|
||||||
|
height: min(80vh, 120rem);
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 2.4rem;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-menu-container {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-content-container {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -34,7 +34,7 @@ import {
|
||||||
PhUpload,
|
PhUpload,
|
||||||
PhWarning,
|
PhWarning,
|
||||||
PhX,
|
PhX,
|
||||||
PhExclamationMark, PhMapPin, PhEmpty, PhShippingContainer, PhPackage, PhVectorThree, PhTag
|
PhExclamationMark, PhMapPin, PhEmpty, PhShippingContainer, PhPackage, PhVectorThree, PhTag, PhInfo
|
||||||
} from "@phosphor-icons/vue";
|
} from "@phosphor-icons/vue";
|
||||||
import {setupSessionRefresh} from "@/store/activeuser.js";
|
import {setupSessionRefresh} from "@/store/activeuser.js";
|
||||||
|
|
||||||
|
|
@ -81,6 +81,7 @@ app.component("PhMapPin", PhMapPin);
|
||||||
app.component("PhPackage", PhPackage);
|
app.component("PhPackage", PhPackage);
|
||||||
app.component("PhVectorThree", PhVectorThree);
|
app.component("PhVectorThree", PhVectorThree);
|
||||||
app.component("PhTag", PhTag);
|
app.component("PhTag", PhTag);
|
||||||
|
app.component("PhInfo", PhInfo);
|
||||||
|
|
||||||
|
|
||||||
app.use(router);
|
app.use(router);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,15 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="start-calculation-container">
|
<div class="start-calculation-container">
|
||||||
<h2 class="page-header">Create Calculation</h2>
|
<div class="start-calculation-header">
|
||||||
|
<div>
|
||||||
|
<h2 class="page-header">Create Calculation</h2>
|
||||||
|
</div>
|
||||||
|
<div class="start-calculation-help">
|
||||||
|
<icon-button v-if="useHelpStore().enableHelp" icon="info"
|
||||||
|
@click="useHelpStore().activateHelp('assistant')"></icon-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="part-numbers-headers">
|
<div class="part-numbers-headers">
|
||||||
|
|
@ -23,8 +32,15 @@
|
||||||
<textarea v-model="partNumberField" name="partNumbers" cols="140" rows="15"></textarea>
|
<textarea v-model="partNumberField" name="partNumbers" cols="140" rows="15"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="part-number-modal-action">
|
<div class="part-number-modal-action">
|
||||||
<basic-button @click="parsePartNumbers" icon="CloudArrowUp">Analyze input</basic-button>
|
<div class="part-number-modal-action-help">
|
||||||
<basic-button @click="closeModal('partNumber')" :show-icon="false" variant="secondary">Cancel</basic-button>
|
<icon-button v-if="useHelpStore().enableHelp" icon="info"
|
||||||
|
@click="useHelpStore().activateHelp('assistant')"></icon-button>
|
||||||
|
</div>
|
||||||
|
<div class="part-number-modal-action-buttons">
|
||||||
|
<basic-button @click="parsePartNumbers" icon="CloudArrowUp">Analyze input</basic-button>
|
||||||
|
<basic-button @click="closeModal('partNumber')" :show-icon="false" variant="secondary">Cancel</basic-button>
|
||||||
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</modal>
|
</modal>
|
||||||
|
|
@ -88,11 +104,22 @@ import CreateNewNode from "@/components/layout/node/CreateNewNode.vue";
|
||||||
import Checkbox from "@/components/UI/Checkbox.vue";
|
import Checkbox from "@/components/UI/Checkbox.vue";
|
||||||
import {UrlSafeBase64} from "@/common.js";
|
import {UrlSafeBase64} from "@/common.js";
|
||||||
import {useNotificationStore} from "@/store/notification.js";
|
import {useNotificationStore} from "@/store/notification.js";
|
||||||
|
import IconButton from "@/components/UI/IconButton.vue";
|
||||||
|
import {useHelpStore} from "@/store/help.js";
|
||||||
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "CalculationAssistant",
|
name: "CalculationAssistant",
|
||||||
components: {Checkbox, CreateNewNode, Modal, SupplierItem, MaterialItem, BasicButton, AutosuggestSearchbar},
|
components: {
|
||||||
|
IconButton,
|
||||||
|
Checkbox,
|
||||||
|
CreateNewNode,
|
||||||
|
Modal,
|
||||||
|
SupplierItem,
|
||||||
|
MaterialItem,
|
||||||
|
BasicButton,
|
||||||
|
AutosuggestSearchbar
|
||||||
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapStores(useNodeStore, useAssistantStore, useNotificationStore),
|
...mapStores(useNodeStore, useAssistantStore, useNotificationStore),
|
||||||
showPartNumberModal() {
|
showPartNumberModal() {
|
||||||
|
|
@ -108,6 +135,7 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
useHelpStore,
|
||||||
setUseExisting(useExisting) {
|
setUseExisting(useExisting) {
|
||||||
this.assistantStore.setCreateEmpty(!useExisting);
|
this.assistantStore.setCreateEmpty(!useExisting);
|
||||||
},
|
},
|
||||||
|
|
@ -160,7 +188,7 @@ export default {
|
||||||
parsePartNumbers() {
|
parsePartNumbers() {
|
||||||
this.closeModal('partNumber');
|
this.closeModal('partNumber');
|
||||||
|
|
||||||
if(this.partNumberField.trim().length !== 0)
|
if (this.partNumberField.trim().length !== 0)
|
||||||
this.assistantStore.getMaterialsAndSuppliers(this.partNumberField);
|
this.assistantStore.getMaterialsAndSuppliers(this.partNumberField);
|
||||||
|
|
||||||
this.partNumberField = '';
|
this.partNumberField = '';
|
||||||
|
|
@ -179,6 +207,22 @@ export default {
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
|
.start-calculation-help {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 1.6rem;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-calculation-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
.start-calculation-footer-container {
|
.start-calculation-footer-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
@ -232,10 +276,17 @@ textarea {
|
||||||
gap: 1.6rem;
|
gap: 1.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.part-number-modal-action-help {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
.part-number-modal-action {
|
.part-number-modal-action {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: space-between;
|
||||||
gap: 1.6rem
|
gap: 1.6rem
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -246,6 +297,13 @@ textarea {
|
||||||
margin-bottom: 1.6rem;
|
margin-bottom: 1.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.part-number-modal-action-buttons {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 1.6rem
|
||||||
|
}
|
||||||
|
|
||||||
.item-list {
|
.item-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,19 @@
|
||||||
<div class="edit-calculation-container"
|
<div class="edit-calculation-container"
|
||||||
:class="{ 'has-selection': hasSelection, 'apply-filter': applyFilter, 'add-all': addAll }">
|
:class="{ 'has-selection': hasSelection, 'apply-filter': applyFilter, 'add-all': addAll }">
|
||||||
<div class="header-container">
|
<div class="header-container">
|
||||||
<h2 class="page-header">Mass edit calculation</h2>
|
|
||||||
|
<div class="header-caption-container">
|
||||||
|
<div>
|
||||||
|
<h2 class="page-header">Mass edit calculation</h2>
|
||||||
|
</div>
|
||||||
|
<div class="header-help-container">
|
||||||
|
<icon-button v-if="useHelpStore().enableHelp" icon="info"
|
||||||
|
@click="useHelpStore().activateHelp('mass-edit-basics')"></icon-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="header-controls">
|
<div class="header-controls">
|
||||||
<basic-button :show-icon="true"
|
<basic-button :show-icon="true"
|
||||||
:disabled="disableButtons"
|
:disabled="disableButtons"
|
||||||
|
|
@ -173,6 +185,8 @@ import DestMassCreate from "@/components/layout/edit/destination/mass/DestMassCr
|
||||||
import ModalDialog from "@/components/UI/ModalDialog.vue";
|
import ModalDialog from "@/components/UI/ModalDialog.vue";
|
||||||
import destinationEdit from "@/components/layout/edit/destination/DestinationEdit.vue";
|
import destinationEdit from "@/components/layout/edit/destination/DestinationEdit.vue";
|
||||||
import logger from "@/logger.js";
|
import logger from "@/logger.js";
|
||||||
|
import IconButton from "@/components/UI/IconButton.vue";
|
||||||
|
import {useHelpStore} from "@/store/help.js";
|
||||||
|
|
||||||
|
|
||||||
const COMPONENT_TYPES = {
|
const COMPONENT_TYPES = {
|
||||||
|
|
@ -187,6 +201,7 @@ const COMPONENT_TYPES = {
|
||||||
export default {
|
export default {
|
||||||
name: "MassEdit",
|
name: "MassEdit",
|
||||||
components: {
|
components: {
|
||||||
|
IconButton,
|
||||||
ModalDialog,
|
ModalDialog,
|
||||||
SortButton,
|
SortButton,
|
||||||
Modal,
|
Modal,
|
||||||
|
|
@ -267,7 +282,7 @@ export default {
|
||||||
return this.premiseEditStore.showProcessingModal || this.destinationEditStore.showProcessingModal;
|
return this.premiseEditStore.showProcessingModal || this.destinationEditStore.showProcessingModal;
|
||||||
},
|
},
|
||||||
shownProcessingMessage() {
|
shownProcessingMessage() {
|
||||||
if(this.premiseEditStore.showProcessingModal)
|
if (this.premiseEditStore.showProcessingModal)
|
||||||
return "Please wait. Prepare calculation ..."
|
return "Please wait. Prepare calculation ..."
|
||||||
|
|
||||||
return this.processingMessage;
|
return this.processingMessage;
|
||||||
|
|
@ -298,6 +313,7 @@ export default {
|
||||||
window.removeEventListener('keyup', this.handleKeyUp);
|
window.removeEventListener('keyup', this.handleKeyUp);
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
useHelpStore,
|
||||||
handleKeyDown(event) {
|
handleKeyDown(event) {
|
||||||
if (event.key === 'Control') {
|
if (event.key === 'Control') {
|
||||||
this.isCtrlPressed = true;
|
this.isCtrlPressed = true;
|
||||||
|
|
@ -444,6 +460,7 @@ export default {
|
||||||
async closeEditModalAction(action) {
|
async closeEditModalAction(action) {
|
||||||
|
|
||||||
let massUpdate = false;
|
let massUpdate = false;
|
||||||
|
let success = true;
|
||||||
|
|
||||||
if (this.modalType === 'amount' || this.modalType === 'routes' || this.modalType === "destinations") {
|
if (this.modalType === 'amount' || this.modalType === 'routes' || this.modalType === "destinations") {
|
||||||
|
|
||||||
|
|
@ -453,18 +470,21 @@ export default {
|
||||||
const setMatrix = this.$refs.modalComponent?.destMatrix;
|
const setMatrix = this.$refs.modalComponent?.destMatrix;
|
||||||
|
|
||||||
if (setMatrix) {
|
if (setMatrix) {
|
||||||
await this.destinationEditStore.massSetDestinations(setMatrix);
|
success = await this.destinationEditStore.massSetDestinations(setMatrix);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
massUpdate = true
|
massUpdate = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear data
|
|
||||||
this.fillData(this.modalType);
|
|
||||||
this.modalType = null;
|
|
||||||
|
|
||||||
if(massUpdate) {
|
if (success) {
|
||||||
|
// Clear data
|
||||||
|
this.fillData(this.modalType);
|
||||||
|
this.modalType = null;
|
||||||
|
} else return;
|
||||||
|
|
||||||
|
if (massUpdate) {
|
||||||
await this.destinationEditStore.massUpdateDestinations(this.editIds);
|
await this.destinationEditStore.massUpdateDestinations(this.editIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -490,6 +510,8 @@ export default {
|
||||||
},
|
},
|
||||||
fillData(type, id = -1, hideDescription = false) {
|
fillData(type, id = -1, hideDescription = false) {
|
||||||
|
|
||||||
|
this.modalTitle = "Edit ".concat(type);
|
||||||
|
|
||||||
if (id === -1) {
|
if (id === -1) {
|
||||||
|
|
||||||
if (type === 'price')
|
if (type === 'price')
|
||||||
|
|
@ -526,8 +548,6 @@ export default {
|
||||||
} else {
|
} else {
|
||||||
const premise = this.premiseEditStore.getById(id);
|
const premise = this.premiseEditStore.getById(id);
|
||||||
|
|
||||||
this.modalTitle = "Edit ".concat(type);
|
|
||||||
|
|
||||||
if (type === "price") {
|
if (type === "price") {
|
||||||
this.modalProps = {
|
this.modalProps = {
|
||||||
price: premise.material_cost,
|
price: premise.material_cost,
|
||||||
|
|
@ -738,4 +758,20 @@ export default {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1.6rem;
|
gap: 1.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-help-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 1.6rem;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-caption-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.6rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,22 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="edit-calculation-container">
|
<div class="edit-calculation-container">
|
||||||
<div class="header-container">
|
<div class="header-container">
|
||||||
<h2 class="page-header">Edit calculation</h2>
|
|
||||||
|
|
||||||
|
<div class="header-container">
|
||||||
|
<div>
|
||||||
|
<h2 class="page-header">Edit calculation</h2>
|
||||||
|
</div>
|
||||||
|
<div class="header-help-container">
|
||||||
|
<icon-button v-if="useHelpStore().enableHelp" icon="info"
|
||||||
|
@click="useHelpStore().activateHelp('single-edit')"></icon-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div class="header-controls">
|
<div class="header-controls">
|
||||||
<basic-button @click="close" :show-icon="false" :disabled="premiseSingleEditStore.showLoadingSpinner"
|
<basic-button @click="close" :show-icon="false" :disabled="premiseSingleEditStore.showLoadingSpinner"
|
||||||
variant="secondary"> {{ fromMassEdit ? 'Back' : 'Close' }}
|
variant="secondary"> {{ fromMassEdit ? 'Back' : 'Close' }}
|
||||||
|
|
@ -107,6 +122,7 @@ import {UrlSafeBase64} from "@/common.js";
|
||||||
import {usePremiseSingleEditStore} from "@/store/premiseSingleEdit.js";
|
import {usePremiseSingleEditStore} from "@/store/premiseSingleEdit.js";
|
||||||
import {useNotificationStore} from "@/store/notification.js";
|
import {useNotificationStore} from "@/store/notification.js";
|
||||||
import Spinner from "@/components/UI/Spinner.vue";
|
import Spinner from "@/components/UI/Spinner.vue";
|
||||||
|
import {useHelpStore} from "@/store/help.js";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "SingleEdit",
|
name: "SingleEdit",
|
||||||
|
|
@ -161,6 +177,7 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
useHelpStore,
|
||||||
|
|
||||||
async startCalculation() {
|
async startCalculation() {
|
||||||
this.showCalculationModal = true;
|
this.showCalculationModal = true;
|
||||||
|
|
@ -209,6 +226,23 @@ export default {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
|
.header-help-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 1.6rem;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
.edit-calculation-container {
|
.edit-calculation-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,23 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
|
|
||||||
<h2 class="page-header"> My calculations</h2>
|
<div class="header-container">
|
||||||
|
<div>
|
||||||
|
<h2 class="page-header"> My calculations</h2>
|
||||||
|
</div>
|
||||||
|
<div class="header-help-container">
|
||||||
|
<icon-button v-if="useHelpStore().enableHelp" icon="info"
|
||||||
|
@click="useHelpStore().activateHelp('dashboard')"></icon-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2 class="page-sub-header">{{ greeting }}</h2>
|
<h2 class="page-sub-header">{{ greeting }}</h2>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<h3 class="sub-header">Status</h3>
|
<h3 class="sub-header">Status</h3>
|
||||||
|
|
||||||
<the-dashboard></the-dashboard>
|
<the-dashboard></the-dashboard>
|
||||||
|
|
||||||
<h3 class="sub-header">Drafts</h3>
|
<h3 class="sub-header">Drafts</h3>
|
||||||
<div class="calculation-list-container">
|
<div class="calculation-list-container">
|
||||||
|
|
@ -79,6 +87,7 @@ import modal from "@/components/UI/Modal.vue";
|
||||||
import {useActiveUserStore} from "@/store/activeuser.js";
|
import {useActiveUserStore} from "@/store/activeuser.js";
|
||||||
import TheDashboard from "@/components/layout/calculation/TheDashboard.vue";
|
import TheDashboard from "@/components/layout/calculation/TheDashboard.vue";
|
||||||
import Box from "@/components/UI/Box.vue";
|
import Box from "@/components/UI/Box.vue";
|
||||||
|
import {useHelpStore} from "@/store/help.js";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "Calculation",
|
name: "Calculation",
|
||||||
|
|
@ -186,6 +195,7 @@ export default {
|
||||||
await this.executeSearch();
|
await this.executeSearch();
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
useHelpStore,
|
||||||
async handleModalAction(action) {
|
async handleModalAction(action) {
|
||||||
if (action === 'dismiss') {
|
if (action === 'dismiss') {
|
||||||
this.modal.state = false;
|
this.modal.state = false;
|
||||||
|
|
@ -361,6 +371,21 @@ export default {
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
|
.header-help-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,26 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="header-container">
|
<div class="header-container">
|
||||||
<h2 class="page-header page-header-align">Reporting
|
|
||||||
|
<div class="header-caption-container">
|
||||||
|
<div>
|
||||||
|
<h2 class="page-header page-header-align">Reporting
|
||||||
|
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="header-help-container">
|
||||||
|
<icon-button v-if="useHelpStore().enableHelp" icon="info"
|
||||||
|
@click="useHelpStore().activateHelp('report')"></icon-button>
|
||||||
|
</div>
|
||||||
<div class="page-header-badges">
|
<div class="page-header-badges">
|
||||||
<basic-badge variant="primary" v-if="period">{{ period }}</basic-badge>
|
<basic-badge variant="primary" v-if="period">{{ period }}</basic-badge>
|
||||||
<basic-badge variant="secondary" v-if="partNumber">{{ partNumber }}</basic-badge>
|
<basic-badge variant="secondary" v-if="partNumber">{{ partNumber }}</basic-badge>
|
||||||
</div>
|
</div>
|
||||||
</h2>
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="header-controls">
|
<div class="header-controls">
|
||||||
<basic-button @click="createReport" icon="file">Create report</basic-button>
|
<basic-button @click="createReport" icon="file">Create report</basic-button>
|
||||||
<basic-button :disabled="!hasReport" variant="secondary" @click="downloadReport" icon="Download">Export
|
<basic-button :disabled="!hasReport" variant="secondary" @click="downloadReport" icon="Download">Export
|
||||||
|
|
@ -56,10 +70,12 @@ import ReportChart from "@/components/UI/ReportChart.vue";
|
||||||
import Report from "@/components/layout/report/Report.vue";
|
import Report from "@/components/layout/report/Report.vue";
|
||||||
import BasicBadge from "@/components/UI/BasicBadge.vue";
|
import BasicBadge from "@/components/UI/BasicBadge.vue";
|
||||||
import {buildDate} from "@/common.js";
|
import {buildDate} from "@/common.js";
|
||||||
|
import IconButton from "@/components/UI/IconButton.vue";
|
||||||
|
import {useHelpStore} from "@/store/help.js";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "Reporting",
|
name: "Reporting",
|
||||||
components: {BasicBadge, Report, ReportChart, Spinner, Box, SelectForReport, BasicButton, Modal},
|
components: {IconButton, BasicBadge, Report, ReportChart, Spinner, Box, SelectForReport, BasicButton, Modal},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
showModal: false,
|
showModal: false,
|
||||||
|
|
@ -79,13 +95,13 @@ export default {
|
||||||
|
|
||||||
for (let i = 0; i < scale.length; i++) {
|
for (let i = 0; i < scale.length; i++) {
|
||||||
for (const report of reports) {
|
for (const report of reports) {
|
||||||
if(report.destinations.length > i) {
|
if (report.destinations.length > i) {
|
||||||
scale[i] = Math.max(scale[i], report.destinations[i].sections.length);
|
scale[i] = Math.max(scale[i], report.destinations[i].sections.length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return scale.map(s => (s+1)*4);
|
return scale.map(s => (s + 1) * 4);
|
||||||
},
|
},
|
||||||
reports() {
|
reports() {
|
||||||
return this.reportsStore.reports
|
return this.reportsStore.reports
|
||||||
|
|
@ -113,6 +129,7 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
useHelpStore,
|
||||||
downloadReport() {
|
downloadReport() {
|
||||||
this.reportsStore.downloadReport();
|
this.reportsStore.downloadReport();
|
||||||
},
|
},
|
||||||
|
|
@ -140,6 +157,22 @@ export default {
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
|
.header-help-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 1.6rem;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-caption-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
.page-header-align {
|
.page-header-align {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -150,6 +183,7 @@ export default {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.8rem;
|
gap: 0.8rem;
|
||||||
|
margin-bottom: 3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.space-around {
|
.space-around {
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,18 @@ export const useAppsStore = defineStore('apps', {
|
||||||
this.apps = resp.data;
|
this.apps = resp.data;
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
},
|
},
|
||||||
|
async exportApp(id) {
|
||||||
|
const url = `${config.backendUrl}/apps/export/${id}`;
|
||||||
|
const resp = await performRequest(this, 'GET', url, null);
|
||||||
|
return resp.data;
|
||||||
|
},
|
||||||
|
async importApp(app) {
|
||||||
|
const url = `${config.backendUrl}/apps/import`;
|
||||||
|
const resp = await performRequest(this, 'POST', url, { data: app },true);
|
||||||
|
|
||||||
|
if(resp.data)
|
||||||
|
await this.loadApps();
|
||||||
|
},
|
||||||
async addApp(appName, appGroups) {
|
async addApp(appName, appGroups) {
|
||||||
const url = `${config.backendUrl}/apps`;
|
const url = `${config.backendUrl}/apps`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import {defineStore} from 'pinia'
|
import {defineStore} from 'pinia'
|
||||||
import performRequest from "@/backend.js";
|
import performRequest from "@/backend.js";
|
||||||
import {config} from '@/config'
|
import {config} from '@/config'
|
||||||
|
import logger from "@/logger.js";
|
||||||
|
import {useNotificationStore} from "@/store/notification.js";
|
||||||
|
|
||||||
export const useDestinationEditStore = defineStore('destinationEdit', {
|
export const useDestinationEditStore = defineStore('destinationEdit', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
|
|
@ -83,30 +85,45 @@ export const useDestinationEditStore = defineStore('destinationEdit', {
|
||||||
async massSetDestinations(updateMatrix) {
|
async massSetDestinations(updateMatrix) {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
|
||||||
const toBeAdded = {};
|
try {
|
||||||
const toBeDeletedMap = new Map();
|
|
||||||
|
|
||||||
updateMatrix.forEach(row => {
|
const toBeAdded = {};
|
||||||
toBeAdded[row.id] = row.destinations.filter(d => d.selected).map(d => d.id);
|
const toBeDeletedMap = new Map();
|
||||||
toBeDeletedMap.set(row.id, row.destinations.filter(d => !d.selected).map(d => d.id));
|
|
||||||
});
|
|
||||||
|
|
||||||
const url = `${config.backendUrl}/calculation/destination`;
|
updateMatrix.forEach(row => {
|
||||||
const {
|
toBeAdded[row.id] = row.destinations.filter(d => d.selected).map(d => d.id);
|
||||||
data: data,
|
toBeDeletedMap.set(row.id, row.destinations.filter(d => !d.selected).map(d => d.id));
|
||||||
headers: headers
|
});
|
||||||
} = await performRequest(this, 'POST', url, {'destination_node_ids': toBeAdded});
|
|
||||||
|
|
||||||
this.destinations.forEach((destinations, premiseId) => {
|
const url = `${config.backendUrl}/calculation/destination`;
|
||||||
const toBeDeleted = toBeDeletedMap.get(premiseId);
|
const {
|
||||||
|
data: data,
|
||||||
|
headers: headers
|
||||||
|
} = await performRequest(this, 'POST', url, {'destination_node_ids': toBeAdded});
|
||||||
|
|
||||||
const filtered = destinations !== null ? destinations.filter(d => !toBeDeleted?.includes(d.destination_node.id)) : [];
|
this.destinations.forEach((destinations, premiseId) => {
|
||||||
|
const toBeDeleted = toBeDeletedMap.get(premiseId);
|
||||||
|
|
||||||
this.destinations.set(premiseId, [...filtered, ...data[premiseId]]);
|
const filtered = destinations !== null ? destinations.filter(d => !toBeDeleted?.includes(d.destination_node.id)) : [];
|
||||||
});
|
const dataForPremiseId = (data[premiseId] ?? null) === null ? [] : data[premiseId];
|
||||||
|
|
||||||
|
this.destinations.set(premiseId, [...filtered, ...dataForPremiseId]);
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error in massSetDestinations:', error);
|
||||||
|
useNotificationStore().addNotification({
|
||||||
|
title: 'Unable to set destinations',
|
||||||
|
message: error.message ?? error.toString(),
|
||||||
|
variant: 'exception',
|
||||||
|
icon: 'bug',
|
||||||
|
})
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
this.loading = false;
|
return true;
|
||||||
},
|
},
|
||||||
async massUpdateDestinations(premiseIds) {
|
async massUpdateDestinations(premiseIds) {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
|
|
||||||
53
src/frontend/src/store/help.js
Normal file
53
src/frontend/src/store/help.js
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import {defineStore} from "pinia";
|
||||||
|
import {config} from "@/config.js";
|
||||||
|
import performRequest from "@/backend.js";
|
||||||
|
|
||||||
|
export const useHelpStore = defineStore('help', {
|
||||||
|
state() {
|
||||||
|
return {
|
||||||
|
currentPage: null,
|
||||||
|
pages: null,
|
||||||
|
content: null,
|
||||||
|
showHelp: false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getters: {
|
||||||
|
title(state) {
|
||||||
|
return state.pages?.find(p => p.page === state.currentPage)?.title;
|
||||||
|
},
|
||||||
|
videoUrl(state) {
|
||||||
|
return state.content?.video;
|
||||||
|
},
|
||||||
|
text(state) {
|
||||||
|
return state.content?.content;
|
||||||
|
},
|
||||||
|
showHelpVideo() {
|
||||||
|
return this.baseUrl && this.baseUrl !== "";
|
||||||
|
},
|
||||||
|
enableHelp() {
|
||||||
|
return this.pages !== null && this.pages.length > 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
async getContent(pageId) {
|
||||||
|
const url = `${config.backendUrl}/help/content/${pageId}`;
|
||||||
|
const {data: data, headers: headers} = await performRequest(this, 'GET', url, null);
|
||||||
|
console.log("help system", data);
|
||||||
|
this.content = data;
|
||||||
|
this.currentPage = pageId;
|
||||||
|
},
|
||||||
|
async loadPages() {
|
||||||
|
const url = `${config.backendUrl}/help/content`;
|
||||||
|
const {data: data, headers: headers} = await performRequest(this, 'GET', url, null);
|
||||||
|
this.pages = data;
|
||||||
|
},
|
||||||
|
async activateHelp(pageId) {
|
||||||
|
await this.getContent(pageId);
|
||||||
|
this.showHelp = true;
|
||||||
|
|
||||||
|
},
|
||||||
|
closeHelp() {
|
||||||
|
this.showHelp = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -20,7 +20,6 @@ export default defineConfig({
|
||||||
src: 'assets/map.json',
|
src: 'assets/map.json',
|
||||||
dest: 'assets/'
|
dest: 'assets/'
|
||||||
}
|
}
|
||||||
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
|
|
@ -35,4 +34,18 @@ export default defineConfig({
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false
|
||||||
|
},
|
||||||
|
'/help': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -18,10 +18,5 @@ public class ShutdownListener {
|
||||||
Runtime runtime = Runtime.getRuntime();
|
Runtime runtime = Runtime.getRuntime();
|
||||||
long usedMemory = (runtime.totalMemory() - runtime.freeMemory()) / 1024 / 1024;
|
long usedMemory = (runtime.totalMemory() - runtime.freeMemory()) / 1024 / 1024;
|
||||||
log.info("Memory: {} used, {} total, {} free, {} max ", usedMemory, runtime.totalMemory() / 1024 / 1024, runtime.freeMemory() / 1024 / 1024, runtime.maxMemory() / 1024 / 1024);
|
log.info("Memory: {} used, {} total, {} free, {} max ", usedMemory, runtime.totalMemory() / 1024 / 1024, runtime.freeMemory() / 1024 / 1024, runtime.maxMemory() / 1024 / 1024);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
log.error("Thread stack dump:");
|
|
||||||
Thread.dumpStack();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ package de.avatic.lcc.controller.configuration;
|
||||||
|
|
||||||
import com.azure.core.annotation.BodyParam;
|
import com.azure.core.annotation.BodyParam;
|
||||||
import de.avatic.lcc.dto.configuration.apps.AppDTO;
|
import de.avatic.lcc.dto.configuration.apps.AppDTO;
|
||||||
|
import de.avatic.lcc.dto.configuration.apps.AppExchangeDTO;
|
||||||
import de.avatic.lcc.service.apps.AppsService;
|
import de.avatic.lcc.service.apps.AppsService;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
|
@ -32,6 +33,18 @@ public class AppsController {
|
||||||
return ResponseEntity.ok(appsService.updateApp(dto));
|
return ResponseEntity.ok(appsService.updateApp(dto));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping({"/export/{id}", "/export/{id}/"})
|
||||||
|
@PreAuthorize("hasRole('SERVICE')")
|
||||||
|
public ResponseEntity<AppExchangeDTO> exportApp(@PathVariable Integer id) {
|
||||||
|
return ResponseEntity.ok(appsService.exportApp(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping({"/import/", "/import"})
|
||||||
|
@PreAuthorize("hasRole('SERVICE')")
|
||||||
|
public ResponseEntity<Boolean> importApp(@RequestBody AppExchangeDTO dto) {
|
||||||
|
return ResponseEntity.ok(appsService.importApp(dto));
|
||||||
|
}
|
||||||
|
|
||||||
@DeleteMapping({"/{id}", "/{id}/"})
|
@DeleteMapping({"/{id}", "/{id}/"})
|
||||||
@PreAuthorize("hasRole('SERVICE')")
|
@PreAuthorize("hasRole('SERVICE')")
|
||||||
public ResponseEntity<Void> deleteApp(@PathVariable Integer id) {
|
public ResponseEntity<Void> deleteApp(@PathVariable Integer id) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
package de.avatic.lcc.controller.help;
|
||||||
|
|
||||||
|
import de.avatic.lcc.dto.help.HelpPage;
|
||||||
|
import de.avatic.lcc.service.help.MarkdownService;
|
||||||
|
import org.apache.commons.compress.utils.Lists;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/help")
|
||||||
|
public class HelpController {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(HelpController.class);
|
||||||
|
|
||||||
|
private final MarkdownService markdownService;
|
||||||
|
|
||||||
|
private final List<HelpPage> pages = List.of(
|
||||||
|
new HelpPage("dashboard", "Dashboard"),
|
||||||
|
new HelpPage("assistant", "Create Calculations"),
|
||||||
|
new HelpPage("single-edit", "Single Edit"),
|
||||||
|
new HelpPage("mass-edit-basics", "Mass Edit (Basics)"),
|
||||||
|
new HelpPage("mass-edit-destinations", "Mass Edit (Advanced)"),
|
||||||
|
new HelpPage("report", "Reporting"));
|
||||||
|
|
||||||
|
@Value(value = "${lcc.help.static:}")
|
||||||
|
private String staticStorageUrl;
|
||||||
|
|
||||||
|
public HelpController(MarkdownService markdownService) {
|
||||||
|
this.markdownService = markdownService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping({"/content", "content"})
|
||||||
|
public List<HelpPage> listContent() {
|
||||||
|
if(staticStorageUrl == null|| staticStorageUrl.isBlank())
|
||||||
|
return List.of();
|
||||||
|
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/content/{resource}")
|
||||||
|
public Map<String, String> getHelpContent(@PathVariable String resource) throws IOException {
|
||||||
|
|
||||||
|
if (pages.stream().noneMatch(p -> p.getPage().equals(resource)))
|
||||||
|
throw new IOException("Page not found");
|
||||||
|
|
||||||
|
Map<String, String> map = new HashMap<>();
|
||||||
|
|
||||||
|
var fileName = resource + ".md";
|
||||||
|
var videoUrl = staticStorageUrl == null ? null : staticStorageUrl + "/" + resource + ".mp4";
|
||||||
|
|
||||||
|
logger.info("Loading help content: {}", fileName);
|
||||||
|
String html = markdownService.loadAndConvertMarkdown(fileName, resource);
|
||||||
|
|
||||||
|
map.put("content", html);
|
||||||
|
|
||||||
|
if (videoUrl != null)
|
||||||
|
map.put("video", videoUrl);
|
||||||
|
|
||||||
|
return map;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
package de.avatic.lcc.dto.configuration.apps;
|
||||||
|
|
||||||
|
public class AppExchangeDTO {
|
||||||
|
|
||||||
|
private String data;
|
||||||
|
|
||||||
|
public void setData(String data) {
|
||||||
|
this.data = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getData() {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/main/java/de/avatic/lcc/dto/help/HelpPage.java
Normal file
28
src/main/java/de/avatic/lcc/dto/help/HelpPage.java
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
package de.avatic.lcc.dto.help;
|
||||||
|
|
||||||
|
public class HelpPage {
|
||||||
|
|
||||||
|
String title;
|
||||||
|
String page;
|
||||||
|
|
||||||
|
public HelpPage(String page, String title) {
|
||||||
|
this.title = title;
|
||||||
|
this.page = page;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTitle() {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTitle(String title) {
|
||||||
|
this.title = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPage() {
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPage(String page) {
|
||||||
|
this.page = page;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
package de.avatic.lcc.model.azuremaps.geocoding.fuzzy;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public class FuzzySearchResponse {
|
||||||
|
private Summary summary;
|
||||||
|
private List<FuzzySearchResult> results;
|
||||||
|
|
||||||
|
public Summary getSummary() {
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSummary(Summary summary) {
|
||||||
|
this.summary = summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<FuzzySearchResult> getResults() {
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setResults(List<FuzzySearchResult> results) {
|
||||||
|
this.results = results;
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public static class Summary {
|
||||||
|
private String query;
|
||||||
|
private int numResults;
|
||||||
|
private int totalResults;
|
||||||
|
|
||||||
|
public String getQuery() {
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setQuery(String query) {
|
||||||
|
this.query = query;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getNumResults() {
|
||||||
|
return numResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNumResults(int numResults) {
|
||||||
|
this.numResults = numResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getTotalResults() {
|
||||||
|
return totalResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTotalResults(int totalResults) {
|
||||||
|
this.totalResults = totalResults;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,150 @@
|
||||||
|
package de.avatic.lcc.model.azuremaps.geocoding.fuzzy;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public class FuzzySearchResult {
|
||||||
|
private String type;
|
||||||
|
private double score;
|
||||||
|
private Position position;
|
||||||
|
private Address address;
|
||||||
|
private String entityType;
|
||||||
|
|
||||||
|
public String getType() {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setType(String type) {
|
||||||
|
this.type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double getScore() {
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setScore(double score) {
|
||||||
|
this.score = score;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Position getPosition() {
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPosition(Position position) {
|
||||||
|
this.position = position;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Address getAddress() {
|
||||||
|
return address;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAddress(Address address) {
|
||||||
|
this.address = address;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getEntityType() {
|
||||||
|
return entityType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEntityType(String entityType) {
|
||||||
|
this.entityType = entityType;
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public static class Position {
|
||||||
|
private double lat;
|
||||||
|
private double lon;
|
||||||
|
|
||||||
|
public double getLat() {
|
||||||
|
return lat;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLat(double lat) {
|
||||||
|
this.lat = lat;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double getLon() {
|
||||||
|
return lon;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLon(double lon) {
|
||||||
|
this.lon = lon;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public static class Address {
|
||||||
|
private String freeformAddress;
|
||||||
|
private String countryCode;
|
||||||
|
private String countryCodeISO3;
|
||||||
|
private String country;
|
||||||
|
private String municipality;
|
||||||
|
private String postalCode;
|
||||||
|
private String streetName;
|
||||||
|
private String streetNumber;
|
||||||
|
|
||||||
|
public String getFreeformAddress() {
|
||||||
|
return freeformAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFreeformAddress(String freeformAddress) {
|
||||||
|
this.freeformAddress = freeformAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCountryCode() {
|
||||||
|
return countryCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCountryCode(String countryCode) {
|
||||||
|
this.countryCode = countryCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCountryCodeISO3() {
|
||||||
|
return countryCodeISO3;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCountryCodeISO3(String countryCodeISO3) {
|
||||||
|
this.countryCodeISO3 = countryCodeISO3;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCountry() {
|
||||||
|
return country;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCountry(String country) {
|
||||||
|
this.country = country;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMunicipality() {
|
||||||
|
return municipality;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMunicipality(String municipality) {
|
||||||
|
this.municipality = municipality;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPostalCode() {
|
||||||
|
return postalCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPostalCode(String postalCode) {
|
||||||
|
this.postalCode = postalCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getStreetName() {
|
||||||
|
return streetName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStreetName(String streetName) {
|
||||||
|
this.streetName = streetName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getStreetNumber() {
|
||||||
|
return streetNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStreetNumber(String streetNumber) {
|
||||||
|
this.streetNumber = streetNumber;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -215,7 +215,7 @@ public class ContainerCalculationResult {
|
||||||
* @return The total utilization value for the container.
|
* @return The total utilization value for the container.
|
||||||
*/
|
*/
|
||||||
public double getTotalUtilizationByVolume() {
|
public double getTotalUtilizationByVolume() {
|
||||||
return getHuUtilizationByVolume() * huUnitCount * layer;
|
return getHuUtilizationByVolume() * huUnitCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -254,4 +254,7 @@ public class ContainerCalculationResult {
|
||||||
return WeightUnit.KG.convertFromG(hu.getWeight()) / maxContainerWeight;
|
return WeightUnit.KG.convertFromG(hu.getWeight()) / maxContainerWeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int getHuPerContainer() {
|
||||||
|
return this.huUnitCount;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
package de.avatic.lcc.model.zolltarifnummern;
|
|
||||||
|
|
||||||
import java.lang.reflect.Array;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public class ZolltarifnummernResponse {
|
|
||||||
|
|
||||||
String query;
|
|
||||||
|
|
||||||
String year;
|
|
||||||
|
|
||||||
String lang;
|
|
||||||
|
|
||||||
String version;
|
|
||||||
|
|
||||||
String total;
|
|
||||||
|
|
||||||
List<ZolltarifnummernResponseEntry> suggestions;
|
|
||||||
|
|
||||||
public String getQuery() {
|
|
||||||
return query;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setQuery(String query) {
|
|
||||||
this.query = query;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getYear() {
|
|
||||||
return year;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setYear(String year) {
|
|
||||||
this.year = year;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getLang() {
|
|
||||||
return lang;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setLang(String lang) {
|
|
||||||
this.lang = lang;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getVersion() {
|
|
||||||
return version;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setVersion(String version) {
|
|
||||||
this.version = version;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getTotal() {
|
|
||||||
return total;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setTotal(String total) {
|
|
||||||
this.total = total;
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<ZolltarifnummernResponseEntry> getSuggestions() {
|
|
||||||
return suggestions;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setSuggestions(List<ZolltarifnummernResponseEntry> suggestions) {
|
|
||||||
this.suggestions = suggestions;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
package de.avatic.lcc.model.zolltarifnummern;
|
|
||||||
|
|
||||||
public class ZolltarifnummernResponseEntry {
|
|
||||||
|
|
||||||
String code;
|
|
||||||
|
|
||||||
String score;
|
|
||||||
|
|
||||||
String value;
|
|
||||||
|
|
||||||
String data;
|
|
||||||
|
|
||||||
public String getCode() {
|
|
||||||
return code;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setCode(String code) {
|
|
||||||
this.code = code;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getScore() {
|
|
||||||
return score;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setScore(String score) {
|
|
||||||
this.score = score;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getValue() {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setValue(String value) {
|
|
||||||
this.value = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getData() {
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setData(String data) {
|
|
||||||
this.data = data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -88,7 +88,7 @@ public class DistanceMatrixRepository {
|
||||||
toId);
|
toId);
|
||||||
|
|
||||||
logger.info("Updated existing distance entry for nodes {} -> {}",
|
logger.info("Updated existing distance entry for nodes {} -> {}",
|
||||||
distance.getFromNodeId(), distance.getToNodeId());
|
fromId, toId);
|
||||||
} else {
|
} else {
|
||||||
// Insert new entry
|
// Insert new entry
|
||||||
String insertQuery = """
|
String insertQuery = """
|
||||||
|
|
@ -111,11 +111,14 @@ public class DistanceMatrixRepository {
|
||||||
distance.getUpdatedAt());
|
distance.getUpdatedAt());
|
||||||
|
|
||||||
logger.info("Inserted new distance entry for nodes {} -> {}",
|
logger.info("Inserted new distance entry for nodes {} -> {}",
|
||||||
distance.getFromNodeId(), distance.getToNodeId());
|
fromId, toId);
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
Integer fromId = distance.getFromUserNodeId() != null ? distance.getFromUserNodeId() : distance.getFromNodeId();
|
||||||
|
Integer toId = distance.getToUserNodeId() != null ? distance.getToUserNodeId() : distance.getToNodeId();
|
||||||
|
|
||||||
logger.error("Error saving distance to database for nodes {} -> {}",
|
logger.error("Error saving distance to database for nodes {} -> {}",
|
||||||
distance.getFromNodeId(), distance.getToNodeId(), e);
|
fromId, toId, e);
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,12 @@ public class PremisesService {
|
||||||
premissIds.forEach(id -> {
|
premissIds.forEach(id -> {
|
||||||
|
|
||||||
var old = premiseRepository.getPremiseById(id).orElseThrow();
|
var old = premiseRepository.getPremiseById(id).orElseThrow();
|
||||||
|
|
||||||
|
|
||||||
|
var existingPremises = premiseRepository.findByMaterialIdAndSupplierId(old.getMaterialId(), old.getSupplierNodeId(), old.getUserSupplierNodeId(), userId);
|
||||||
|
var existingDrafts = existingPremises.stream().filter(p -> p.getState().equals(PremiseState.DRAFT)).toList();
|
||||||
|
this.delete(existingDrafts.stream().map(Premise::getId).toList());
|
||||||
|
|
||||||
var newId = premiseRepository.insert(old.getMaterialId(), old.getSupplierNodeId(), old.getUserSupplierNodeId(), BigDecimal.valueOf(old.getLocation().getLatitude()), BigDecimal.valueOf(old.getLocation().getLongitude()), old.getCountryId(), userId);
|
var newId = premiseRepository.insert(old.getMaterialId(), old.getSupplierNodeId(), old.getUserSupplierNodeId(), BigDecimal.valueOf(old.getLocation().getLatitude()), BigDecimal.valueOf(old.getLocation().getLongitude()), old.getCountryId(), userId);
|
||||||
|
|
||||||
premiseRepository.updateMaterial(Collections.singletonList(newId), old.getHsCode(), old.getTariffRate(), old.getTariffUnlocked());
|
premiseRepository.updateMaterial(Collections.singletonList(newId), old.getHsCode(), old.getTariffRate(), old.getTariffUnlocked());
|
||||||
|
|
|
||||||
|
|
@ -123,10 +123,21 @@ public class PropertyService {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
public <T> Optional<T> getProperty(SystemPropertyMappingId mappingId, Integer setId) {
|
||||||
|
var prop = propertyRepository.getPropertyByMappingId(mappingId, setId);
|
||||||
|
|
||||||
|
return doCasting(prop);
|
||||||
|
}
|
||||||
|
|
||||||
public <T> Optional<T> getProperty(SystemPropertyMappingId mappingId) {
|
public <T> Optional<T> getProperty(SystemPropertyMappingId mappingId) {
|
||||||
var prop = propertyRepository.getPropertyByMappingId(mappingId);
|
var prop = propertyRepository.getPropertyByMappingId(mappingId);
|
||||||
|
|
||||||
|
return doCasting(prop);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private <T> Optional<T> doCasting(Optional<PropertyDTO> prop) {
|
||||||
if (prop.isEmpty())
|
if (prop.isEmpty())
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
|
|
||||||
|
|
@ -158,4 +169,6 @@ public class PropertyService {
|
||||||
default -> throw new IllegalArgumentException("Unsupported data type: " + dataType);
|
default -> throw new IllegalArgumentException("Unsupported data type: " + dataType);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
package de.avatic.lcc.service.api;
|
package de.avatic.lcc.service.api;
|
||||||
|
|
||||||
import de.avatic.lcc.model.excel.ExcelNode;
|
|
||||||
import de.avatic.lcc.model.azuremaps.geocoding.batch.BatchGeocodingRequest;
|
import de.avatic.lcc.model.azuremaps.geocoding.batch.BatchGeocodingRequest;
|
||||||
import de.avatic.lcc.model.azuremaps.geocoding.batch.BatchGeocodingResponse;
|
import de.avatic.lcc.model.azuremaps.geocoding.batch.BatchGeocodingResponse;
|
||||||
import de.avatic.lcc.model.azuremaps.geocoding.batch.BatchItem;
|
import de.avatic.lcc.model.azuremaps.geocoding.batch.BatchItem;
|
||||||
|
import de.avatic.lcc.model.azuremaps.geocoding.fuzzy.FuzzySearchResponse;
|
||||||
import de.avatic.lcc.model.bulk.BulkInstruction;
|
import de.avatic.lcc.model.bulk.BulkInstruction;
|
||||||
import de.avatic.lcc.model.db.country.IsoCode;
|
import de.avatic.lcc.model.db.country.IsoCode;
|
||||||
|
import de.avatic.lcc.model.excel.ExcelNode;
|
||||||
import de.avatic.lcc.util.exception.internalerror.ExcelValidationError;
|
import de.avatic.lcc.util.exception.internalerror.ExcelValidationError;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
@ -17,6 +18,8 @@ import org.springframework.web.util.UriComponentsBuilder;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
@ -45,6 +48,7 @@ public class BatchGeoApiService {
|
||||||
}
|
}
|
||||||
|
|
||||||
ArrayList<BulkInstruction<ExcelNode>> noGeo = new ArrayList<>();
|
ArrayList<BulkInstruction<ExcelNode>> noGeo = new ArrayList<>();
|
||||||
|
ArrayList<BulkInstruction<ExcelNode>> failedGeoLookups = new ArrayList<>();
|
||||||
int totalSuccessful = 0;
|
int totalSuccessful = 0;
|
||||||
|
|
||||||
for (var node : nodes) {
|
for (var node : nodes) {
|
||||||
|
|
@ -53,6 +57,7 @@ public class BatchGeoApiService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
for (int currentBatch = 0; currentBatch < noGeo.size(); currentBatch += MAX_BATCH_SIZE) {
|
for (int currentBatch = 0; currentBatch < noGeo.size(); currentBatch += MAX_BATCH_SIZE) {
|
||||||
int end = Math.min(currentBatch + MAX_BATCH_SIZE, noGeo.size());
|
int end = Math.min(currentBatch + MAX_BATCH_SIZE, noGeo.size());
|
||||||
var chunk = noGeo.subList(currentBatch, end);
|
var chunk = noGeo.subList(currentBatch, end);
|
||||||
|
|
@ -73,7 +78,8 @@ public class BatchGeoApiService {
|
||||||
if (!result.getFeatures().isEmpty() &&
|
if (!result.getFeatures().isEmpty() &&
|
||||||
(result.getFeatures().getFirst().getProperties().getConfidence().equalsIgnoreCase("high") ||
|
(result.getFeatures().getFirst().getProperties().getConfidence().equalsIgnoreCase("high") ||
|
||||||
result.getFeatures().getFirst().getProperties().getConfidence().equalsIgnoreCase("medium") ||
|
result.getFeatures().getFirst().getProperties().getConfidence().equalsIgnoreCase("medium") ||
|
||||||
result.getFeatures().getFirst().getProperties().getMatchCodes().stream().anyMatch(s -> s.equalsIgnoreCase("good")))) {
|
(result.getFeatures().getFirst().getProperties().getMatchCodes() != null &&
|
||||||
|
result.getFeatures().getFirst().getProperties().getMatchCodes().stream().anyMatch(s -> s.equalsIgnoreCase("good"))))) {
|
||||||
var geometry = result.getFeatures().getFirst().getGeometry();
|
var geometry = result.getFeatures().getFirst().getGeometry();
|
||||||
var properties = result.getFeatures().getFirst().getProperties();
|
var properties = result.getFeatures().getFirst().getProperties();
|
||||||
node.setGeoLng(BigDecimal.valueOf(geometry.getCoordinates().get(0)));
|
node.setGeoLng(BigDecimal.valueOf(geometry.getCoordinates().get(0)));
|
||||||
|
|
@ -82,11 +88,106 @@ public class BatchGeoApiService {
|
||||||
node.setCountryId(IsoCode.valueOf(properties.getAddress().getCountryRegion().getIso()));
|
node.setCountryId(IsoCode.valueOf(properties.getAddress().getCountryRegion().getIso()));
|
||||||
} else {
|
} else {
|
||||||
logger.warn("Geocoding failed for address {}", node.getAddress());
|
logger.warn("Geocoding failed for address {}", node.getAddress());
|
||||||
throw new ExcelValidationError("Unable to geocode " + node.getName() + ". Please check your address or enter geo position yourself.");
|
failedGeoLookups.add(chunk.get(itemIdx));
|
||||||
|
//throw new ExcelValidationError("Unable to geocode " + node.getName() + ". Please check your address or enter geo position yourself.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Second pass: fuzzy lookup with company name for failed addresses
|
||||||
|
if (!failedGeoLookups.isEmpty()) {
|
||||||
|
logger.info("Retrying {} failed lookups with fuzzy search", failedGeoLookups.size());
|
||||||
|
int fuzzySuccessful = 0;
|
||||||
|
|
||||||
|
for (var instruction : failedGeoLookups) {
|
||||||
|
var node = instruction.getEntity();
|
||||||
|
var fuzzyResult = executeFuzzySearch(node);
|
||||||
|
|
||||||
|
if (fuzzyResult.isPresent() && fuzzyResult.get().getResults() != null
|
||||||
|
&& !fuzzyResult.get().getResults().isEmpty()) {
|
||||||
|
|
||||||
|
var result = fuzzyResult.get().getResults().getFirst();
|
||||||
|
|
||||||
|
// Score >= 0.7 means good confidence (1.0 = perfect match)
|
||||||
|
if (result.getScore() >= 7.0) {
|
||||||
|
node.setGeoLat(BigDecimal.valueOf(result.getPosition().getLat()));
|
||||||
|
node.setGeoLng(BigDecimal.valueOf(result.getPosition().getLon()));
|
||||||
|
node.setAddress(result.getAddress().getFreeformAddress());
|
||||||
|
|
||||||
|
// Update country if it differs
|
||||||
|
if (result.getAddress().getCountryCode() != null) {
|
||||||
|
try {
|
||||||
|
node.setCountryId(IsoCode.valueOf(result.getAddress().getCountryCode()));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
logger.warn("Unknown country code: {}", result.getAddress().getCountryCode());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fuzzySuccessful++;
|
||||||
|
logger.info("Fuzzy search successful for: {} (score: {})",
|
||||||
|
node.getName(), result.getScore());
|
||||||
|
} else {
|
||||||
|
logger.warn("Fuzzy search returned low confidence result for: {} (score: {})",
|
||||||
|
node.getName(), result.getScore());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.error("Fuzzy search found no results for: {}", node.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Fuzzy lookup recovered {} of {} failed addresses",
|
||||||
|
fuzzySuccessful, failedGeoLookups.size());
|
||||||
|
|
||||||
|
// Throw error for remaining failed lookups
|
||||||
|
int remainingFailed = failedGeoLookups.size() - fuzzySuccessful;
|
||||||
|
if (remainingFailed > 0) {
|
||||||
|
var firstFailed = failedGeoLookups.stream()
|
||||||
|
.filter(i -> i.getEntity().getGeoLat() == null)
|
||||||
|
.findFirst()
|
||||||
|
.map(BulkInstruction::getEntity)
|
||||||
|
.orElse(null);
|
||||||
|
|
||||||
|
if (firstFailed != null) {
|
||||||
|
throw new ExcelValidationError("Unable to geocode " + firstFailed.getName()
|
||||||
|
+ ". Please check your address or enter geo position yourself.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<FuzzySearchResponse> executeFuzzySearch(ExcelNode node) {
|
||||||
|
try {
|
||||||
|
String companyName = node.getName();
|
||||||
|
String country = node.getCountryId().name();
|
||||||
|
|
||||||
|
// Normalisiere Unicode für konsistente Suche
|
||||||
|
companyName = java.text.Normalizer.normalize(companyName, java.text.Normalizer.Form.NFC);
|
||||||
|
|
||||||
|
// URL-Encoding
|
||||||
|
String encodedQuery = URLEncoder.encode(companyName + ", " + node.getAddress() + ", " + country, StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
String url = String.format(
|
||||||
|
"https://atlas.microsoft.com/search/fuzzy/json?api-version=1.0&subscription-key=%s&query=%s&limit=5",
|
||||||
|
subscriptionKey,
|
||||||
|
encodedQuery
|
||||||
|
);
|
||||||
|
|
||||||
|
URI uri = URI.create(url);
|
||||||
|
|
||||||
|
logger.debug("Fuzzy search for: {} (normalized & encoded)", companyName);
|
||||||
|
|
||||||
|
ResponseEntity<FuzzySearchResponse> response = restTemplate.getForEntity(
|
||||||
|
uri,
|
||||||
|
FuzzySearchResponse.class
|
||||||
|
);
|
||||||
|
|
||||||
|
return Optional.ofNullable(response.getBody());
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Fuzzy search failed for {}", node.getName(), e);
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String getGeoCodeString(ExcelNode excelNode) {
|
private String getGeoCodeString(ExcelNode excelNode) {
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ public class DistanceApiService {
|
||||||
Optional<Distance> cachedDistance = distanceMatrixRepository.getDistance(from, isUsrFrom, to, isUsrTo);
|
Optional<Distance> cachedDistance = distanceMatrixRepository.getDistance(from, isUsrFrom, to, isUsrTo);
|
||||||
|
|
||||||
if (cachedDistance.isPresent() && cachedDistance.get().getState() == DistanceMatrixState.VALID) {
|
if (cachedDistance.isPresent() && cachedDistance.get().getState() == DistanceMatrixState.VALID) {
|
||||||
logger.info("Found cached distance from node {} (user: {}) to node {} (user {})", from.getExternalMappingId(), isUsrFrom, to.getExternalMappingId(), isUsrTo);
|
logger.info("Found cached distance from node {} (user: {}) to node {} (user {}) - {} meters", from.getExternalMappingId(), isUsrFrom, to.getExternalMappingId(), isUsrTo, cachedDistance.get().getDistance().doubleValue());
|
||||||
return cachedDistance;
|
return cachedDistance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,7 +89,7 @@ public class DistanceApiService {
|
||||||
distanceMatrixRepository.updateRetries(cachedDistance.get().getId());
|
distanceMatrixRepository.updateRetries(cachedDistance.get().getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("Fetching distance from Azure Maps for nodes {} to {}", from.getExternalMappingId(), to.getExternalMappingId());
|
logger.info("Fetching distance from Azure Maps for nodes {} to {}", from.getExternalMappingId() == null ? from.getName() : from.getExternalMappingId(), to.getExternalMappingId() == null ? to.getName() : to.getExternalMappingId());
|
||||||
AzureMapResponse distanceResponse = fetchDistanceFromAzureMaps(from, isUsrFrom, to, isUsrTo, true);
|
AzureMapResponse distanceResponse = fetchDistanceFromAzureMaps(from, isUsrFrom, to, isUsrTo, true);
|
||||||
|
|
||||||
if (distanceResponse.distance != null) {
|
if (distanceResponse.distance != null) {
|
||||||
|
|
@ -97,7 +97,7 @@ public class DistanceApiService {
|
||||||
return Optional.of(distanceResponse.distance);
|
return Optional.of(distanceResponse.distance);
|
||||||
}
|
}
|
||||||
|
|
||||||
if(distanceResponse.errorType != AzureMapsErrorType.NO_ERROR) {
|
if (distanceResponse.errorType != AzureMapsErrorType.NO_ERROR) {
|
||||||
distanceMatrixRepository.saveDistance(getErrorDistance(cachedDistance, from, isUsrFrom, to, isUsrTo));
|
distanceMatrixRepository.saveDistance(getErrorDistance(cachedDistance, from, isUsrFrom, to, isUsrTo));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -111,7 +111,7 @@ public class DistanceApiService {
|
||||||
distance.setUpdatedAt(LocalDateTime.now());
|
distance.setUpdatedAt(LocalDateTime.now());
|
||||||
distance.setRetries(distance.getRetries() == null ? 0 : distance.getRetries() + 1);
|
distance.setRetries(distance.getRetries() == null ? 0 : distance.getRetries() + 1);
|
||||||
|
|
||||||
if(cachedDistance.isEmpty()) {
|
if (cachedDistance.isEmpty()) {
|
||||||
distance.setFromUserNodeId(isUsrFrom ? from.getId() : null);
|
distance.setFromUserNodeId(isUsrFrom ? from.getId() : null);
|
||||||
distance.setFromNodeId(isUsrFrom ? null : from.getId());
|
distance.setFromNodeId(isUsrFrom ? null : from.getId());
|
||||||
distance.setToUserNodeId(isUsrTo ? to.getId() : null);
|
distance.setToUserNodeId(isUsrTo ? to.getId() : null);
|
||||||
|
|
@ -208,7 +208,7 @@ public class DistanceApiService {
|
||||||
String errorMessage = errorNode.path("error").path("message").asText();
|
String errorMessage = errorNode.path("error").path("message").asText();
|
||||||
|
|
||||||
logger.warn("Azure Maps API Error for nodes {} ({}) to {} ({}): {} - {}",
|
logger.warn("Azure Maps API Error for nodes {} ({}) to {} ({}): {} - {}",
|
||||||
from.getExternalMappingId(), from.getId(), to.getExternalMappingId(), to.getId(),
|
from.getExternalMappingId() == null ? from.getName() : from.getExternalMappingId(), from.getId(), to.getExternalMappingId() == null ? to.getName() : to.getExternalMappingId(), to.getId(),
|
||||||
errorCode, errorMessage);
|
errorCode, errorMessage);
|
||||||
|
|
||||||
if (errorMessage.contains("NO_ROUTE_FOUND"))
|
if (errorMessage.contains("NO_ROUTE_FOUND"))
|
||||||
|
|
|
||||||
|
|
@ -32,18 +32,16 @@ public class TaxationResolverService {
|
||||||
private final CountryRepository countryRepository;
|
private final CountryRepository countryRepository;
|
||||||
private final EUTaxationApiService eUTaxationApiService;
|
private final EUTaxationApiService eUTaxationApiService;
|
||||||
private final PropertyRepository propertyRepository;
|
private final PropertyRepository propertyRepository;
|
||||||
private final ZolltarifnummernApiService zolltarifnummernApiService;
|
|
||||||
private final NomenclatureService nomenclatureService;
|
private final NomenclatureService nomenclatureService;
|
||||||
private final CountryPropertyRepository countryPropertyRepository;
|
private final CountryPropertyRepository countryPropertyRepository;
|
||||||
|
|
||||||
private final Logger logger = LoggerFactory.getLogger(TaxationResolverService.class);
|
private final Logger logger = LoggerFactory.getLogger(TaxationResolverService.class);
|
||||||
|
|
||||||
|
|
||||||
public TaxationResolverService(CountryRepository countryRepository, EUTaxationApiService eUTaxationApiService, PropertyRepository propertyRepository, ZolltarifnummernApiService zolltarifnummernApiService, NomenclatureService nomenclatureService, CountryPropertyRepository countryPropertyRepository) {
|
public TaxationResolverService(CountryRepository countryRepository, EUTaxationApiService eUTaxationApiService, PropertyRepository propertyRepository, NomenclatureService nomenclatureService, CountryPropertyRepository countryPropertyRepository) {
|
||||||
this.countryRepository = countryRepository;
|
this.countryRepository = countryRepository;
|
||||||
this.eUTaxationApiService = eUTaxationApiService;
|
this.eUTaxationApiService = eUTaxationApiService;
|
||||||
this.propertyRepository = propertyRepository;
|
this.propertyRepository = propertyRepository;
|
||||||
this.zolltarifnummernApiService = zolltarifnummernApiService;
|
|
||||||
this.nomenclatureService = nomenclatureService;
|
this.nomenclatureService = nomenclatureService;
|
||||||
this.countryPropertyRepository = countryPropertyRepository;
|
this.countryPropertyRepository = countryPropertyRepository;
|
||||||
}
|
}
|
||||||
|
|
@ -104,19 +102,6 @@ public class TaxationResolverService {
|
||||||
return singleRequests;
|
return singleRequests;
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<TaxationResolverSingleRequest> resolveIncompleteHsCodes(List<TaxationResolverRequest> request) {
|
|
||||||
|
|
||||||
var futures = request.stream()
|
|
||||||
.collect(Collectors.toMap(
|
|
||||||
r -> r,
|
|
||||||
r -> zolltarifnummernApiService.getDeclarableHsCodes(r.material().getHsCode()))
|
|
||||||
);
|
|
||||||
|
|
||||||
CompletableFuture.allOf(futures.values().toArray(new CompletableFuture[0])).join();
|
|
||||||
|
|
||||||
return futures.keySet().stream().flatMap(k -> futures.get(k).join().stream().map(resp -> new TaxationResolverSingleRequest(resp, k.countryId(), k))).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private Map<TaxationResolverSingleRequest, GoodsMeasForWsResponse> doSingleRequests(List<TaxationResolverSingleRequest> requests) {
|
private Map<TaxationResolverSingleRequest, GoodsMeasForWsResponse> doSingleRequests(List<TaxationResolverSingleRequest> requests) {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
package de.avatic.lcc.service.api;
|
|
||||||
|
|
||||||
import de.avatic.lcc.model.zolltarifnummern.ZolltarifnummernResponse;
|
|
||||||
import de.avatic.lcc.model.zolltarifnummern.ZolltarifnummernResponseEntry;
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.scheduling.annotation.Async;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.web.client.RestTemplate;
|
|
||||||
import org.springframework.web.util.UriComponentsBuilder;
|
|
||||||
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.concurrent.CompletableFuture;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
public class ZolltarifnummernApiService {
|
|
||||||
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(ZolltarifnummernApiService.class);
|
|
||||||
|
|
||||||
private static final String API_V_1 = "https://www.zolltarifnummern.de/api/v1/cnSuggest";
|
|
||||||
private final RestTemplate restTemplate;
|
|
||||||
|
|
||||||
public ZolltarifnummernApiService(RestTemplate restTemplate) {
|
|
||||||
this.restTemplate = restTemplate;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Async("customLookupExecutor")
|
|
||||||
public CompletableFuture<Set<String>> getDeclarableHsCodes(String incompleteHsCode) {
|
|
||||||
|
|
||||||
try {
|
|
||||||
String url = UriComponentsBuilder.fromUriString(API_V_1)
|
|
||||||
.queryParam("term", incompleteHsCode)
|
|
||||||
.queryParam("lang", "en")
|
|
||||||
.encode()
|
|
||||||
.toUriString();
|
|
||||||
|
|
||||||
var resp = restTemplate.getForObject(url, ZolltarifnummernResponse.class);
|
|
||||||
|
|
||||||
if (resp != null && resp.getSuggestions() != null) {
|
|
||||||
return CompletableFuture.completedFuture(resp.getSuggestions().stream()
|
|
||||||
.map(ZolltarifnummernResponseEntry::getCode)
|
|
||||||
.filter(s -> s.startsWith(incompleteHsCode))
|
|
||||||
.filter(s -> s.length() >= 10).collect(Collectors.toSet()));
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (Throwable t) {
|
|
||||||
logger.error("HS code lookup failed with exception \"{}\"", t.getMessage());
|
|
||||||
// just continue
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.warn("Unable to load tarif numbers for HS code {}", incompleteHsCode);
|
|
||||||
|
|
||||||
return CompletableFuture.completedFuture(Collections.emptySet());
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +1,23 @@
|
||||||
package de.avatic.lcc.service.apps;
|
package de.avatic.lcc.service.apps;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import de.avatic.lcc.dto.configuration.apps.AppDTO;
|
import de.avatic.lcc.dto.configuration.apps.AppDTO;
|
||||||
|
import de.avatic.lcc.dto.configuration.apps.AppExchangeDTO;
|
||||||
import de.avatic.lcc.model.db.users.App;
|
import de.avatic.lcc.model.db.users.App;
|
||||||
import de.avatic.lcc.repositories.users.AppRepository;
|
import de.avatic.lcc.repositories.users.AppRepository;
|
||||||
import de.avatic.lcc.service.transformer.apps.AppTransformer;
|
import de.avatic.lcc.service.transformer.apps.AppTransformer;
|
||||||
|
import de.avatic.lcc.util.exception.base.InternalErrorException;
|
||||||
|
import io.jsonwebtoken.security.Keys;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.security.SecureRandom;
|
import javax.crypto.*;
|
||||||
|
import javax.crypto.spec.GCMParameterSpec;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.*;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
@ -16,14 +26,31 @@ import java.util.UUID;
|
||||||
@Service
|
@Service
|
||||||
public class AppsService {
|
public class AppsService {
|
||||||
|
|
||||||
|
|
||||||
|
private static final String HMAC_ALGORITHM = "HmacSHA256";
|
||||||
|
private static final String ENCRYPTION_ALGORITHM = "AES/GCM/NoPadding";
|
||||||
|
private static final int GCM_TAG_LENGTH = 128;
|
||||||
|
private static final int GCM_IV_LENGTH = 12;
|
||||||
private final AppRepository appRepository;
|
private final AppRepository appRepository;
|
||||||
private final AppTransformer appTransformer;
|
private final AppTransformer appTransformer;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final Key signingKey;
|
||||||
|
private final SecretKeySpec encryptionKey;
|
||||||
|
|
||||||
public AppsService(AppRepository appRepository, AppTransformer appTransformer, PasswordEncoder passwordEncoder) {
|
|
||||||
|
public AppsService(@Value("${jwt.secret}") String secret, AppRepository appRepository, AppTransformer appTransformer, PasswordEncoder passwordEncoder, ObjectMapper objectMapper) {
|
||||||
this.appRepository = appRepository;
|
this.appRepository = appRepository;
|
||||||
this.appTransformer = appTransformer;
|
this.appTransformer = appTransformer;
|
||||||
this.passwordEncoder = passwordEncoder;
|
this.passwordEncoder = passwordEncoder;
|
||||||
|
this.signingKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
|
||||||
|
// AES-256 Key aus JWT Secret ableiten
|
||||||
|
byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8);
|
||||||
|
byte[] aesKey = new byte[32]; // 256 bit
|
||||||
|
System.arraycopy(keyBytes, 0, aesKey, 0, Math.min(keyBytes.length, 32));
|
||||||
|
this.encryptionKey = new SecretKeySpec(aesKey, "AES");
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<AppDTO> listApps() {
|
public List<AppDTO> listApps() {
|
||||||
|
|
@ -35,7 +62,7 @@ public class AppsService {
|
||||||
var newApp = dto.getId() == null;
|
var newApp = dto.getId() == null;
|
||||||
String appSecret = null;
|
String appSecret = null;
|
||||||
|
|
||||||
if(newApp) {
|
if (newApp) {
|
||||||
dto.setClientId(generateAppId());
|
dto.setClientId(generateAppId());
|
||||||
appSecret = generateAppSecret();
|
appSecret = generateAppSecret();
|
||||||
dto.setClientSecret(passwordEncoder.encode(appSecret));
|
dto.setClientSecret(passwordEncoder.encode(appSecret));
|
||||||
|
|
@ -43,7 +70,7 @@ public class AppsService {
|
||||||
|
|
||||||
var id = appRepository.update(appTransformer.toAppEntity(dto));
|
var id = appRepository.update(appTransformer.toAppEntity(dto));
|
||||||
|
|
||||||
if(newApp) {
|
if (newApp) {
|
||||||
dto.setId(id);
|
dto.setId(id);
|
||||||
dto.setClientSecret(appSecret);
|
dto.setClientSecret(appSecret);
|
||||||
}
|
}
|
||||||
|
|
@ -79,4 +106,103 @@ public class AppsService {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public AppExchangeDTO exportApp(Integer id) {
|
||||||
|
var app = appRepository.getById(id).map(appTransformer::toAppDTOWithHashedSecret);
|
||||||
|
|
||||||
|
if (app.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("App mit ID " + id + " nicht gefunden");
|
||||||
|
}
|
||||||
|
AppExchangeDTO exchangeDTO = new AppExchangeDTO();
|
||||||
|
try {
|
||||||
|
|
||||||
|
String json = objectMapper.writeValueAsString(app);
|
||||||
|
byte[] jsonBytes = json.getBytes(StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
Cipher cipher = Cipher.getInstance(ENCRYPTION_ALGORITHM);
|
||||||
|
byte[] iv = new byte[GCM_IV_LENGTH];
|
||||||
|
new SecureRandom().nextBytes(iv);
|
||||||
|
GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, encryptionKey, gcmSpec);
|
||||||
|
byte[] encryptedData = cipher.doFinal(jsonBytes);
|
||||||
|
|
||||||
|
Mac mac = Mac.getInstance(HMAC_ALGORITHM);
|
||||||
|
mac.init(new SecretKeySpec(signingKey.getEncoded(), HMAC_ALGORITHM));
|
||||||
|
mac.update(iv);
|
||||||
|
mac.update(encryptedData);
|
||||||
|
byte[] signature = mac.doFinal();
|
||||||
|
|
||||||
|
byte[] bundle = new byte[iv.length + encryptedData.length + signature.length];
|
||||||
|
System.arraycopy(iv, 0, bundle, 0, iv.length);
|
||||||
|
System.arraycopy(encryptedData, 0, bundle, iv.length, encryptedData.length);
|
||||||
|
System.arraycopy(signature, 0, bundle, iv.length + encryptedData.length, signature.length);
|
||||||
|
|
||||||
|
|
||||||
|
exchangeDTO.setData(Base64.getEncoder().encodeToString(bundle));
|
||||||
|
} catch (JsonProcessingException | NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException |
|
||||||
|
InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException _) {
|
||||||
|
throw new InternalErrorException("Fehler beim Exportieren der App");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return exchangeDTO;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean importApp(AppExchangeDTO exchangeDTO) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
byte[] bundle = Base64.getDecoder().decode(exchangeDTO.getData());
|
||||||
|
|
||||||
|
// 2. Bundle aufteilen
|
||||||
|
if (bundle.length < GCM_IV_LENGTH + 32) {
|
||||||
|
throw new IllegalArgumentException("Ungültiges Export-Bundle");
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] iv = new byte[GCM_IV_LENGTH];
|
||||||
|
byte[] signature = new byte[32];
|
||||||
|
byte[] encryptedData = new byte[bundle.length - GCM_IV_LENGTH - 32];
|
||||||
|
|
||||||
|
System.arraycopy(bundle, 0, iv, 0, GCM_IV_LENGTH);
|
||||||
|
System.arraycopy(bundle, GCM_IV_LENGTH, encryptedData, 0, encryptedData.length);
|
||||||
|
System.arraycopy(bundle, GCM_IV_LENGTH + encryptedData.length, signature, 0, 32);
|
||||||
|
|
||||||
|
// 3. Signatur verifizieren
|
||||||
|
Mac mac = Mac.getInstance(HMAC_ALGORITHM);
|
||||||
|
mac.init(new SecretKeySpec(signingKey.getEncoded(), HMAC_ALGORITHM));
|
||||||
|
mac.update(iv);
|
||||||
|
mac.update(encryptedData);
|
||||||
|
byte[] expectedSignature = mac.doFinal();
|
||||||
|
|
||||||
|
if (!java.security.MessageDigest.isEqual(signature, expectedSignature)) {
|
||||||
|
throw new SecurityException("Ungültige Signatur - Daten wurden manipuliert oder stammen nicht von dieser Instanz");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Entschlüsseln
|
||||||
|
Cipher cipher = Cipher.getInstance(ENCRYPTION_ALGORITHM);
|
||||||
|
GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, encryptionKey, gcmSpec);
|
||||||
|
byte[] decryptedData = cipher.doFinal(encryptedData);
|
||||||
|
|
||||||
|
// 5. JSON deserialisieren
|
||||||
|
String json = new String(decryptedData, StandardCharsets.UTF_8);
|
||||||
|
AppDTO dto = objectMapper.readValue(json, AppDTO.class);
|
||||||
|
|
||||||
|
// 6. Prüfen ob App mit dieser Client-ID bereits existiert
|
||||||
|
var existingApp = appRepository.getByClientId(dto.getClientId());
|
||||||
|
if (existingApp.isPresent()) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"App mit Client-ID '" + dto.getClientId() + "' existiert bereits"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
App app = appTransformer.toAppEntityWithHashedSecret(dto);
|
||||||
|
appRepository.update(app);
|
||||||
|
} catch (JsonProcessingException | NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException |
|
||||||
|
InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException _) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import de.avatic.lcc.repositories.premise.PremiseRepository;
|
||||||
import de.avatic.lcc.repositories.premise.RouteRepository;
|
import de.avatic.lcc.repositories.premise.RouteRepository;
|
||||||
import de.avatic.lcc.repositories.premise.RouteSectionRepository;
|
import de.avatic.lcc.repositories.premise.RouteSectionRepository;
|
||||||
import de.avatic.lcc.repositories.properties.PropertyRepository;
|
import de.avatic.lcc.repositories.properties.PropertyRepository;
|
||||||
|
import de.avatic.lcc.service.access.PropertyService;
|
||||||
import de.avatic.lcc.service.calculation.execution.steps.*;
|
import de.avatic.lcc.service.calculation.execution.steps.*;
|
||||||
import de.avatic.lcc.service.precalculation.PostCalculationCheckService;
|
import de.avatic.lcc.service.precalculation.PostCalculationCheckService;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
|
|
@ -51,9 +52,10 @@ public class CalculationExecutionService {
|
||||||
private final PostCalculationCheckService postCalculationCheckService;
|
private final PostCalculationCheckService postCalculationCheckService;
|
||||||
private final CalculationJobDestinationRepository calculationJobDestinationRepository;
|
private final CalculationJobDestinationRepository calculationJobDestinationRepository;
|
||||||
private final CalculationJobRouteSectionRepository calculationJobRouteSectionRepository;
|
private final CalculationJobRouteSectionRepository calculationJobRouteSectionRepository;
|
||||||
|
private final PropertyService propertyService;
|
||||||
|
|
||||||
|
|
||||||
public CalculationExecutionService(PremiseRepository premiseRepository, DestinationRepository destinationRepository, RouteRepository routeRepository, RouteSectionRepository routeSectionRepository, CustomCostCalculationService customCostCalculationService, RouteSectionCostCalculationService routeSectionCostCalculationService, HandlingCostCalculationService handlingCostCalculationService, InventoryCostCalculationService inventoryCostCalculationService, PropertyRepository propertyRepository, AirfreightCalculationService airfreightCalculationService, PremiseToHuService premiseToHuService, ContainerCalculationService containerCalculationService, ShippingFrequencyCalculationService shippingFrequencyCalculationService, PostCalculationCheckService postCalculationCheckService, CalculationJobDestinationRepository calculationJobDestinationRepository, CalculationJobRouteSectionRepository calculationJobRouteSectionRepository) {
|
public CalculationExecutionService(PremiseRepository premiseRepository, DestinationRepository destinationRepository, RouteRepository routeRepository, RouteSectionRepository routeSectionRepository, CustomCostCalculationService customCostCalculationService, RouteSectionCostCalculationService routeSectionCostCalculationService, HandlingCostCalculationService handlingCostCalculationService, InventoryCostCalculationService inventoryCostCalculationService, PropertyRepository propertyRepository, AirfreightCalculationService airfreightCalculationService, PremiseToHuService premiseToHuService, ContainerCalculationService containerCalculationService, ShippingFrequencyCalculationService shippingFrequencyCalculationService, PostCalculationCheckService postCalculationCheckService, CalculationJobDestinationRepository calculationJobDestinationRepository, CalculationJobRouteSectionRepository calculationJobRouteSectionRepository, PropertyService propertyService) {
|
||||||
this.premiseRepository = premiseRepository;
|
this.premiseRepository = premiseRepository;
|
||||||
this.destinationRepository = destinationRepository;
|
this.destinationRepository = destinationRepository;
|
||||||
this.routeRepository = routeRepository;
|
this.routeRepository = routeRepository;
|
||||||
|
|
@ -70,6 +72,7 @@ public class CalculationExecutionService {
|
||||||
this.postCalculationCheckService = postCalculationCheckService;
|
this.postCalculationCheckService = postCalculationCheckService;
|
||||||
this.calculationJobDestinationRepository = calculationJobDestinationRepository;
|
this.calculationJobDestinationRepository = calculationJobDestinationRepository;
|
||||||
this.calculationJobRouteSectionRepository = calculationJobRouteSectionRepository;
|
this.calculationJobRouteSectionRepository = calculationJobRouteSectionRepository;
|
||||||
|
this.propertyService = propertyService;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ContainerType getBestContainerType(Map<ContainerType, List<SectionInfo>> sectionResults) {
|
private static ContainerType getBestContainerType(Map<ContainerType, List<SectionInfo>> sectionResults) {
|
||||||
|
|
@ -137,15 +140,19 @@ public class CalculationExecutionService {
|
||||||
AirfreightResult airfreightCost = airfreightCalculationService.doCalculation(setId, periodId, premise, destination);
|
AirfreightResult airfreightCost = airfreightCalculationService.doCalculation(setId, periodId, premise, destination);
|
||||||
ContainerType usedContainerType = null;
|
ContainerType usedContainerType = null;
|
||||||
|
|
||||||
|
ContainerCalculationResult selectedContainerCalculation = null;
|
||||||
|
|
||||||
CalculationJobDestination destinationCalculationJob = new CalculationJobDestination();
|
CalculationJobDestination destinationCalculationJob = new CalculationJobDestination();
|
||||||
boolean hasMainRun = true;
|
boolean hasMainRun = true;
|
||||||
BigDecimal leadTime = null;
|
BigDecimal leadTime = null;
|
||||||
|
boolean isWeightExceeded = false;
|
||||||
|
|
||||||
if (destination.getD2d()) {
|
if (destination.getD2d()) {
|
||||||
var containerCalculation = containerCalculationService.doCalculation(setId, premiseToHuService.createHuFromPremise(premise), ContainerType.FEU, premise.getHuMixable());
|
selectedContainerCalculation = containerCalculationService.doCalculation(setId, premiseToHuService.createHuFromPremise(premise), ContainerType.FEU, premise.getHuMixable(), premise.getHuStackable());
|
||||||
sections = List.of(new SectionInfo(null, routeSectionCostCalculationService.doD2dCalculation(setId, periodId, premise, destination, containerCalculation), containerCalculation));
|
sections = List.of(new SectionInfo(null, routeSectionCostCalculationService.doD2dCalculation(setId, periodId, premise, destination, selectedContainerCalculation), selectedContainerCalculation));
|
||||||
leadTime = BigDecimal.valueOf(destination.getLeadTimeD2d());
|
leadTime = BigDecimal.valueOf(destination.getLeadTimeD2d());
|
||||||
usedContainerType = ContainerType.FEU;
|
usedContainerType = ContainerType.FEU;
|
||||||
|
isWeightExceeded = sections.getFirst().result().isWeightPrice();
|
||||||
} else {
|
} else {
|
||||||
var bestContainerTypeResult = getSectionsFromBestContainerType(setId, periodId, destination, premise);
|
var bestContainerTypeResult = getSectionsFromBestContainerType(setId, periodId, destination, premise);
|
||||||
sections = bestContainerTypeResult.sections;
|
sections = bestContainerTypeResult.sections;
|
||||||
|
|
@ -160,7 +167,20 @@ public class CalculationExecutionService {
|
||||||
s.result().setPreRun(false);
|
s.result().setPreRun(false);
|
||||||
s.result().setPostRun(false);
|
s.result().setPostRun(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var containerSections = sections.stream().filter(s -> s.section().getRateType() != RateType.MATRIX).toList();
|
||||||
|
|
||||||
|
if(containerSections.size() > 1) {
|
||||||
|
isWeightExceeded = containerSections.stream().anyMatch(s -> s.result().isWeightPrice());
|
||||||
|
} else {
|
||||||
|
isWeightExceeded = sections.getFirst().result().isWeightPrice();
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
isWeightExceeded = sections.stream().map(SectionInfo::result).filter(CalculationJobRouteSection::getMainRun).anyMatch(CalculationJobRouteSection::isWeightPrice);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
selectedContainerCalculation = bestContainerTypeResult.selectedContainerCalculation;
|
||||||
}
|
}
|
||||||
|
|
||||||
destinationCalculationJob.setD2D(destination.getD2d());
|
destinationCalculationJob.setD2D(destination.getD2d());
|
||||||
|
|
@ -168,9 +188,9 @@ public class CalculationExecutionService {
|
||||||
if(destination.getD2d())
|
if(destination.getD2d())
|
||||||
destinationCalculationJob.setRateD2D(destination.getRateD2d());
|
destinationCalculationJob.setRateD2D(destination.getRateD2d());
|
||||||
|
|
||||||
customCost = customCostCalculationService.doCalculation(setId, premise, destination, sections);
|
customCost = customCostCalculationService.doCalculation(setId, premise, destination, sections, selectedContainerCalculation);
|
||||||
handlingCost = handlingCostCalculationService.doCalculation(setId, premise, destination, hasMainRun);
|
handlingCost = handlingCostCalculationService.doCalculation(setId, premise, destination, hasMainRun, selectedContainerCalculation);
|
||||||
inventoryCost = inventoryCostCalculationService.doCalculation(setId, premise, destination, leadTime);
|
inventoryCost = inventoryCostCalculationService.doCalculation(setId, premise, destination, leadTime, selectedContainerCalculation);
|
||||||
|
|
||||||
destinationCalculationJob.setContainerType(usedContainerType);
|
destinationCalculationJob.setContainerType(usedContainerType);
|
||||||
|
|
||||||
|
|
@ -203,16 +223,20 @@ public class CalculationExecutionService {
|
||||||
destinationCalculationJob.setAnnualCustomCost(customCost.getAnnualCost());
|
destinationCalculationJob.setAnnualCustomCost(customCost.getAnnualCost());
|
||||||
|
|
||||||
destinationCalculationJob.setAnnualTransportationCost(sections.stream().map(SectionInfo::result).map(CalculationJobRouteSection::getAnnualCost).reduce(BigDecimal.ZERO, BigDecimal::add));
|
destinationCalculationJob.setAnnualTransportationCost(sections.stream().map(SectionInfo::result).map(CalculationJobRouteSection::getAnnualCost).reduce(BigDecimal.ZERO, BigDecimal::add));
|
||||||
destinationCalculationJob.setTransportWeightExceeded(sections.stream().map(SectionInfo::result).filter(CalculationJobRouteSection::getMainRun).anyMatch(CalculationJobRouteSection::isWeightPrice));
|
destinationCalculationJob.setTransportWeightExceeded(isWeightExceeded);
|
||||||
destinationCalculationJob.setLayerCount(sections.getFirst().containerResult().getLayer());
|
destinationCalculationJob.setLayerCount(sections.getFirst().containerResult().getLayer());
|
||||||
destinationCalculationJob.setLayerStructure(null); //TODO generate layer structure
|
destinationCalculationJob.setLayerStructure(null); //TODO generate layer structure
|
||||||
destinationCalculationJob.setHuCount(sections.getFirst().containerResult().getHuUnitCount());
|
destinationCalculationJob.setHuCount(sections.getFirst().containerResult().getHuUnitCount());
|
||||||
|
|
||||||
destinationCalculationJob.setAnnualAmount(BigDecimal.valueOf(destination.getAnnualAmount()));
|
destinationCalculationJob.setAnnualAmount(BigDecimal.valueOf(destination.getAnnualAmount()));
|
||||||
destinationCalculationJob.setShippingFrequency(shippingFrequencyCalculationService.doCalculation(setId, destination.getAnnualAmount()));
|
|
||||||
|
|
||||||
|
double huAnnualAmount = BigDecimal.valueOf(destination.getAnnualAmount()).divide(BigDecimal.valueOf(premise.getHuUnitCount()),4, RoundingMode.UP ).doubleValue();
|
||||||
|
destinationCalculationJob.setShippingFrequency((int) Math.round(shippingFrequencyCalculationService.doCalculation(setId, huAnnualAmount, selectedContainerCalculation.getHuPerContainer(), !premise.getHuMixable())));
|
||||||
|
|
||||||
var commonCost = destinationCalculationJob.getAnnualHandlingCost()
|
var commonCost = destinationCalculationJob.getAnnualHandlingCost()
|
||||||
.add(destinationCalculationJob.getAnnualDisposalCost())
|
.add(destinationCalculationJob.getAnnualDisposalCost())
|
||||||
|
.add(destinationCalculationJob.getAnnualRepackingCost())
|
||||||
.add(destinationCalculationJob.getAnnualCapitalCost())
|
.add(destinationCalculationJob.getAnnualCapitalCost())
|
||||||
.add(destinationCalculationJob.getAnnualStorageCost())
|
.add(destinationCalculationJob.getAnnualStorageCost())
|
||||||
.add(materialCost.multiply(BigDecimal.valueOf(destination.getAnnualAmount())))
|
.add(materialCost.multiply(BigDecimal.valueOf(destination.getAnnualAmount())))
|
||||||
|
|
@ -238,6 +262,16 @@ public class CalculationExecutionService {
|
||||||
private BestContainerTypeResult getSectionsFromBestContainerType(Integer setId, Integer periodId, Destination destination, Premise premise) {
|
private BestContainerTypeResult getSectionsFromBestContainerType(Integer setId, Integer periodId, Destination destination, Premise premise) {
|
||||||
PackagingDimension hu = premiseToHuService.createHuFromPremise(premise);
|
PackagingDimension hu = premiseToHuService.createHuFromPremise(premise);
|
||||||
|
|
||||||
|
Map<ContainerType, Boolean> active = new HashMap<>() {
|
||||||
|
{
|
||||||
|
put(ContainerType.TRUCK, true);
|
||||||
|
put(ContainerType.FEU, (Boolean)propertyService.getProperty(SystemPropertyMappingId.FEU, setId).orElse(true));
|
||||||
|
put(ContainerType.TEU, (Boolean)propertyService.getProperty(SystemPropertyMappingId.TEU, setId).orElse(true));
|
||||||
|
put(ContainerType.HC, (Boolean)propertyService.getProperty(SystemPropertyMappingId.FEU_HQ, setId).orElse(true));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
var route = routeRepository.getSelectedByDestinationId(destination.getId()).orElseThrow();
|
var route = routeRepository.getSelectedByDestinationId(destination.getId()).orElseThrow();
|
||||||
List<RouteSection> routeSections = routeSectionRepository.getByRouteId(route.getId());
|
List<RouteSection> routeSections = routeSectionRepository.getByRouteId(route.getId());
|
||||||
|
|
||||||
|
|
@ -246,11 +280,12 @@ public class CalculationExecutionService {
|
||||||
|
|
||||||
// Get container calculation
|
// Get container calculation
|
||||||
for (var containerType : ContainerType.values()) {
|
for (var containerType : ContainerType.values()) {
|
||||||
containerCalculation.put(containerType, containerCalculationService.doCalculation(setId, hu, containerType, premise.getHuMixable()));
|
if (!active.get(containerType)) continue;
|
||||||
|
containerCalculation.put(containerType, containerCalculationService.doCalculation(setId, hu, containerType, premise.getHuMixable(), premise.getHuStackable()));
|
||||||
}
|
}
|
||||||
|
|
||||||
for (var containerType : ContainerType.values()) {
|
for (var containerType : ContainerType.values()) {
|
||||||
if (!containerType.equals(ContainerType.TRUCK)) {
|
if (!containerType.equals(ContainerType.TRUCK) && active.get(containerType)) {
|
||||||
|
|
||||||
var sectionInfo = new ArrayList<SectionInfo>();
|
var sectionInfo = new ArrayList<SectionInfo>();
|
||||||
|
|
||||||
|
|
@ -264,10 +299,10 @@ public class CalculationExecutionService {
|
||||||
|
|
||||||
}
|
}
|
||||||
var bestContainerType = getBestContainerType(sectionInfos);
|
var bestContainerType = getBestContainerType(sectionInfos);
|
||||||
return new BestContainerTypeResult(bestContainerType, sectionInfos.get(bestContainerType));
|
return new BestContainerTypeResult(bestContainerType, sectionInfos.get(bestContainerType), containerCalculation.get(bestContainerType));
|
||||||
}
|
}
|
||||||
|
|
||||||
private record BestContainerTypeResult(ContainerType containerType, List<SectionInfo> sections) {
|
private record BestContainerTypeResult(ContainerType containerType, List<SectionInfo> sections, ContainerCalculationResult selectedContainerCalculation) {
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,8 +70,8 @@ public class CalculationJobProcessorManagementService {
|
||||||
validPeriod = validityPeriodRepository.getByDate(request.getDate());
|
validPeriod = validityPeriodRepository.getByDate(request.getDate());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
boolean multiRequest = request.getPremiseIds().size() > 1;
|
||||||
request.getPremiseIds().forEach(premiseId -> preCalculationCheckService.doPrecheck(premiseId, validSet, validPeriod, request.getDate()));
|
request.getPremiseIds().forEach(premiseId -> preCalculationCheckService.doPrecheck(premiseId, validSet, validPeriod, request.getDate(), multiRequest));
|
||||||
|
|
||||||
|
|
||||||
if (validSet.isEmpty() || validPeriod.isEmpty())
|
if (validSet.isEmpty() || validPeriod.isEmpty())
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ public class ContainerCalculationService {
|
||||||
* @param containerType The type of container to be loaded
|
* @param containerType The type of container to be loaded
|
||||||
* @return ContainerCalculationResult containing loading pattern and capacity information
|
* @return ContainerCalculationResult containing loading pattern and capacity information
|
||||||
*/
|
*/
|
||||||
public ContainerCalculationResult doCalculation(Integer setId, PackagingDimension hu, ContainerType containerType, boolean mixable) {
|
public ContainerCalculationResult doCalculation(Integer setId, PackagingDimension hu, ContainerType containerType, boolean mixable, boolean stackable) {
|
||||||
|
|
||||||
var weightInKg = BigDecimal.valueOf(WeightUnit.KG.convertFromG(hu.getWeight()));
|
var weightInKg = BigDecimal.valueOf(WeightUnit.KG.convertFromG(hu.getWeight()));
|
||||||
var maxContainerLoad = BigDecimal.valueOf(getMaxContainerLoad(containerType, setId));
|
var maxContainerLoad = BigDecimal.valueOf(getMaxContainerLoad(containerType, setId));
|
||||||
|
|
@ -60,7 +60,7 @@ public class ContainerCalculationService {
|
||||||
var solutionHorizontal = solveLayer(SolutionType.HORIZONTAL, dimensions, containerType.getLength(), containerType.getWidth());
|
var solutionHorizontal = solveLayer(SolutionType.HORIZONTAL, dimensions, containerType.getLength(), containerType.getWidth());
|
||||||
var solutionVertical = solveLayer(SolutionType.VERTICAL, dimensions, containerType.getWidth(), containerType.getLength());
|
var solutionVertical = solveLayer(SolutionType.VERTICAL, dimensions, containerType.getWidth(), containerType.getLength());
|
||||||
var bestSolution = solutionHorizontal.getTotal() < solutionVertical.getTotal() ? solutionVertical : solutionHorizontal;
|
var bestSolution = solutionHorizontal.getTotal() < solutionVertical.getTotal() ? solutionVertical : solutionHorizontal;
|
||||||
int layers = mixable ? getLayerCount(dimensions, containerType) : 1;
|
int layers = stackable ? getLayerCount(dimensions, containerType) : 1;
|
||||||
|
|
||||||
if(PalletType.EURO_PALLET.fitsOn(hu) && bestSolution.getTotal() < containerType.getPalletCount(PalletType.EURO_PALLET)) {
|
if(PalletType.EURO_PALLET.fitsOn(hu) && bestSolution.getTotal() < containerType.getPalletCount(PalletType.EURO_PALLET)) {
|
||||||
return new ContainerCalculationResult(Math.min(containerType.getPalletCount(PalletType.EURO_PALLET)*layers,maxUnitByWeight), layers, null, (containerType.getPalletCount(PalletType.EURO_PALLET)*layers) > maxUnitByWeight, containerType, dimensions, maxContainerLoad.intValueExact());
|
return new ContainerCalculationResult(Math.min(containerType.getPalletCount(PalletType.EURO_PALLET)*layers,maxUnitByWeight), layers, null, (containerType.getPalletCount(PalletType.EURO_PALLET)*layers) > maxUnitByWeight, containerType, dimensions, maxContainerLoad.intValueExact());
|
||||||
|
|
|
||||||
|
|
@ -37,18 +37,7 @@ public class CustomCostCalculationService {
|
||||||
this.shippingFrequencyCalculationService = shippingFrequencyCalculationService;
|
this.shippingFrequencyCalculationService = shippingFrequencyCalculationService;
|
||||||
}
|
}
|
||||||
|
|
||||||
private BigDecimal getContainerShare(Premise premise, ContainerCalculationResult containerCalculationResult) {
|
public CustomResult doCalculation(Integer setId, Premise premise, Destination destination, List<SectionInfo> sections, ContainerCalculationResult containerCalculationResult) {
|
||||||
var weightExceeded = containerCalculationResult.isWeightExceeded();
|
|
||||||
var mixable = premise.getHuMixable();
|
|
||||||
|
|
||||||
if (mixable) {
|
|
||||||
return BigDecimal.valueOf(weightExceeded ? containerCalculationResult.getHuUtilizationByWeight() : containerCalculationResult.getHuUtilizationByVolume());
|
|
||||||
} else {
|
|
||||||
return BigDecimal.ONE.divide(BigDecimal.valueOf(containerCalculationResult.getHuUnitCount()), 10, RoundingMode.HALF_UP);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public CustomResult doCalculation(Integer setId, Premise premise, Destination destination, List<SectionInfo> sections) {
|
|
||||||
|
|
||||||
var destUnion = countryPropertyRepository.getByMappingIdAndCountryId(CountryPropertyMappingId.UNION, setId, destination.getCountryId()).orElseThrow();
|
var destUnion = countryPropertyRepository.getByMappingIdAndCountryId(CountryPropertyMappingId.UNION, setId, destination.getCountryId()).orElseThrow();
|
||||||
var sourceUnion = countryPropertyRepository.getByMappingIdAndCountryId(CountryPropertyMappingId.UNION, setId, premise.getCountryId()).orElseThrow();
|
var sourceUnion = countryPropertyRepository.getByMappingIdAndCountryId(CountryPropertyMappingId.UNION, setId, premise.getCountryId()).orElseThrow();
|
||||||
|
|
@ -62,16 +51,17 @@ public class CustomCostCalculationService {
|
||||||
var transportationRiskCost = relevantSections.stream().map(s -> s.result().getAnnualRiskCost()).reduce(BigDecimal.ZERO, BigDecimal::add);
|
var transportationRiskCost = relevantSections.stream().map(s -> s.result().getAnnualRiskCost()).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
|
|
||||||
|
|
||||||
double huAnnualAmount = BigDecimal.valueOf(destination.getAnnualAmount()).divide(BigDecimal.valueOf(relevantSections.getFirst().containerResult().getHuUnitCount()),2, RoundingMode.HALF_UP).doubleValue();
|
double huAnnualAmount = BigDecimal.valueOf(destination.getAnnualAmount()).divide(BigDecimal.valueOf(premise.getHuUnitCount()),0, RoundingMode.CEILING).doubleValue();
|
||||||
|
|
||||||
return getCustomCalculationResult(setId, premise, destination, getContainerShare(premise, relevantSections.getFirst().containerResult()), huAnnualAmount, transportationCost, transportationChanceCost, transportationRiskCost);
|
return getCustomCalculationResult(setId, premise, destination, huAnnualAmount, transportationCost, transportationChanceCost, transportationRiskCost, containerCalculationResult);
|
||||||
}
|
}
|
||||||
|
|
||||||
return CustomResult.EMPTY;
|
return CustomResult.EMPTY;
|
||||||
}
|
}
|
||||||
|
|
||||||
private CustomResult getCustomCalculationResult(Integer setId, Premise premise, Destination destination, BigDecimal containerShare, double huAnnualAmount, BigDecimal transportationCost, BigDecimal transportationChanceCost, BigDecimal transportationRiskCost) {
|
private CustomResult getCustomCalculationResult(Integer setId, Premise premise, Destination destination, double huAnnualAmount, BigDecimal transportationCost, BigDecimal transportationChanceCost, BigDecimal transportationRiskCost, ContainerCalculationResult containerCalculationResult) {
|
||||||
var shippingFrequency = shippingFrequencyCalculationService.doCalculation(setId, huAnnualAmount);
|
|
||||||
|
var shippingFrequency = shippingFrequencyCalculationService.doCalculation(setId, huAnnualAmount, containerCalculationResult.getHuPerContainer(), !premise.getHuMixable());
|
||||||
var customFee = Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.CUSTOM_FEE, setId).orElseThrow().getCurrentValue());
|
var customFee = Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.CUSTOM_FEE, setId).orElseThrow().getCurrentValue());
|
||||||
|
|
||||||
var tariffRate = premise.getTariffRate();
|
var tariffRate = premise.getTariffRate();
|
||||||
|
|
@ -87,7 +77,7 @@ public class CustomCostCalculationService {
|
||||||
|
|
||||||
var customValue = materialCost.add(fcaFee).add(transportationCost);
|
var customValue = materialCost.add(fcaFee).add(transportationCost);
|
||||||
var customDuties = customValue.multiply(tariffRate);
|
var customDuties = customValue.multiply(tariffRate);
|
||||||
var annualCustomFee = BigDecimal.valueOf(shippingFrequency).multiply(BigDecimal.valueOf(customFee)).multiply(containerShare);
|
var annualCustomFee = BigDecimal.valueOf(shippingFrequency).multiply(BigDecimal.valueOf(customFee));
|
||||||
var annualCost = customDuties.add(annualCustomFee);
|
var annualCost = customDuties.add(annualCustomFee);
|
||||||
|
|
||||||
var customRiskValue = materialCost.add(fcaFee).add(transportationRiskCost);
|
var customRiskValue = materialCost.add(fcaFee).add(transportationRiskCost);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package de.avatic.lcc.service.calculation.execution.steps;
|
package de.avatic.lcc.service.calculation.execution.steps;
|
||||||
|
|
||||||
|
import de.avatic.lcc.model.calculation.ContainerCalculationResult;
|
||||||
import de.avatic.lcc.model.calculation.HandlingResult;
|
import de.avatic.lcc.model.calculation.HandlingResult;
|
||||||
import de.avatic.lcc.model.db.packaging.LoadCarrierType;
|
import de.avatic.lcc.model.db.packaging.LoadCarrierType;
|
||||||
import de.avatic.lcc.model.db.packaging.PackagingDimension;
|
import de.avatic.lcc.model.db.packaging.PackagingDimension;
|
||||||
|
|
@ -13,6 +14,7 @@ import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.math.RoundingMode;
|
import java.math.RoundingMode;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class HandlingCostCalculationService {
|
public class HandlingCostCalculationService {
|
||||||
|
|
@ -30,32 +32,46 @@ public class HandlingCostCalculationService {
|
||||||
this.shippingFrequencyCalculationService = shippingFrequencyCalculationService;
|
this.shippingFrequencyCalculationService = shippingFrequencyCalculationService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public HandlingResult doCalculation(Integer setId, Premise premise, Destination destination, Boolean addRepackingAndDisposalCost) {
|
public HandlingResult doCalculation(Integer setId, Premise premise, Destination destination, Boolean addRepackingAndDisposalCost, ContainerCalculationResult containerCalculationResult) {
|
||||||
|
|
||||||
var hu = premiseToHuService.createHuFromPremise(premise);
|
var hu = premiseToHuService.createHuFromPremise(premise);
|
||||||
return (LoadCarrierType.SLC == hu.getLoadCarrierType() ? getSLCCost(setId, destination, hu, hu.getLoadCarrierType(), addRepackingAndDisposalCost) : getLLCCost(setId, destination, hu, hu.getLoadCarrierType(), addRepackingAndDisposalCost));
|
return (LoadCarrierType.SLC == hu.getLoadCarrierType() ? getSLCCost(setId, premise, destination, hu, hu.getLoadCarrierType(), addRepackingAndDisposalCost, containerCalculationResult) : getLLCCost(setId, premise, destination, hu, hu.getLoadCarrierType(), addRepackingAndDisposalCost, containerCalculationResult));
|
||||||
}
|
}
|
||||||
|
|
||||||
private HandlingResult getSLCCost(Integer setId, Destination destination, PackagingDimension hu, LoadCarrierType loadCarrierType, boolean addRepackingAndDisposalCost) {
|
private HandlingResult getSLCCost(Integer setId, Premise premise, Destination destination, PackagingDimension hu, LoadCarrierType loadCarrierType, boolean addRepackingAndDisposalCost, ContainerCalculationResult containerCalculationResult) {
|
||||||
|
|
||||||
var destinationHandling = destination.getHandlingCost();
|
var destinationHandling = destination.getHandlingCost();
|
||||||
var destinationDisposal = destination.getDisposalCost();
|
var destinationDisposal = destination.getDisposalCost();
|
||||||
var destinationRepacking = destination.getRepackingCost();
|
var destinationRepacking = destination.getRepackingCost();
|
||||||
|
|
||||||
BigDecimal huAnnualAmount = BigDecimal.valueOf(destination.getAnnualAmount()).divide(BigDecimal.valueOf(hu.getContentUnitCount()),4, RoundingMode.UP );
|
BigDecimal huAnnualAmount = BigDecimal.valueOf(destination.getAnnualAmount()).divide(BigDecimal.valueOf(hu.getContentUnitCount()),4, RoundingMode.UP );
|
||||||
BigDecimal handling = destinationHandling != null ? destinationHandling : BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.GLT_HANDLING, setId).orElseThrow().getCurrentValue()));
|
BigDecimal shippingFreq = BigDecimal.valueOf(shippingFrequencyCalculationService.doCalculation(setId, huAnnualAmount.doubleValue(), containerCalculationResult.getHuPerContainer(), !premise.getHuMixable()));
|
||||||
BigDecimal release = BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.GLT_RELEASE, setId).orElseThrow().getCurrentValue()));
|
|
||||||
BigDecimal dispatch = BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.GLT_DISPATCH, setId).orElseThrow().getCurrentValue()));
|
BigDecimal multiplier = shippingFreq.compareTo(huAnnualAmount) > 0 ? shippingFreq : huAnnualAmount;
|
||||||
BigDecimal disposal = destinationDisposal != null ? destinationDisposal : (addRepackingAndDisposalCost ? BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.DISPOSAL, setId).orElseThrow().getCurrentValue())) : BigDecimal.ZERO);
|
|
||||||
|
BigDecimal handling = BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.KLT_HANDLING, setId).orElseThrow().getCurrentValue()));
|
||||||
|
BigDecimal release = BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.KLT_RELEASE, setId).orElseThrow().getCurrentValue()));
|
||||||
|
BigDecimal dispatch = BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.KLT_DISPATCH, setId).orElseThrow().getCurrentValue()));
|
||||||
|
|
||||||
BigDecimal wageFactor = BigDecimal.valueOf(Double.parseDouble(countryPropertyRepository.getByMappingIdAndCountryId(CountryPropertyMappingId.WAGE, setId, destination.getCountryId()).orElseThrow().getCurrentValue()));
|
BigDecimal wageFactor = BigDecimal.valueOf(Double.parseDouble(countryPropertyRepository.getByMappingIdAndCountryId(CountryPropertyMappingId.WAGE, setId, destination.getCountryId()).orElseThrow().getCurrentValue()));
|
||||||
BigDecimal booking = BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.BOOKING_KLT, setId).orElseThrow().getCurrentValue()));
|
BigDecimal booking = BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.BOOKING_KLT, setId).orElseThrow().getCurrentValue()));
|
||||||
|
|
||||||
|
BigDecimal annualRepacking = Objects.requireNonNullElse(destinationRepacking, getRepackingCost(setId, hu, loadCarrierType, addRepackingAndDisposalCost, destinationRepacking).multiply(wageFactor)).multiply(huAnnualAmount);
|
||||||
|
BigDecimal annualDisposal = Objects.requireNonNullElse(destinationDisposal.multiply(huAnnualAmount), BigDecimal.ZERO);
|
||||||
|
|
||||||
|
BigDecimal annualHandling;
|
||||||
|
|
||||||
|
if(destinationHandling != null)
|
||||||
|
annualHandling = destinationHandling.multiply(multiplier);
|
||||||
|
else
|
||||||
|
annualHandling = (((handling.multiply(multiplier)).add((dispatch.multiply(huAnnualAmount))).add((release.multiply(huAnnualAmount)))).add(booking.multiply(shippingFreq))).multiply(wageFactor);
|
||||||
|
|
||||||
|
|
||||||
return new HandlingResult(LoadCarrierType.SLC,
|
return new HandlingResult(LoadCarrierType.SLC,
|
||||||
getRepackingCost(setId, hu, loadCarrierType, addRepackingAndDisposalCost, destinationRepacking).multiply(huAnnualAmount),
|
annualRepacking,
|
||||||
handling.multiply(huAnnualAmount),
|
annualHandling,
|
||||||
destinationDisposal == null ? BigDecimal.ZERO : (disposal.multiply(huAnnualAmount)), //TODO: disposal SLC, ignore?
|
annualDisposal,
|
||||||
huAnnualAmount.multiply((handling.add(booking).add(release).add(dispatch).add(getRepackingCost(setId, hu, loadCarrierType, addRepackingAndDisposalCost, destinationRepacking)))).multiply(wageFactor));
|
annualHandling.add(annualRepacking).add(annualDisposal));
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -77,24 +93,35 @@ public class HandlingCostCalculationService {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private HandlingResult getLLCCost(Integer setId, Destination destination, PackagingDimension hu, LoadCarrierType type, boolean addRepackingAndDisposalCost) {
|
private HandlingResult getLLCCost(Integer setId, Premise premise, Destination destination, PackagingDimension hu, LoadCarrierType type, boolean addRepackingAndDisposalCost, ContainerCalculationResult containerCalculationResult) {
|
||||||
|
|
||||||
var destinationHandling = destination.getHandlingCost();
|
var destinationHandling = destination.getHandlingCost();
|
||||||
var destinationDisposal = destination.getDisposalCost();
|
var destinationDisposal = destination.getDisposalCost();
|
||||||
var destinationRepacking = destination.getRepackingCost();
|
var destinationRepacking = destination.getRepackingCost();
|
||||||
|
|
||||||
BigDecimal huAnnualAmount = BigDecimal.valueOf(destination.getAnnualAmount()).divide(BigDecimal.valueOf(hu.getContentUnitCount()),4, RoundingMode.UP );
|
BigDecimal huAnnualAmount = BigDecimal.valueOf(destination.getAnnualAmount()).divide(BigDecimal.valueOf(hu.getContentUnitCount()),4, RoundingMode.UP );
|
||||||
BigDecimal handling = destinationHandling != null ? destinationHandling : BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.GLT_HANDLING, setId).orElseThrow().getCurrentValue()));
|
BigDecimal shippingFreq = BigDecimal.valueOf(shippingFrequencyCalculationService.doCalculation(setId, huAnnualAmount.doubleValue(), containerCalculationResult.getHuPerContainer(), !premise.getHuMixable()));
|
||||||
|
|
||||||
|
BigDecimal multiplier = shippingFreq.compareTo(huAnnualAmount) > 0 ? shippingFreq : huAnnualAmount;
|
||||||
|
|
||||||
|
BigDecimal handling = BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.GLT_HANDLING, setId).orElseThrow().getCurrentValue()));
|
||||||
BigDecimal release = BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.GLT_RELEASE, setId).orElseThrow().getCurrentValue()));
|
BigDecimal release = BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.GLT_RELEASE, setId).orElseThrow().getCurrentValue()));
|
||||||
BigDecimal dispatch = BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.GLT_DISPATCH, setId).orElseThrow().getCurrentValue()));
|
BigDecimal dispatch = BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.GLT_DISPATCH, setId).orElseThrow().getCurrentValue()));
|
||||||
BigDecimal disposal = destinationDisposal != null ? destinationDisposal : (addRepackingAndDisposalCost ? BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.DISPOSAL, setId).orElseThrow().getCurrentValue())) : BigDecimal.ZERO);
|
BigDecimal disposal = (addRepackingAndDisposalCost ? BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.DISPOSAL, setId).orElseThrow().getCurrentValue())) : BigDecimal.ZERO);
|
||||||
|
|
||||||
BigDecimal wageFactor = BigDecimal.valueOf(Double.parseDouble(countryPropertyRepository.getByMappingIdAndCountryId(CountryPropertyMappingId.WAGE, setId, destination.getCountryId()).orElseThrow().getCurrentValue()));
|
BigDecimal wageFactor = BigDecimal.valueOf(Double.parseDouble(countryPropertyRepository.getByMappingIdAndCountryId(CountryPropertyMappingId.WAGE, setId, destination.getCountryId()).orElseThrow().getCurrentValue()));
|
||||||
BigDecimal booking = BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.BOOKING, setId).orElseThrow().getCurrentValue()));
|
BigDecimal booking = BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.BOOKING, setId).orElseThrow().getCurrentValue()));
|
||||||
|
|
||||||
var annualRepacking = getRepackingCost(setId, hu, type, addRepackingAndDisposalCost, destinationRepacking).multiply(wageFactor).multiply( huAnnualAmount);
|
BigDecimal annualRepacking = Objects.requireNonNullElse(destinationRepacking, getRepackingCost(setId, hu, type, addRepackingAndDisposalCost, destinationRepacking).multiply(wageFactor)).multiply(huAnnualAmount);
|
||||||
var annualHandling = ((handling.add(dispatch).add(release)).multiply(wageFactor).multiply(huAnnualAmount)).add(booking.multiply(BigDecimal.valueOf(shippingFrequencyCalculationService.doCalculation(setId, huAnnualAmount.doubleValue()))));
|
|
||||||
var annualDisposal = (disposal.multiply(huAnnualAmount));
|
BigDecimal annualHandling;
|
||||||
|
|
||||||
|
if(destinationHandling != null)
|
||||||
|
annualHandling = destinationHandling.multiply(multiplier);
|
||||||
|
else
|
||||||
|
annualHandling = (((handling.multiply(multiplier)).add((dispatch.multiply(huAnnualAmount))).add((release.multiply(huAnnualAmount)))).add(booking.multiply(shippingFreq))).multiply(wageFactor);
|
||||||
|
|
||||||
|
BigDecimal annualDisposal = Objects.requireNonNullElse(destinationDisposal, disposal).multiply(huAnnualAmount);
|
||||||
|
|
||||||
return new HandlingResult(LoadCarrierType.LLC, annualRepacking, annualHandling, annualDisposal, annualRepacking.add(annualHandling).add(annualDisposal));
|
return new HandlingResult(LoadCarrierType.LLC, annualRepacking, annualHandling, annualDisposal, annualRepacking.add(annualHandling).add(annualDisposal));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package de.avatic.lcc.service.calculation.execution.steps;
|
package de.avatic.lcc.service.calculation.execution.steps;
|
||||||
|
|
||||||
|
import de.avatic.lcc.model.calculation.ContainerCalculationResult;
|
||||||
import de.avatic.lcc.model.calculation.InventoryCostResult;
|
import de.avatic.lcc.model.calculation.InventoryCostResult;
|
||||||
import de.avatic.lcc.model.db.packaging.PackagingDimension;
|
import de.avatic.lcc.model.db.packaging.PackagingDimension;
|
||||||
import de.avatic.lcc.model.db.premises.Premise;
|
import de.avatic.lcc.model.db.premises.Premise;
|
||||||
|
|
@ -30,7 +31,7 @@ public class InventoryCostCalculationService {
|
||||||
this.premiseToHuService = premiseToHuService;
|
this.premiseToHuService = premiseToHuService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public InventoryCostResult doCalculation(Integer setId, Premise premise, Destination destination, BigDecimal leadTime) {
|
public InventoryCostResult doCalculation(Integer setId, Premise premise, Destination destination, BigDecimal leadTime, ContainerCalculationResult containerCalculationResult) {
|
||||||
|
|
||||||
|
|
||||||
var fcaFee = BigDecimal.ZERO;
|
var fcaFee = BigDecimal.ZERO;
|
||||||
|
|
@ -53,7 +54,7 @@ public class InventoryCostCalculationService {
|
||||||
var dailyAmount = annualAmount.divide(BigDecimal.valueOf(365), 10, RoundingMode.HALF_UP);
|
var dailyAmount = annualAmount.divide(BigDecimal.valueOf(365), 10, RoundingMode.HALF_UP);
|
||||||
var workdayAmount = annualAmount.divide(workdays, 10, RoundingMode.HALF_UP);
|
var workdayAmount = annualAmount.divide(workdays, 10, RoundingMode.HALF_UP);
|
||||||
|
|
||||||
var opStock = (annualAmount.divide(BigDecimal.valueOf(Math.max(shippingFrequencyCalculationService.doCalculation(setId, huAnnualAmount),1)), 10, RoundingMode.HALF_UP).multiply(BigDecimal.valueOf(.5)));
|
var opStock = (annualAmount.divide(BigDecimal.valueOf(Math.max(shippingFrequencyCalculationService.doCalculation(setId, huAnnualAmount, containerCalculationResult.getHuPerContainer(), !premise.getHuMixable()),1)), 10, RoundingMode.HALF_UP).multiply(BigDecimal.valueOf(.5)));
|
||||||
var safetyStock = safetyDays.multiply(workdayAmount);
|
var safetyStock = safetyDays.multiply(workdayAmount);
|
||||||
var stockedInventory = opStock.add(safetyStock);
|
var stockedInventory = opStock.add(safetyStock);
|
||||||
var inTransportStock = dailyAmount.multiply(leadTime);
|
var inTransportStock = dailyAmount.multiply(leadTime);
|
||||||
|
|
@ -71,7 +72,8 @@ public class InventoryCostCalculationService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private BigDecimal getSpaceCostPerHu(Integer setId, PackagingDimension hu) {
|
private BigDecimal getSpaceCostPerHu(Integer setId, PackagingDimension hu) {
|
||||||
var spaceCost = BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.SPACE_COST, setId).orElseThrow().getCurrentValue()));
|
var spaceCostStr = propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.SPACE_COST, setId).orElseThrow().getCurrentValue();
|
||||||
|
var spaceCost = BigDecimal.valueOf(Double.parseDouble(spaceCostStr));
|
||||||
var spaceCostPerHu = BigDecimal.valueOf(hu.getFloorArea(DimensionUnit.M)*hu.getRoundedHeight(DimensionUnit.M)).multiply(spaceCost);
|
var spaceCostPerHu = BigDecimal.valueOf(hu.getFloorArea(DimensionUnit.M)*hu.getRoundedHeight(DimensionUnit.M)).multiply(spaceCost);
|
||||||
return spaceCostPerHu;
|
return spaceCostPerHu;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,8 +40,9 @@ public class RouteSectionCostCalculationService {
|
||||||
private final ChangeRiskFactorCalculationService changeRiskFactorCalculationService;
|
private final ChangeRiskFactorCalculationService changeRiskFactorCalculationService;
|
||||||
private final NodeRepository nodeRepository;
|
private final NodeRepository nodeRepository;
|
||||||
private final UserNodeRepository userNodeRepository;
|
private final UserNodeRepository userNodeRepository;
|
||||||
|
private final ShippingFrequencyCalculationService shippingFrequencyCalculationService;
|
||||||
|
|
||||||
public RouteSectionCostCalculationService(ContainerRateRepository containerRateRepository, MatrixRateRepository matrixRateRepository, RouteNodeRepository routeNodeRepository, DistanceService distanceService, PropertyRepository propertyRepository, ChangeRiskFactorCalculationService changeRiskFactorCalculationService, NodeRepository nodeRepository, UserNodeRepository userNodeRepository) {
|
public RouteSectionCostCalculationService(ContainerRateRepository containerRateRepository, MatrixRateRepository matrixRateRepository, RouteNodeRepository routeNodeRepository, DistanceService distanceService, PropertyRepository propertyRepository, ChangeRiskFactorCalculationService changeRiskFactorCalculationService, NodeRepository nodeRepository, UserNodeRepository userNodeRepository, ShippingFrequencyCalculationService shippingFrequencyCalculationService) {
|
||||||
this.containerRateRepository = containerRateRepository;
|
this.containerRateRepository = containerRateRepository;
|
||||||
this.matrixRateRepository = matrixRateRepository;
|
this.matrixRateRepository = matrixRateRepository;
|
||||||
this.routeNodeRepository = routeNodeRepository;
|
this.routeNodeRepository = routeNodeRepository;
|
||||||
|
|
@ -50,6 +51,7 @@ public class RouteSectionCostCalculationService {
|
||||||
this.changeRiskFactorCalculationService = changeRiskFactorCalculationService;
|
this.changeRiskFactorCalculationService = changeRiskFactorCalculationService;
|
||||||
this.nodeRepository = nodeRepository;
|
this.nodeRepository = nodeRepository;
|
||||||
this.userNodeRepository = userNodeRepository;
|
this.userNodeRepository = userNodeRepository;
|
||||||
|
this.shippingFrequencyCalculationService = shippingFrequencyCalculationService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public CalculationJobRouteSection doD2dCalculation(Integer setId, Integer periodId, Premise premise, Destination destination, ContainerCalculationResult containerCalculation) {
|
public CalculationJobRouteSection doD2dCalculation(Integer setId, Integer periodId, Premise premise, Destination destination, ContainerCalculationResult containerCalculation) {
|
||||||
|
|
@ -94,7 +96,10 @@ public class RouteSectionCostCalculationService {
|
||||||
containerCalculation.getMaxContainerWeight(),
|
containerCalculation.getMaxContainerWeight(),
|
||||||
BigDecimal.valueOf(containerCalculation.getTotalUtilizationByVolume()),
|
BigDecimal.valueOf(containerCalculation.getTotalUtilizationByVolume()),
|
||||||
BigDecimal.valueOf(containerCalculation.getHuUtilizationByWeight()),
|
BigDecimal.valueOf(containerCalculation.getHuUtilizationByWeight()),
|
||||||
utilization);
|
utilization,
|
||||||
|
shippingFrequencyCalculationService.doCalculation(setId, huAnnualAmount.doubleValue(), containerCalculation.getHuPerContainer(), !premise.getHuMixable()),
|
||||||
|
huAnnualAmount.doubleValue(),
|
||||||
|
containerCalculation);
|
||||||
|
|
||||||
result.setCbmPrice(!containerCalculation.isWeightExceeded());
|
result.setCbmPrice(!containerCalculation.isWeightExceeded());
|
||||||
result.setWeightPrice(containerCalculation.isWeightExceeded());
|
result.setWeightPrice(containerCalculation.isWeightExceeded());
|
||||||
|
|
@ -177,7 +182,10 @@ public class RouteSectionCostCalculationService {
|
||||||
containerCalculation.getMaxContainerWeight(),
|
containerCalculation.getMaxContainerWeight(),
|
||||||
BigDecimal.valueOf(containerCalculation.getTotalUtilizationByVolume()),
|
BigDecimal.valueOf(containerCalculation.getTotalUtilizationByVolume()),
|
||||||
BigDecimal.valueOf(containerCalculation.getTotalUtilizationByWeight()),
|
BigDecimal.valueOf(containerCalculation.getTotalUtilizationByWeight()),
|
||||||
utilization);
|
utilization,
|
||||||
|
shippingFrequencyCalculationService.doCalculation(setId, huAnnualAmount.doubleValue(), containerCalculation.getHuPerContainer(), !premise.getHuMixable()),
|
||||||
|
huAnnualAmount.doubleValue(),
|
||||||
|
containerCalculation);
|
||||||
|
|
||||||
result.setCbmPrice(!containerCalculation.isWeightExceeded());
|
result.setCbmPrice(!containerCalculation.isWeightExceeded());
|
||||||
result.setWeightPrice(containerCalculation.isWeightExceeded());
|
result.setWeightPrice(containerCalculation.isWeightExceeded());
|
||||||
|
|
@ -211,7 +219,11 @@ public class RouteSectionCostCalculationService {
|
||||||
int maxContainerWeight,
|
int maxContainerWeight,
|
||||||
BigDecimal totalVolumeUtilization,
|
BigDecimal totalVolumeUtilization,
|
||||||
BigDecimal totalWeightUtilization,
|
BigDecimal totalWeightUtilization,
|
||||||
BigDecimal propertyUtilization) {
|
BigDecimal propertyUtilization,
|
||||||
|
double shippingFrequency,
|
||||||
|
double annualHuAmount,
|
||||||
|
ContainerCalculationResult containerCalculationResult
|
||||||
|
) {
|
||||||
|
|
||||||
|
|
||||||
BigDecimal utilization;
|
BigDecimal utilization;
|
||||||
|
|
@ -221,14 +233,26 @@ public class RouteSectionCostCalculationService {
|
||||||
BigDecimal cbmRate = rate.divide(BigDecimal.valueOf(containerType.getVolume()), 10, RoundingMode.HALF_UP);
|
BigDecimal cbmRate = rate.divide(BigDecimal.valueOf(containerType.getVolume()), 10, RoundingMode.HALF_UP);
|
||||||
BigDecimal weightRate = rate.divide(BigDecimal.valueOf(maxContainerWeight), 10, RoundingMode.HALF_UP);
|
BigDecimal weightRate = rate.divide(BigDecimal.valueOf(maxContainerWeight), 10, RoundingMode.HALF_UP);
|
||||||
|
|
||||||
|
|
||||||
if (huMixable) {
|
if (huMixable) {
|
||||||
volumePrice = cbmRate.divide(propertyUtilization, 10, RoundingMode.HALF_UP);
|
volumePrice = cbmRate.divide(propertyUtilization, 10, RoundingMode.HALF_UP);
|
||||||
weightPrice = weightRate.divide(BigDecimal.valueOf(1), 10, RoundingMode.HALF_UP);
|
weightPrice = weightRate.divide(BigDecimal.valueOf(1), 10, RoundingMode.HALF_UP);
|
||||||
utilization = weightExceeded ? BigDecimal.ONE : propertyUtilization;
|
utilization = weightExceeded ? BigDecimal.ONE : propertyUtilization;
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
|
double huPerContainer = annualHuAmount / shippingFrequency;
|
||||||
|
|
||||||
|
// if the shipping frequency is bigger than the annual amount the "totalXXUtilization" cannot be used.
|
||||||
|
if(huPerContainer < (containerCalculationResult.getHuPerContainer())) {
|
||||||
|
|
||||||
|
totalVolumeUtilization = BigDecimal.valueOf(huPerContainer * containerCalculationResult.getHu().getVolume(DimensionUnit.M)).divide(BigDecimal.valueOf(containerCalculationResult.getContainerType().getVolume()), 20, RoundingMode.HALF_UP);
|
||||||
|
totalWeightUtilization = BigDecimal.valueOf(huPerContainer * containerCalculationResult.getHu().getWeight(WeightUnit.KG)).divide(BigDecimal.valueOf(containerCalculationResult.getMaxContainerWeight()), 20, RoundingMode.HALF_UP);
|
||||||
|
}
|
||||||
|
|
||||||
volumePrice = cbmRate.divide(totalVolumeUtilization, 10, RoundingMode.HALF_UP);
|
volumePrice = cbmRate.divide(totalVolumeUtilization, 10, RoundingMode.HALF_UP);
|
||||||
weightPrice = weightRate.divide(totalWeightUtilization, 10, RoundingMode.HALF_UP);
|
weightPrice = weightRate.divide(totalWeightUtilization, 10, RoundingMode.HALF_UP);
|
||||||
utilization = weightExceeded ? totalWeightUtilization : totalVolumeUtilization;
|
utilization = weightExceeded ? totalWeightUtilization : totalVolumeUtilization;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new PriceCalculationResult(volumePrice, weightPrice, utilization);
|
return new PriceCalculationResult(volumePrice, weightPrice, utilization);
|
||||||
|
|
|
||||||
|
|
@ -13,26 +13,20 @@ public class ShippingFrequencyCalculationService {
|
||||||
this.propertyRepository = propertyRepository;
|
this.propertyRepository = propertyRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public int doCalculation(Integer setId, int huAnnualAmount) {
|
public double doCalculation(Integer setId, double huAnnualAmount, int maxHuPerContainer, boolean fillContainer) {
|
||||||
var minAnnualFrequency = Integer.parseInt(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.FREQ_MIN, setId).orElseThrow().getCurrentValue());
|
int minAnnualFrequency = Integer.parseInt(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.FREQ_MIN, setId).orElseThrow().getCurrentValue());
|
||||||
var maxAnnualFrequency = Integer.parseInt(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.FREQ_MAX, setId).orElseThrow().getCurrentValue());
|
int maxAnnualFrequency = Integer.parseInt(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.FREQ_MAX, setId).orElseThrow().getCurrentValue());
|
||||||
|
|
||||||
if (huAnnualAmount > maxAnnualFrequency)
|
if (fillContainer) {
|
||||||
|
var fullContainers = huAnnualAmount / maxHuPerContainer;
|
||||||
|
return fullContainers > minAnnualFrequency ? fullContainers : minAnnualFrequency;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (huAnnualAmount > (double) maxAnnualFrequency)
|
||||||
return maxAnnualFrequency;
|
return maxAnnualFrequency;
|
||||||
|
|
||||||
return Math.max(huAnnualAmount, minAnnualFrequency);
|
return Math.max(huAnnualAmount, minAnnualFrequency);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public double doCalculation(Integer setId, double huAnnualAmount) {
|
|
||||||
Integer minAnnualFrequency = Integer.parseInt(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.FREQ_MIN, setId).orElseThrow().getCurrentValue());
|
|
||||||
Integer maxAnnualFrequency = Integer.parseInt(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.FREQ_MAX, setId).orElseThrow().getCurrentValue());
|
|
||||||
|
|
||||||
if (huAnnualAmount > maxAnnualFrequency.doubleValue())
|
|
||||||
return maxAnnualFrequency;
|
|
||||||
|
|
||||||
return Math.max(huAnnualAmount, minAnnualFrequency.doubleValue());
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -140,26 +140,51 @@ public class NodeExcelMapper {
|
||||||
|
|
||||||
validateConstraints(row);
|
validateConstraints(row);
|
||||||
|
|
||||||
entity.setExternalMappingId(row.getCell(NodeHeader.MAPPING_ID.ordinal()).getStringCellValue());
|
entity.setExternalMappingId(getCellValueAsString(row.getCell(NodeHeader.MAPPING_ID.ordinal())));
|
||||||
entity.setName(row.getCell(NodeHeader.NAME.ordinal()).getStringCellValue());
|
entity.setName(getCellValueAsString(row.getCell(NodeHeader.NAME.ordinal())));
|
||||||
entity.setAddress(row.getCell(NodeHeader.ADDRESS.ordinal()).getStringCellValue());
|
entity.setAddress(getCellValueAsString(row.getCell(NodeHeader.ADDRESS.ordinal())));
|
||||||
entity.setCountryId(IsoCode.valueOf(row.getCell(NodeHeader.COUNTRY.ordinal()).getStringCellValue()));
|
entity.setCountryId(IsoCode.valueOf(getCellValueAsString(row.getCell(NodeHeader.COUNTRY.ordinal()))));
|
||||||
|
|
||||||
entity.setGeoLat(mapGeoCoordinate(CellUtil.getCell(row, NodeHeader.GEO_LATITUDE.ordinal())));
|
entity.setGeoLat(mapGeoCoordinate(CellUtil.getCell(row, NodeHeader.GEO_LATITUDE.ordinal())));
|
||||||
entity.setGeoLng(mapGeoCoordinate(CellUtil.getCell(row, NodeHeader.GEO_LONGITUDE.ordinal())));
|
entity.setGeoLng(mapGeoCoordinate(CellUtil.getCell(row, NodeHeader.GEO_LONGITUDE.ordinal())));
|
||||||
|
|
||||||
entity.setSource(Boolean.valueOf(row.getCell(NodeHeader.IS_SOURCE.ordinal()).getStringCellValue()));
|
entity.setSource(Boolean.valueOf(getCellValueAsString(row.getCell(NodeHeader.IS_SOURCE.ordinal()))));
|
||||||
entity.setIntermediate(Boolean.valueOf(row.getCell(NodeHeader.IS_INTERMEDIATE.ordinal()).getStringCellValue()));
|
entity.setIntermediate(Boolean.valueOf(getCellValueAsString(row.getCell(NodeHeader.IS_INTERMEDIATE.ordinal()))));
|
||||||
entity.setDestination(Boolean.valueOf(row.getCell(NodeHeader.IS_DESTINATION.ordinal()).getStringCellValue()));
|
entity.setDestination(Boolean.valueOf(getCellValueAsString(row.getCell(NodeHeader.IS_DESTINATION.ordinal()))));
|
||||||
|
|
||||||
if(!entity.getSource() && !entity.getDestination() && !entity.getIntermediate())
|
if(!entity.getSource() && !entity.getDestination() && !entity.getIntermediate())
|
||||||
throw new ExcelValidationError("Unable to validate row " + (row.getRowNum() + 1) + " column " + toExcelLetter(ContainerRateHeader.FROM_NODE.ordinal()) + ": Node with mapping id " + row.getCell(NodeHeader.MAPPING_ID.ordinal()).getStringCellValue() + " must be either source, destination or intermediate");
|
throw new ExcelValidationError("Unable to validate row " + (row.getRowNum() + 1) + " column " + toExcelLetter(ContainerRateHeader.FROM_NODE.ordinal()) + ": Node with mapping id " + getCellValueAsString(row.getCell(NodeHeader.MAPPING_ID.ordinal())) + " must be either source, destination or intermediate");
|
||||||
|
|
||||||
|
entity.setPredecessorRequired(Boolean.valueOf(getCellValueAsString(row.getCell(NodeHeader.IS_PREDECESSOR_MANDATORY.ordinal()))));
|
||||||
|
entity.setNodePredecessors(mapChainsFromCell(getCellValueAsString(CellUtil.getCell(row, NodeHeader.PREDECESSOR_NODES.ordinal()))));
|
||||||
|
entity.setOutboundCountries(mapOutboundCountriesFromCell(getCellValueAsString(CellUtil.getCell(row, NodeHeader.OUTBOUND_COUNTRIES.ordinal()))));
|
||||||
|
|
||||||
entity.setPredecessorRequired(Boolean.valueOf(row.getCell(NodeHeader.IS_PREDECESSOR_MANDATORY.ordinal()).getStringCellValue()));
|
return new BulkInstruction<>(entity, BulkInstructionType.valueOf(getCellValueAsString(row.getCell(NodeHeader.OPERATION.ordinal()))));
|
||||||
entity.setNodePredecessors(mapChainsFromCell(CellUtil.getCell(row, NodeHeader.PREDECESSOR_NODES.ordinal()).getStringCellValue()));
|
}
|
||||||
entity.setOutboundCountries(mapOutboundCountriesFromCell(CellUtil.getCell(row, NodeHeader.OUTBOUND_COUNTRIES.ordinal()).getStringCellValue()));
|
|
||||||
return new BulkInstruction<>(entity, BulkInstructionType.valueOf(row.getCell(NodeHeader.OPERATION.ordinal()).getStringCellValue()));
|
/**
|
||||||
|
* Extracts string value from cell with proper handling of different cell types and encoding
|
||||||
|
*/
|
||||||
|
private String getCellValueAsString(Cell cell) {
|
||||||
|
if (cell == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return switch (cell.getCellType()) {
|
||||||
|
case STRING -> {
|
||||||
|
String value = cell.getStringCellValue();
|
||||||
|
yield java.text.Normalizer.normalize(value, java.text.Normalizer.Form.NFC).trim();
|
||||||
|
}
|
||||||
|
case NUMERIC -> {
|
||||||
|
if (DateUtil.isCellDateFormatted(cell)) {
|
||||||
|
yield cell.getDateCellValue().toString();
|
||||||
|
}
|
||||||
|
yield String.valueOf(cell.getNumericCellValue());
|
||||||
|
}
|
||||||
|
case BOOLEAN -> String.valueOf(cell.getBooleanCellValue());
|
||||||
|
case FORMULA -> cell.getCellFormula();
|
||||||
|
default -> "";
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
243
src/main/java/de/avatic/lcc/service/help/MarkdownService.java
Normal file
243
src/main/java/de/avatic/lcc/service/help/MarkdownService.java
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
package de.avatic.lcc.service.help;
|
||||||
|
|
||||||
|
import org.commonmark.node.Node;
|
||||||
|
import org.commonmark.parser.Parser;
|
||||||
|
import org.commonmark.renderer.html.HtmlRenderer;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class MarkdownService {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(MarkdownService.class);
|
||||||
|
|
||||||
|
@Value(value = "${lcc.help.static:}")
|
||||||
|
private String imagesBaseUrl;
|
||||||
|
|
||||||
|
@Value("${help.markdown.cache.enabled:true}")
|
||||||
|
private boolean cacheEnabled;
|
||||||
|
|
||||||
|
private final Parser parser;
|
||||||
|
private final HtmlRenderer renderer;
|
||||||
|
private final Map<String, String> contentCache = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public MarkdownService() {
|
||||||
|
|
||||||
|
this.parser = Parser.builder()
|
||||||
|
|
||||||
|
.build();
|
||||||
|
|
||||||
|
this.renderer = HtmlRenderer.builder()
|
||||||
|
|
||||||
|
.escapeHtml(false) // HTML in Markdown erlauben
|
||||||
|
.sanitizeUrls(true) // URLs sanitizen
|
||||||
|
.build();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konvertiert Markdown-String zu HTML
|
||||||
|
*/
|
||||||
|
public String convertToHtml(String markdown, String imagePrefix) {
|
||||||
|
if (markdown == null || markdown.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
Node document = parser.parse(markdown);
|
||||||
|
String html = renderer.render(document);
|
||||||
|
|
||||||
|
// Bild-URLs anpassen
|
||||||
|
return replaceImageUrls(html, imagePrefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt Markdown-Datei und konvertiert zu HTML
|
||||||
|
*/
|
||||||
|
public String loadAndConvertMarkdown(String fileName, String imagePrefix) throws IOException {
|
||||||
|
// Cache-Key erstellen
|
||||||
|
String cacheKey = fileName + "_" + imagesBaseUrl.hashCode();
|
||||||
|
|
||||||
|
// Aus Cache laden wenn aktiviert
|
||||||
|
if (cacheEnabled && contentCache.containsKey(cacheKey)) {
|
||||||
|
logger.debug("Loading help content from cache: {}", fileName);
|
||||||
|
return contentCache.get(cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug("Loading help content from file: {}", fileName);
|
||||||
|
|
||||||
|
// Markdown-Datei laden
|
||||||
|
String markdown = loadMarkdownFile(fileName);
|
||||||
|
|
||||||
|
// Zu HTML konvertieren
|
||||||
|
String html = convertToHtml(markdown, imagePrefix);
|
||||||
|
|
||||||
|
// In HTML-Template einbetten
|
||||||
|
// html = wrapInHtmlTemplate(html, fileName);
|
||||||
|
|
||||||
|
// Im Cache speichern
|
||||||
|
if (cacheEnabled) {
|
||||||
|
contentCache.put(cacheKey, html);
|
||||||
|
}
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt Markdown-Datei aus Resources
|
||||||
|
*/
|
||||||
|
private String loadMarkdownFile(String fileName) throws IOException {
|
||||||
|
// .md Extension hinzufügen falls nicht vorhanden
|
||||||
|
if (!fileName.endsWith(".md")) {
|
||||||
|
fileName = fileName + ".md";
|
||||||
|
}
|
||||||
|
|
||||||
|
Resource resource = new ClassPathResource("static/help/" + fileName);
|
||||||
|
|
||||||
|
if (!resource.exists()) {
|
||||||
|
throw new IOException("Help file not found: " + fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ersetzt relative Bild-URLs durch absolute URLs zum Azure Blob Storage
|
||||||
|
*/
|
||||||
|
private String replaceImageUrls(String html, String imagePrefix) {
|
||||||
|
if (imagesBaseUrl == null || imagesBaseUrl.isEmpty()) {
|
||||||
|
logger.warn("Images base URL not configured");
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verschiedene Bild-URL-Formate ersetzen
|
||||||
|
html = html.replaceAll("src=\"images/", "src=\"" + imagesBaseUrl + "/" + imagePrefix + "-");
|
||||||
|
html = html.replaceAll("src='images/", "src='" + imagesBaseUrl + "/" + imagePrefix + "-");
|
||||||
|
html = html.replaceAll("src=\"./images/", "src=\"" + imagesBaseUrl + "/" + imagePrefix + "-");
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bettet HTML-Inhalt in vollständiges HTML-Dokument ein
|
||||||
|
*/
|
||||||
|
private String wrapInHtmlTemplate(String content, String title) {
|
||||||
|
return """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>%s</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
max-width: 100%%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #2c3e50;
|
||||||
|
border-bottom: 2px solid #3498db;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
color: #34495e;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
color: #34495e;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
background-color: #3498db;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
tr:nth-child(even) {
|
||||||
|
background-color: #f2f2f2;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
blockquote {
|
||||||
|
border-left: 4px solid #3498db;
|
||||||
|
padding-left: 1rem;
|
||||||
|
margin-left: 0;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: #3498db;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
ul, ol {
|
||||||
|
padding-left: 2rem;
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
%s
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""".formatted(title, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leert den Content-Cache
|
||||||
|
*/
|
||||||
|
public void clearCache() {
|
||||||
|
contentCache.clear();
|
||||||
|
logger.info("Help content cache cleared");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt Cache-Statistiken zurück
|
||||||
|
*/
|
||||||
|
public Map<String, Object> getCacheStats() {
|
||||||
|
return Map.of(
|
||||||
|
"cacheEnabled", cacheEnabled,
|
||||||
|
"cachedFiles", contentCache.size(),
|
||||||
|
"imagesBaseUrl", imagesBaseUrl
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -69,12 +69,12 @@ public class PreCalculationCheckService {
|
||||||
this.userNodeRepository = userNodeRepository;
|
this.userNodeRepository = userNodeRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void doPrecheck(Integer premiseId, Optional<PropertySet> set, Optional<ValidityPeriod> period) {
|
public void doPrecheck(Integer premiseId, Optional<PropertySet> set, Optional<ValidityPeriod> period, boolean showCalcInfo) {
|
||||||
doPrecheck(premiseId, set, period, LocalDate.now());
|
doPrecheck(premiseId, set, period, LocalDate.now(), showCalcInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public void doPrecheck(Integer premiseId, Optional<PropertySet> set, Optional<ValidityPeriod> period, LocalDate date) {
|
public void doPrecheck(Integer premiseId, Optional<PropertySet> set, Optional<ValidityPeriod> period, LocalDate date, boolean showCalcInfo) {
|
||||||
var premise = premiseRepository.getPremiseById(premiseId).orElseThrow();
|
var premise = premiseRepository.getPremiseById(premiseId).orElseThrow();
|
||||||
|
|
||||||
supplierCheck(premise);
|
supplierCheck(premise);
|
||||||
|
|
@ -88,46 +88,46 @@ public class PreCalculationCheckService {
|
||||||
var partNumber = material.get().getPartNumber();
|
var partNumber = material.get().getPartNumber();
|
||||||
var supplierName = supplier.get().getName();
|
var supplierName = supplier.get().getName();
|
||||||
|
|
||||||
materialCheck(premise, supplierName, partNumber);
|
materialCheck(premise, supplierName, partNumber, showCalcInfo);
|
||||||
|
|
||||||
packagingCheck(premise, supplierName, partNumber);
|
packagingCheck(premise, supplierName, partNumber, showCalcInfo);
|
||||||
|
|
||||||
priceCheck(premise, supplierName, partNumber);
|
priceCheck(premise, supplierName, partNumber, showCalcInfo);
|
||||||
|
|
||||||
var destinations = destinationRepository.getByPremiseId(premiseId);
|
var destinations = destinationRepository.getByPremiseId(premiseId);
|
||||||
|
|
||||||
if (destinations == null || destinations.isEmpty()) {
|
if (destinations == null || destinations.isEmpty()) {
|
||||||
throw new PremiseValidationError("Please add at least one destination to continue", supplierName, partNumber);
|
throw new PremiseValidationError("Please add at least one destination to continue", supplierName, partNumber, showCalcInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (Destination destination : destinations) {
|
for (Destination destination : destinations) {
|
||||||
|
|
||||||
var node = nodeRepository.getByDestinationId(destination.getId()).orElseThrow();
|
var node = nodeRepository.getByDestinationId(destination.getId()).orElseThrow();
|
||||||
|
|
||||||
destinationCheck(destination, node, supplierName, partNumber);
|
destinationCheck(destination, node, supplierName, partNumber, showCalcInfo);
|
||||||
|
|
||||||
var routes = routeRepository.getByDestinationId(destination.getId());
|
var routes = routeRepository.getByDestinationId(destination.getId());
|
||||||
|
|
||||||
|
|
||||||
if (routes.isEmpty() && destination.getD2d() == false)
|
if (routes.isEmpty() && destination.getD2d() == false)
|
||||||
throw new PremiseValidationError(String.format("No standard route found for %s - try using an individual rate instead", node.getName()), supplierName, partNumber);
|
throw new PremiseValidationError(String.format("No standard route found for %s - try using an individual rate instead", node.getName()), supplierName, partNumber, showCalcInfo);
|
||||||
|
|
||||||
if (routes.stream().noneMatch(Route::getSelected) && destination.getD2d() == false)
|
if (routes.stream().noneMatch(Route::getSelected) && destination.getD2d() == false)
|
||||||
throw new PremiseValidationError(String.format("Please select a route for %s", node.getName()), supplierName, partNumber);
|
throw new PremiseValidationError(String.format("Please select a route for %s", node.getName()), supplierName, partNumber, showCalcInfo);
|
||||||
|
|
||||||
if (destination.getD2d() && (destination.getRateD2d() == null || destination.getRateD2d().compareTo(BigDecimal.ZERO) == 0)) {
|
if (destination.getD2d() && (destination.getRateD2d() == null || destination.getRateD2d().compareTo(BigDecimal.ZERO) == 0)) {
|
||||||
throw new PremiseValidationError("Please enter a door-to-door rate greater than zero", supplierName, partNumber);
|
throw new PremiseValidationError("Please enter a door-to-door rate greater than zero", supplierName, partNumber, showCalcInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (destination.getD2d() && (destination.getLeadTimeD2d() == null || destination.getLeadTimeD2d() == 0)) {
|
if (destination.getD2d() && (destination.getLeadTimeD2d() == null || destination.getLeadTimeD2d() == 0)) {
|
||||||
throw new PremiseValidationError("Please enter a door-to-door lead time", supplierName, partNumber);
|
throw new PremiseValidationError("Please enter a door-to-door lead time", supplierName, partNumber, showCalcInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
periodCheck(period.orElse(null), set.orElse(null), date, supplierName, partNumber);
|
periodCheck(period.orElse(null), set.orElse(null), date, supplierName, partNumber);
|
||||||
|
|
||||||
routes.stream().filter(Route::getSelected).findAny().ifPresent(r -> {
|
routes.stream().filter(Route::getSelected).findAny().ifPresent(r -> {
|
||||||
var sections = routeSectionRepository.getByRouteId(r.getId());
|
var sections = routeSectionRepository.getByRouteId(r.getId());
|
||||||
routeCheck(sections, period.orElseThrow(), date, supplierName, partNumber);
|
routeCheck(sections, period.orElseThrow(), date, supplierName, partNumber, showCalcInfo);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -163,74 +163,74 @@ public class PreCalculationCheckService {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void routeCheck(List<RouteSection> sections, ValidityPeriod period, LocalDate calculationDate, String supplierName, String partNumber) {
|
private void routeCheck(List<RouteSection> sections, ValidityPeriod period, LocalDate calculationDate, String supplierName, String partNumber, boolean showCalcInfo) {
|
||||||
|
|
||||||
sections.forEach(section -> {
|
sections.forEach(section -> {
|
||||||
var fromRouteNode = routeNodeRepository.getFromNodeBySectionId(section.getId());
|
var fromRouteNode = routeNodeRepository.getFromNodeBySectionId(section.getId());
|
||||||
var toRouteNode = routeNodeRepository.getToNodeBySectionId(section.getId());
|
var toRouteNode = routeNodeRepository.getToNodeBySectionId(section.getId());
|
||||||
|
|
||||||
if (fromRouteNode.isEmpty() || toRouteNode.isEmpty())
|
if (fromRouteNode.isEmpty() || toRouteNode.isEmpty())
|
||||||
throw new PremiseValidationError("Route configuration issue - please contact your administrator", supplierName, partNumber);
|
throw new PremiseValidationError("Route configuration issue - please contact your administrator", supplierName, partNumber, showCalcInfo);
|
||||||
|
|
||||||
if (RateType.MATRIX == section.getRateType()) {
|
if (RateType.MATRIX == section.getRateType()) {
|
||||||
var rate = matrixRateRepository.getByCountryIds(fromRouteNode.get().getCountryId(), toRouteNode.get().getCountryId(), period.getId());
|
var rate = matrixRateRepository.getByCountryIds(fromRouteNode.get().getCountryId(), toRouteNode.get().getCountryId(), period.getId());
|
||||||
constructRouteSectionError(calculationDate, fromRouteNode.get(), toRouteNode.get(), rate.isEmpty(), supplierName, partNumber);
|
constructRouteSectionError(calculationDate, fromRouteNode.get(), toRouteNode.get(), rate.isEmpty(), supplierName, partNumber, showCalcInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (RateType.CONTAINER == section.getRateType()) {
|
if (RateType.CONTAINER == section.getRateType()) {
|
||||||
var rate = containerRateRepository.findRoute(fromRouteNode.get().getNodeId(), toRouteNode.get().getNodeId(), period.getId(), section.getTransportType());
|
var rate = containerRateRepository.findRoute(fromRouteNode.get().getNodeId(), toRouteNode.get().getNodeId(), period.getId(), section.getTransportType());
|
||||||
constructRouteSectionError(calculationDate, fromRouteNode.get(), toRouteNode.get(), rate.isEmpty(), supplierName, partNumber);
|
constructRouteSectionError(calculationDate, fromRouteNode.get(), toRouteNode.get(), rate.isEmpty(), supplierName, partNumber, showCalcInfo);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private void constructRouteSectionError(LocalDate calculationDate, RouteNode fromRouteNode, RouteNode toRouteNode, boolean empty, String supplierName, String partNumber) {
|
private void constructRouteSectionError(LocalDate calculationDate, RouteNode fromRouteNode, RouteNode toRouteNode, boolean empty, String supplierName, String partNumber, boolean showCalcInfo) {
|
||||||
if (empty) {
|
if (empty) {
|
||||||
var dateStr = calculationDate == null ? "" : String.format("on %s", calculationDate.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")));
|
var dateStr = calculationDate == null ? "" : String.format("on %s", calculationDate.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")));
|
||||||
var errStr = String.format("Transport rate missing for %s to %s %s - please contact your administrator",
|
var errStr = String.format("Transport rate missing for %s to %s %s - please contact your administrator",
|
||||||
fromRouteNode.getExternalMappingId(),
|
fromRouteNode.getExternalMappingId(),
|
||||||
toRouteNode.getExternalMappingId(),
|
toRouteNode.getExternalMappingId(),
|
||||||
dateStr);
|
dateStr);
|
||||||
throw new PremiseValidationError(errStr, supplierName, partNumber);
|
throw new PremiseValidationError(errStr, supplierName, partNumber, showCalcInfo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void destinationCheck(Destination destination, Node node, String supplierName, String partNumber) {
|
private void destinationCheck(Destination destination, Node node, String supplierName, String partNumber, boolean showCalcInfo) {
|
||||||
|
|
||||||
if (destination.getAnnualAmount() == null || destination.getAnnualAmount() == 0)
|
if (destination.getAnnualAmount() == null || destination.getAnnualAmount() == 0)
|
||||||
throw new PremiseValidationError(String.format("Annual quantity for %s must be greater than zero", node.getName()), supplierName, partNumber);
|
throw new PremiseValidationError(String.format("Annual quantity for %s must be greater than zero", node.getName()), supplierName, partNumber, showCalcInfo);
|
||||||
|
|
||||||
if (destination.getD2d() == null)
|
if (destination.getD2d() == null)
|
||||||
throw new PremiseValidationError(String.format("Something's missing for %s - please contact your administrator", node.getName()), supplierName, partNumber);
|
throw new PremiseValidationError(String.format("Something's missing for %s - please contact your administrator", node.getName()), supplierName, partNumber, showCalcInfo);
|
||||||
|
|
||||||
if (destination.getD2d() == true) {
|
if (destination.getD2d() == true) {
|
||||||
if (destination.getRateD2d() == null || destination.getRateD2d().compareTo(BigDecimal.ZERO) == 0) {
|
if (destination.getRateD2d() == null || destination.getRateD2d().compareTo(BigDecimal.ZERO) == 0) {
|
||||||
throw new PremiseValidationError(String.format("Door-to-door rate for %s needs to be greater than zero", node.getName()), supplierName, partNumber);
|
throw new PremiseValidationError(String.format("Door-to-door rate for %s needs to be greater than zero", node.getName()), supplierName, partNumber, showCalcInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (destination.getLeadTimeD2d() == null || destination.getLeadTimeD2d() == 0) {
|
if (destination.getLeadTimeD2d() == null || destination.getLeadTimeD2d() == 0) {
|
||||||
throw new PremiseValidationError(String.format("Please set a lead time for door-to-door delivery to %s", node.getName()), supplierName, partNumber);
|
throw new PremiseValidationError(String.format("Please set a lead time for door-to-door delivery to %s", node.getName()), supplierName, partNumber, showCalcInfo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (destination.getCountryId() == null || destination.getCountryId() == 0) {
|
if (destination.getCountryId() == null || destination.getCountryId() == 0) {
|
||||||
throw new PremiseValidationError(String.format("Configuration issue with %s - please contact your administrator", node.getName()), supplierName, partNumber);
|
throw new PremiseValidationError(String.format("Configuration issue with %s - please contact your administrator", node.getName()), supplierName, partNumber, showCalcInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (destination.getGeoLat() == null || destination.getGeoLng() == null) {
|
if (destination.getGeoLat() == null || destination.getGeoLng() == null) {
|
||||||
throw new PremiseValidationError(String.format("Location data missing for %s - please contact your administrator", node.getName()), supplierName, partNumber);
|
throw new PremiseValidationError(String.format("Location data missing for %s - please contact your administrator", node.getName()), supplierName, partNumber, showCalcInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (destination.getDisposalCost() != null && destination.getDisposalCost().compareTo(BigDecimal.ZERO) < 0) {
|
if (destination.getDisposalCost() != null && destination.getDisposalCost().compareTo(BigDecimal.ZERO) < 0) {
|
||||||
throw new PremiseValidationError(String.format("Disposal costs for %s can't be negative", node.getName()), supplierName, partNumber);
|
throw new PremiseValidationError(String.format("Disposal costs for %s can't be negative", node.getName()), supplierName, partNumber, showCalcInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (destination.getHandlingCost() != null && destination.getHandlingCost().compareTo(BigDecimal.ZERO) < 0) {
|
if (destination.getHandlingCost() != null && destination.getHandlingCost().compareTo(BigDecimal.ZERO) < 0) {
|
||||||
throw new PremiseValidationError(String.format("Handling costs for %s can't be negative", node.getName()), supplierName, partNumber);
|
throw new PremiseValidationError(String.format("Handling costs for %s can't be negative", node.getName()), supplierName, partNumber, showCalcInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (destination.getRepackingCost() != null && destination.getRepackingCost().compareTo(BigDecimal.ZERO) < 0) {
|
if (destination.getRepackingCost() != null && destination.getRepackingCost().compareTo(BigDecimal.ZERO) < 0) {
|
||||||
throw new PremiseValidationError(String.format("Repackaging costs for %s can't be negative", node.getName()), supplierName, partNumber);
|
throw new PremiseValidationError(String.format("Repackaging costs for %s can't be negative", node.getName()), supplierName, partNumber, showCalcInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -242,62 +242,62 @@ public class PreCalculationCheckService {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void priceCheck(Premise premise, String supplierName, String partNumber) {
|
private void priceCheck(Premise premise, String supplierName, String partNumber, boolean showCalcInfo) {
|
||||||
|
|
||||||
if (premise.getMaterialCost() == null || premise.getMaterialCost().compareTo(BigDecimal.ZERO) == 0) {
|
if (premise.getMaterialCost() == null || premise.getMaterialCost().compareTo(BigDecimal.ZERO) == 0) {
|
||||||
throw new PremiseValidationError("Please enter a material cost (MEK_A) greater than zero", supplierName, partNumber);
|
throw new PremiseValidationError("Please enter a material cost (MEK_A) greater than zero", supplierName, partNumber, showCalcInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (premise.getOverseaShare() == null) {
|
if (premise.getOverseaShare() == null) {
|
||||||
throw new PremiseValidationError("Please enter the overseas share", supplierName, partNumber);
|
throw new PremiseValidationError("Please enter the overseas share", supplierName, partNumber, showCalcInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (premise.getFcaEnabled() == null) {
|
if (premise.getFcaEnabled() == null) {
|
||||||
throw new PremiseValidationError("FCA configuration missing - please contact your administrator", supplierName, partNumber);
|
throw new PremiseValidationError("FCA configuration missing - please contact your administrator", supplierName, partNumber, showCalcInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void packagingCheck(Premise premise, String supplierName, String partNumber) {
|
private void packagingCheck(Premise premise, String supplierName, String partNumber, boolean showCalcInfo) {
|
||||||
|
|
||||||
if (premise.getHuMixable() == null) {
|
if (premise.getHuMixable() == null) {
|
||||||
throw new PremiseValidationError("Packaging configuration incomplete - please contact your administrator", supplierName, partNumber);
|
throw new PremiseValidationError("Packaging configuration incomplete - please contact your administrator", supplierName, partNumber, showCalcInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (premise.getHuStackable() == null) {
|
if (premise.getHuStackable() == null) {
|
||||||
throw new PremiseValidationError("Packaging configuration incomplete - please contact your administrator", supplierName, partNumber);
|
throw new PremiseValidationError("Packaging configuration incomplete - please contact your administrator", supplierName, partNumber, showCalcInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (premise.getHuStackable() == false && premise.getHuMixable() == true) {
|
if (premise.getHuStackable() == false && premise.getHuMixable() == true) {
|
||||||
throw new PremiseValidationError("If packaging is mixable, it must also be stackable", supplierName, partNumber);
|
throw new PremiseValidationError("If packaging is mixable, it must also be stackable", supplierName, partNumber, showCalcInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (premise.getIndividualHuLength() == null || premise.getIndividualHuLength() == 0) {
|
if (premise.getIndividualHuLength() == null || premise.getIndividualHuLength() == 0) {
|
||||||
throw new PremiseValidationError("Please enter packaging length", supplierName, partNumber);
|
throw new PremiseValidationError("Please enter packaging length", supplierName, partNumber, showCalcInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (premise.getIndividualHuWidth() == null || premise.getIndividualHuWidth() == 0) {
|
if (premise.getIndividualHuWidth() == null || premise.getIndividualHuWidth() == 0) {
|
||||||
throw new PremiseValidationError("Please enter packaging width", supplierName, partNumber);
|
throw new PremiseValidationError("Please enter packaging width", supplierName, partNumber, showCalcInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (premise.getIndividualHuHeight() == null || premise.getIndividualHuHeight() == 0) {
|
if (premise.getIndividualHuHeight() == null || premise.getIndividualHuHeight() == 0) {
|
||||||
throw new PremiseValidationError("Please enter packaging height", supplierName, partNumber);
|
throw new PremiseValidationError("Please enter packaging height", supplierName, partNumber, showCalcInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (premise.getIndividualHuWeight() == null || premise.getIndividualHuWeight() == 0) {
|
if (premise.getIndividualHuWeight() == null || premise.getIndividualHuWeight() == 0) {
|
||||||
throw new PremiseValidationError("Please enter packaging weight", supplierName, partNumber);
|
throw new PremiseValidationError("Please enter packaging weight", supplierName, partNumber, showCalcInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (premise.getHuUnitCount() == null || premise.getHuUnitCount() == 0) {
|
if (premise.getHuUnitCount() == null || premise.getHuUnitCount() == 0) {
|
||||||
throw new PremiseValidationError("Please enter the number of units per package", supplierName, partNumber);
|
throw new PremiseValidationError("Please enter the number of units per package", supplierName, partNumber, showCalcInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (premise.getHuDisplayedWeightUnit() == null) {
|
if (premise.getHuDisplayedWeightUnit() == null) {
|
||||||
throw new PremiseValidationError("Weight unit configuration missing - please contact your administrator", supplierName, partNumber);
|
throw new PremiseValidationError("Weight unit configuration missing - please contact your administrator", supplierName, partNumber, showCalcInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (premise.getHuDisplayedDimensionUnit() == null) {
|
if (premise.getHuDisplayedDimensionUnit() == null) {
|
||||||
throw new PremiseValidationError("Dimension unit configuration missing - please contact your administrator", supplierName, partNumber);
|
throw new PremiseValidationError("Dimension unit configuration missing - please contact your administrator", supplierName, partNumber, showCalcInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
var hu = dimensionTransformer.toDimensionEntity(premise).withTolerance(DIMENSION_TOLERANCE);
|
var hu = dimensionTransformer.toDimensionEntity(premise).withTolerance(DIMENSION_TOLERANCE);
|
||||||
|
|
@ -306,26 +306,26 @@ public class PreCalculationCheckService {
|
||||||
Optional<Integer> feuLoad = propertyService.getProperty(SystemPropertyMappingId.TEU_LOAD);
|
Optional<Integer> feuLoad = propertyService.getProperty(SystemPropertyMappingId.TEU_LOAD);
|
||||||
|
|
||||||
if (teuLoad.isEmpty() || feuLoad.isEmpty())
|
if (teuLoad.isEmpty() || feuLoad.isEmpty())
|
||||||
throw new PremiseValidationError("System configuration incomplete - please contact your administrator", supplierName, partNumber);
|
throw new PremiseValidationError("System configuration incomplete - please contact your administrator", supplierName, partNumber, showCalcInfo);
|
||||||
|
|
||||||
if (WeightUnit.KG.convertFromG(hu.getWeight()) > teuLoad.get() && hu.getWeight() > feuLoad.get())
|
if (WeightUnit.KG.convertFromG(hu.getWeight()) > teuLoad.get() && hu.getWeight() > feuLoad.get())
|
||||||
throw new PremiseValidationError(String.format("Package weight exceeds %d kg - please check your weight and unit", Math.max(teuLoad.get(), feuLoad.get())), supplierName, partNumber);
|
throw new PremiseValidationError(String.format("Package weight exceeds %d kg - please check your weight and unit", Math.max(teuLoad.get(), feuLoad.get())), supplierName, partNumber, showCalcInfo);
|
||||||
|
|
||||||
var teuFitsXY = (hu.getLength() < ContainerType.TEU.getLength() && hu.getWidth() < ContainerType.TEU.getWidth());
|
var teuFitsXY = (hu.getLength() < ContainerType.TEU.getLength() && hu.getWidth() < ContainerType.TEU.getWidth());
|
||||||
var teuFitsYX = (hu.getWidth() < ContainerType.TEU.getLength() && hu.getLength() < ContainerType.TEU.getWidth());
|
var teuFitsYX = (hu.getWidth() < ContainerType.TEU.getLength() && hu.getLength() < ContainerType.TEU.getWidth());
|
||||||
|
|
||||||
if (!teuFitsYX && !teuFitsXY) {
|
if (!teuFitsYX && !teuFitsXY) {
|
||||||
throw new PremiseValidationError("Package dimensions are too large - please check your measurements and unit", supplierName, partNumber);
|
throw new PremiseValidationError("Package dimensions are too large - please check your measurements and unit", supplierName, partNumber, showCalcInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if ((hu.getLength() * hu.getWidth()) < 20000) {
|
if ((hu.getLength() * hu.getWidth()) < 20000) {
|
||||||
throw new PremiseValidationError("Package dimensions are too small - please check your measurements and unit", supplierName, partNumber);
|
throw new PremiseValidationError("Package dimensions are too small - please check your measurements and unit", supplierName, partNumber, showCalcInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void materialCheck(Premise premise, String supplierName, String partNumber) {
|
private void materialCheck(Premise premise, String supplierName, String partNumber, boolean showCalcInfo) {
|
||||||
|
|
||||||
// if (premise.getTariffUnlocked()) {
|
// if (premise.getTariffUnlocked()) {
|
||||||
// if (premise.getHsCode() == null || premise.getHsCode().length() < 10)
|
// if (premise.getHsCode() == null || premise.getHsCode().length() < 10)
|
||||||
|
|
@ -337,7 +337,7 @@ public class PreCalculationCheckService {
|
||||||
// }
|
// }
|
||||||
|
|
||||||
if (premise.getTariffRate() == null) {
|
if (premise.getTariffRate() == null) {
|
||||||
throw new PremiseValidationError("Please enter a tariff rate", supplierName, partNumber);
|
throw new PremiseValidationError("Please enter a tariff rate", supplierName, partNumber, showCalcInfo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ public class AppTransformer {
|
||||||
|
|
||||||
dto.setId(entity.getId());
|
dto.setId(entity.getId());
|
||||||
dto.setName(entity.getName());
|
dto.setName(entity.getName());
|
||||||
dto.setClientSecret(entity.getClientSecret());
|
|
||||||
dto.setClientId(entity.getClientId());
|
dto.setClientId(entity.getClientId());
|
||||||
dto.setGroups(entity.getGroups().stream().map(Group::getName).toList());
|
dto.setGroups(entity.getGroups().stream().map(Group::getName).toList());
|
||||||
|
|
||||||
|
|
@ -38,4 +37,26 @@ public class AppTransformer {
|
||||||
group.setName(name);
|
group.setName(name);
|
||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public AppDTO toAppDTOWithHashedSecret(App entity) {
|
||||||
|
AppDTO dto = new AppDTO();
|
||||||
|
|
||||||
|
dto.setName(entity.getName());
|
||||||
|
dto.setClientSecret(entity.getClientSecret());
|
||||||
|
dto.setClientId(entity.getClientId());
|
||||||
|
dto.setGroups(entity.getGroups().stream().map(Group::getName).toList());
|
||||||
|
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
public App toAppEntityWithHashedSecret(AppDTO dto) {
|
||||||
|
App entity = new App();
|
||||||
|
|
||||||
|
entity.setName(dto.getName());
|
||||||
|
entity.setClientSecret(dto.getClientSecret());
|
||||||
|
entity.setClientId(dto.getClientId());
|
||||||
|
entity.setGroups(dto.getGroups().stream().map(this::fromGroupDTO).toList());
|
||||||
|
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -329,7 +329,7 @@ public class ReportTransformer {
|
||||||
var airfreightValue = destination.stream().map(CalculationJobDestination::getAnnualAirFreightCost).reduce(BigDecimal.ZERO, BigDecimal::add).divide(annualAmount, 4, RoundingMode.HALF_UP);
|
var airfreightValue = destination.stream().map(CalculationJobDestination::getAnnualAirFreightCost).reduce(BigDecimal.ZERO, BigDecimal::add).divide(annualAmount, 4, RoundingMode.HALF_UP);
|
||||||
|
|
||||||
var materialValue = destination.stream().map(CalculationJobDestination::getMaterialCost).reduce(BigDecimal.ZERO, BigDecimal::add).divide(BigDecimal.valueOf(destination.size()), 4, RoundingMode.HALF_UP);
|
var materialValue = destination.stream().map(CalculationJobDestination::getMaterialCost).reduce(BigDecimal.ZERO, BigDecimal::add).divide(BigDecimal.valueOf(destination.size()), 4, RoundingMode.HALF_UP);
|
||||||
var fcaFeesValues = destination.stream().map(CalculationJobDestination::getFcaCost).reduce(BigDecimal.ZERO, BigDecimal::add);
|
var fcaFeesValues = destination.stream().map(CalculationJobDestination::getFcaCost).reduce(BigDecimal.ZERO, BigDecimal::add).divide(BigDecimal.valueOf(destination.size()), 4, RoundingMode.HALF_UP);
|
||||||
var repackingValues = annualAmount.compareTo(BigDecimal.ZERO) == 0 ? BigDecimal.ZERO : destination.stream().map(CalculationJobDestination::getAnnualRepackingCost).reduce(BigDecimal.ZERO, BigDecimal::add).divide(annualAmount, 4, RoundingMode.HALF_UP);
|
var repackingValues = annualAmount.compareTo(BigDecimal.ZERO) == 0 ? BigDecimal.ZERO : destination.stream().map(CalculationJobDestination::getAnnualRepackingCost).reduce(BigDecimal.ZERO, BigDecimal::add).divide(annualAmount, 4, RoundingMode.HALF_UP);
|
||||||
var handlingValues = annualAmount.compareTo(BigDecimal.ZERO) == 0 ? BigDecimal.ZERO : destination.stream().map(CalculationJobDestination::getAnnualHandlingCost).reduce(BigDecimal.ZERO, BigDecimal::add).divide(annualAmount, 4, RoundingMode.HALF_UP);
|
var handlingValues = annualAmount.compareTo(BigDecimal.ZERO) == 0 ? BigDecimal.ZERO : destination.stream().map(CalculationJobDestination::getAnnualHandlingCost).reduce(BigDecimal.ZERO, BigDecimal::add).divide(annualAmount, 4, RoundingMode.HALF_UP);
|
||||||
var storageValues = annualAmount.compareTo(BigDecimal.ZERO) == 0 ? BigDecimal.ZERO : destination.stream().map(CalculationJobDestination::getAnnualStorageCost).reduce(BigDecimal.ZERO, BigDecimal::add).divide(annualAmount, 4, RoundingMode.HALF_UP);
|
var storageValues = annualAmount.compareTo(BigDecimal.ZERO) == 0 ? BigDecimal.ZERO : destination.stream().map(CalculationJobDestination::getAnnualStorageCost).reduce(BigDecimal.ZERO, BigDecimal::add).divide(annualAmount, 4, RoundingMode.HALF_UP);
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ import de.avatic.lcc.util.exception.base.InternalErrorException;
|
||||||
|
|
||||||
public class PremiseValidationError extends InternalErrorException {
|
public class PremiseValidationError extends InternalErrorException {
|
||||||
|
|
||||||
public PremiseValidationError(String message, String supplierName, String partNumber) {
|
public PremiseValidationError(String message, String supplierName, String partNumber, boolean showCalcInfo) {
|
||||||
super("Calculation data validation failed.", String.format("%s (Part number:%s - %s)",message, partNumber, supplierName));
|
super("Calculation data validation failed.", showCalcInfo ? String.format("%s (Part number: %s - %s)" , message, partNumber, supplierName) : message);
|
||||||
}
|
}
|
||||||
|
|
||||||
public PremiseValidationError(String message) {
|
public PremiseValidationError(String message) {
|
||||||
|
|
|
||||||
113
src/main/resources/static/help/assistant.md
Normal file
113
src/main/resources/static/help/assistant.md
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
|
||||||
|
> The logistics cost calculation tool lets you create cost calculations for combinations of supplier and part number. You can set up several supplier/part number combos at once. The assistant helps you import part numbers, assign existing suppliers, add new suppliers, and then automatically generates all the calculations you need.
|
||||||
|
|
||||||
|
### Step 1: Start the Assistant for a New Calculation
|
||||||
|
|
||||||
|
In the main view of the logistics cost calculation tool, open the assistant by clicking the **“New Calculation”** button.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Step 2: Paste Part Numbers from Text
|
||||||
|
|
||||||
|
When you start the assistant, a window pops up where you can paste your part numbers. Just copy the relevant part numbers from a text, like an email, and drop them into the input field.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Step 3: Analyze the Text and Detect Part Numbers
|
||||||
|
|
||||||
|
Click **“Analyse Input”** in the assistant so the tool can scan the pasted text for part numbers and pull them out.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
All detected part numbers will then be listed in the assistant, ready for the next steps.
|
||||||
|
|
||||||
|
### Step 4: Reset Detected Part Numbers Input Step
|
||||||
|
|
||||||
|
If you spot a mistake or want to start over with different part numbers, click **“Drop Part Numbers”** at the top of the assistant. This will remove all currently detected part numbers and reset the input step.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
You can then paste in new or corrected part numbers if needed.
|
||||||
|
|
||||||
|
### Step 5: Enter Part Numbers Manually and Analyze Again
|
||||||
|
|
||||||
|
You can also type in part numbers yourself. Enter the part numbers into the text field, separated by any delimiter you like (such as comma, semicolon, space, or line break).
|
||||||
|
Then click **“Analyse Input”** again so the tool can detect and extract the part numbers you entered.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The assistant will show the successfully recognized part numbers. The tool will also try to suggest matching suppliers if calculations have been made for certain part numbers and suppliers before. These will appear below the part numbers.
|
||||||
|
|
||||||
|
### Step 6: Remove Supplier and Part Number Entries
|
||||||
|
|
||||||
|
If you want to remove a supplier from your current selection, click the trash can icon next to that supplier.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
You can remove a single part number from the list the same way.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
This lets you tidy up any unnecessary combinations before creating your calculations.
|
||||||
|
|
||||||
|
### Step 7: Search for and Add Existing Suppliers
|
||||||
|
|
||||||
|
To add another supplier to one or more part numbers, use the search box in the assistant.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Enter the name of the supplier you want, pick them from the results list, and click **“Bietvorschlag”** to add them.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The supplier will then be assigned to the selected part numbers.
|
||||||
|
|
||||||
|
### Step 8: Create a New Supplier
|
||||||
|
|
||||||
|
If you don’t find the supplier you’re looking for, you can add a new one by clicking the **“Create New Supplier”** button.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
In the dialog that opens, type in the new supplier’s name and then enter their address.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
For example, you can enter the street, house number, zip code, and city (like “Bozener Straße, Ludwigshafen”). Click **“Check Address”** afterward so the tool can check and, if needed, correct the address.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
A map will be shown so you can confirm the address was correctly recognized.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Once all details are correct, click the **“Create”** button to add the new supplier and insert them directly into your current form.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Step 9: Review the Number of Calculations to be Created
|
||||||
|
|
||||||
|
After you’ve assigned all relevant part numbers and suppliers, the tool will show you at the bottom how many calculations will be created from the available combinations.
|
||||||
|
Each unique pairing of a part number and supplier results in its own logistics calculation.
|
||||||
|
|
||||||
|
### Step 10: Set Up Data Transfer from Existing Calculations
|
||||||
|
|
||||||
|
At the end of the assistant, there’s a checkbox that lets you choose whether to pull in data from already existing calculations:
|
||||||
|
|
||||||
|
* If the box is checked, the tool will try to import any available calculation data and pre-fill the new calculations, saving you manual entry later.
|
||||||
|
* If you leave it unchecked, the new calculations will be created from scratch, without any pre-filled info.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Step 11: Generate Calculations
|
||||||
|
|
||||||
|
Finally, click the **“Create”** button to set up all calculations for your selected combinations of part numbers and suppliers.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Your new logistics calculations will now be available in the system for further editing.
|
||||||
116
src/main/resources/static/help/dashboard.md
Normal file
116
src/main/resources/static/help/dashboard.md
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
|
||||||
|
> This document explains how the personal dashboard in the Logistics Cost Calculation Tool is set up, how to use filters, selection features, and the different actions available for one or multiple calculations.
|
||||||
|
|
||||||
|
### Step 1: Opening the Dashboard
|
||||||
|
|
||||||
|
When you start the Logistics Cost Calculation Tool, your personal dashboard opens automatically. You can also get to the dashboard anytime by clicking **“My Calculations”** at the top of the main menu.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Step 2: Understanding the Dashboard Layout
|
||||||
|
|
||||||
|
The dashboard is divided into two main sections:
|
||||||
|
The **top section** displays stats about your calculations. The **bottom section** is a table listing all your calculations and drafts.
|
||||||
|
|
||||||
|
In the stats area, you can quickly see:
|
||||||
|
- how many calculations you’ve already completed,
|
||||||
|
- how many are currently in draft mode,
|
||||||
|
- how many have been put in the queue and are waiting to run,
|
||||||
|
- and how many of those queued calculations have failed.
|
||||||
|
|
||||||
|
As soon as a calculation finishes successfully, it gets added to the **“Completed”** count in the stats section.
|
||||||
|
|
||||||
|
### Step 3: Filtering the Calculations Table
|
||||||
|
|
||||||
|
The lower part of the dashboard shows a table with all your calculations. There’s a search bar above the table so you can filter the list.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
You can use this search bar to filter by **supplier** or **part number**. The table updates automatically as you type.
|
||||||
|
|
||||||
|
There’s also a status dropdown to choose which calculations you want to see. For example, you can filter to show only **completed** calculations.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
### Step 4: Navigating the Table
|
||||||
|
|
||||||
|
At the bottom of the table, you’ll find controls for **page navigation** so you can flip through multiple pages of calculations.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
### Step 5: Selecting One or Multiple Calculations
|
||||||
|
|
||||||
|
In the first column of the table, there are **checkboxes** so you can select one or more calculations.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
You can even select calculations across different pages—the selection is **persistent** as you move from page to page.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
Once you’ve selected at least one calculation, a **toolbar** pops up at the bottom of the screen. It shows how many calculations you’ve picked and gives you buttons for available actions, as well as a button to clear your selection.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
### Step 6: Using Actions for Single Calculations
|
||||||
|
|
||||||
|
In each row of the table, there are buttons for actions you can take on that specific calculation:
|
||||||
|
|
||||||
|
- **Pencil icon**: Edit the calculation
|
||||||
|
- **Trash can icon**: Delete the calculation
|
||||||
|
- **Archive icon**: Archive the calculation
|
||||||
|
|
||||||
|
Not all actions are available for every status:
|
||||||
|
- Editing is only possible for calculations in **draft mode**.
|
||||||
|
- Deleting is also only possible for **draft** calculations.
|
||||||
|
- Archiving is only available for **completed** calculations.
|
||||||
|
|
||||||
|
### Step 7: Working with Multiple Calculations at Once
|
||||||
|
|
||||||
|
If you select multiple calculations using the checkboxes,
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
the toolbar at the bottom lets you apply the same actions (like **edit**, **delete**, **archive**) to all selected calculations at once.
|
||||||
|
|
||||||
|
If you, for example, select both completed calculations and drafts, then hit **Edit**,
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
you’ll see an error message. This message tells you that completed calculations can’t be edited directly. You’ll get a few options:
|
||||||
|
|
||||||
|
- Create a **copy** of the completed calculations and edit those copies.
|
||||||
|
- Open **only** the calculations that are still in **draft mode**.
|
||||||
|
- Or use **Cancel** to stop the action.
|
||||||
|
|
||||||
|
### Step 8: Deleting Multiple Calculations
|
||||||
|
|
||||||
|
If you select several calculations and then click the **Delete** button in the toolbar,
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
you’ll get an error message if any of the selected calculations can’t be deleted. The message will tell you that only calculations in **draft mode** can be deleted—so, for example, two out of four selected items.
|
||||||
|
|
||||||
|
To confirm the deletion, just click the **Delete** button again. You can stop the process with the **Cancel** button.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Step 9: Archiving Multiple Calculations
|
||||||
|
|
||||||
|
If you select several calculations and click the **Archive** button in the toolbar,
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
you’ll see a message letting you know that only **completed** calculations can be archived. If you’ve selected drafts too, they won’t be archived.
|
||||||
|
|
||||||
|
Click the **Archive** button in the message to archive the selected completed calculations. Or, hit **Cancel** to cancel the action.
|
||||||
|
|
||||||
|

|
||||||
198
src/main/resources/static/help/mass-edit-basics.md
Normal file
198
src/main/resources/static/help/mass-edit-basics.md
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
|
||||||
|
> This guide explains how to edit multiple calculations at once in the mass edit view, how to switch between mass and single editing, and how to set, update, or copy values from other calculations as needed.
|
||||||
|
|
||||||
|
### Step 1: Open the Mass Edit Mode
|
||||||
|
|
||||||
|
Whenever you select several calculations at the same time and start to edit them, the mass edit mode will automatically open.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
After picking multiple calculations, you can begin editing.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
In mass edit mode, each row in the table represents one calculation. Every calculation always refers to a combination of material and supplier.
|
||||||
|
|
||||||
|
### Step 2: Edit Individual Rows in Mass Edit View
|
||||||
|
|
||||||
|
At the end of each row, there’s a pencil icon you can click to open that specific calculation in single edit mode.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
When you click the pencil, you’ll switch to the detailed view for that calculation, where you can set all parameters just like usual.
|
||||||
|
|
||||||
|
To get back to the mass edit view from single edit mode, use the back button.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
There’s also an X icon in each row. Clicking this removes the row from the mass edit view, but it doesn’t delete the underlying calculation itself.
|
||||||
|
|
||||||
|
### Step 3: Edit Parameters Through Cells in the Mass View
|
||||||
|
|
||||||
|
The columns in the mass edit view match the parameter groups from the single edit screen.
|
||||||
|
For example, if you click the cell for “Packaging” in the first row,
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
the same packaging input window opens as in single edit mode.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
You can enter the values as usual in this window.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Here, you’ll enter for example the data for a Euro pallet,
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
including the weight
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
and the number of units per package.
|
||||||
|
|
||||||
|
Important
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
is to
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
pick the right dimensions.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Once you’re done, hit “OK” to confirm your entry,
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
and the values will be saved for that cell.
|
||||||
|
|
||||||
|
Cells with a red exclamation mark show that values are missing. Make sure to fill these in before your calculation is complete.
|
||||||
|
|
||||||
|
### Step 4: Edit Multiple Calculations at the Same Time
|
||||||
|
|
||||||
|
To update several calculations at once, you can select multiple rows in the mass edit view using the checkbox at the beginning of each row.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
As soon as you select more than one row, an editing bar will pop up at the bottom.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
This bar shows how many rows you have selected and lets you edit common parameters for all the marked calculations at once.
|
||||||
|
|
||||||
|
For example, clicking the “Price” field in the editing bar
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
opens the price input window.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
You might enter a price of eighty-five euros here,
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
along with a seventy percent Oversea Share. Then you check FCA fee.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
and confirm everything with “OK.” The values you entered will be applied to all the selected rows.
|
||||||
|
|
||||||
|
If you want to deselect the currently chosen rows, just click the “Cancel” button in the editing bar.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Step 5: Select Rows by Material or Supplier
|
||||||
|
|
||||||
|
If you want to pick specific rows, like all those with the same material number, hold down the Ctrl key and click on a material cell.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
This will select all rows with that material number. While holding Ctrl, your cursor will change to show you’re in multi-select mode.
|
||||||
|
|
||||||
|
The same approach works for suppliers: If you hold Ctrl and click on a supplier,
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
all rows with that supplier get selected.
|
||||||
|
|
||||||
|
If you want to combine different suppliers or materials, start by holding Ctrl
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
and clicking the first material or supplier cell.
|
||||||
|
Then, hold both Ctrl and Shift and click on another material number.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
This lets you add more rows to your current selection.
|
||||||
|
|
||||||
|
### Step 6: Set Shared Parameters (e.g. Packaging) for Selected Rows
|
||||||
|
|
||||||
|
For all currently selected items
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
you can edit the packaging together, for example.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
In the window that pops up, enter the new packaging values.
|
||||||
|
|
||||||
|
Important: If you only fill in certain fields (like just the HU length) and hit “OK,” only that specific field gets updated. Any other values that already exist in the other fields will stay the same.
|
||||||
|
|
||||||
|
For example, if you enter 1800 as the HU length in one field
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
and then click “OK,”
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
the HU length in that row will be set to 1800, but any previous values like 800 and 500 millimeters in other rows will remain.
|
||||||
|
|
||||||
|
### Step 7: Copy and Adjust Values Between Calculations
|
||||||
|
|
||||||
|
Another option is to copy values from one calculation into others.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Start by selecting the target calculations.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Next, click on the parameter group (such as Packaging, Price, etc.) that you want to copy values for.
|
||||||
|
The cursor will change to a special copy symbol to show that you can transfer values.
|
||||||
|
|
||||||
|
Now, if you click directly on the source cell (not through the editing bar at the bottom),
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
the values from that cell will be pulled into the input window.
|
||||||
|
You can tweak these values if needed
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
before confirming them with “OK.”
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The updated values will only go into the rows you picked earlier. The original source calculation stays unchanged.
|
||||||
|
|
||||||
|

|
||||||
270
src/main/resources/static/help/mass-edit-destinations.md
Normal file
270
src/main/resources/static/help/mass-edit-destinations.md
Normal file
|
|
@ -0,0 +1,270 @@
|
||||||
|
|
||||||
|
> This guide walks you through fully managing calculations in bulk edit mode—from setting up destinations (using the Destination Manager), distributing annual quantities, and handling routes, to entering handling, repackaging, and other costs. You'll also see how to manage Door-to-Door routes and efficiently edit values for multiple combinations at once.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
### Step 1: Understanding Warnings for Empty Cells
|
||||||
|
|
||||||
|
When you open calculations in bulk edit mode, you might notice empty values in the “Routes” or “Annual Quantity” columns. Clicking these cells will show a warning.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
This video explains what the warning means and how to handle it.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The warning pops up because destinations haven't been set up yet for these calculations.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
### Step 2: Open Destination Manager in Bulk Edit Mode
|
||||||
|
|
||||||
|
To add destinations in bulk edit mode, click the “Destination Manager” button at the top.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
A summary will pop up showing all your material and supplier combinations.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
There's a search bar at the top where you can look up specific plants (for example, type “Aschaffenburg” and pick it from the suggestions).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
You can then search for another plant (like “Geisa”)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
and add that one too.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
### Step 3: Assign Destinations to Combinations with Checkboxes
|
||||||
|
|
||||||
|
Once you've added your destinations, you'll see checkboxes for each material and supplier combination. Use these to assign the right destinations to each calculation.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
For example, you can set it up so a specific combo is delivered to both destinations.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Set checkmarks for all the combinations as needed.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
### Step 4: Apply Destinations and Auto-fill Cells
|
||||||
|
|
||||||
|
After assigning your destinations, click the “OK” button at the bottom to process the data.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The previously empty cells in the bulk edit table will now be filled in. You can always reopen the Destination Manager to adjust these settings
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
and confirm your changes again with “OK”.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The main table in bulk edit mode will update accordingly.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
### Step 5: Open Annual Quantity and Routes Menus
|
||||||
|
|
||||||
|
Now, when you click a cell in the “Annual Quantity” or “Routes” columns, the respective editing menu will pop up.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The easiest way to manage annual quantities and routes is to first select all relevant rows, then open the correct menu at the bottom by clicking the “Annual Quantity” or “Routes” button.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
This menu will include all materials and part numbers you've selected, so you can edit them together.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
### Step 6: Enter Annual Quantities for Destinations
|
||||||
|
|
||||||
|
In the annual quantity menu, you'll see all materials, suppliers, and destinations listed. Enter the quantity in the cell to show how much of a material, from a certain supplier, should go to a certain destination.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
You can type values directly into each cell
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
or select several rows at once
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
and enter the value in the input field at the top.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
For example, enter “600” for Aschaffenburg and “200” for Geisa, then confirm the entry for the selected calculations by clicking the checkmark. If you make a mistake, use the “X” to cancel. Click the checkmark to confirm your values.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Grayed-out cells show that, according to the Destination Manager, this material and supplier combo isn't delivered to that plant.
|
||||||
|
|
||||||
|
Now finish filling out the table.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
### Step 7: Edit Handling, Repackaging Costs, and Routes Using Tabs
|
||||||
|
|
||||||
|
At the top, you’ll see tabs for editing handling and repackaging costs, as well as routes, for all selected calculations.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Since all rows in bulk edit are selected, you can set all routes in one go.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
### Step 8: Set Routes via Dropdown and Door-to-Door Routing
|
||||||
|
|
||||||
|
Use the dropdown menus to choose the right route for each supplier and destination combination.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Pick the appropriate route for every combination.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Besides predefined routes, you can also use Door-to-Door routing.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
If you pick Door-to-Door, a small red exclamation mark appears. This means you still need to fill in a Door-to-Door rate and lead time. Click the little pencil icon
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
and enter the Door-to-Door rate and lead time.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Click “OK” to confirm.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The exclamation mark will disappear.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
### Step 9: Enter Handling and Repackaging Costs Individually
|
||||||
|
|
||||||
|
In the “Handling and Repackaging” tab, you can enter individual handling, repackaging, and disposal costs.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
First, acknowledge the warning by checking the box to enable editing.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
You'll see a list with all material, supplier, and destination combinations. Now, you can enter handling, repackaging, and disposal costs for each one individually.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
### Step 10: Use Ctrl to Multi-select and Set Values at Once
|
||||||
|
|
||||||
|
Both in the handling/repackaging tab and the annual quantity menu—just like in bulk edit—you can select the same material number multiple times by holding down the Ctrl key and clicking the material number.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Then, as with annual quantities, enter a value in the input bar at the top
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
and use the checkmark to apply it to all the selected rows.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
This also works for specific destinations or suppliers.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
### Step 11: Use Ctrl + Shift for Advanced Multi-selection
|
||||||
|
|
||||||
|
To select several suppliers at once—just like in the bulk editor—hold down the Shift key along with Ctrl, then click another supplier.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
This will add that supplier to your current selection.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
You can then enter values at the top again. Empty cells will be left as they are; only cells where you’ve typed something new will be changed. Existing values (like “300”) stay the same.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
This kind of selection is especially handy in the annual quantity menu.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
When you’re done, click the “OK” button to save your changes.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Now, your annual quantities and routes are fully filled out for bulk calculations.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
### Step 12: Start or Close Bulk Calculation
|
||||||
|
|
||||||
|
You can start the bulk calculation with the “Calculate and close” button. This works the same way as in single edit mode. Or, if you're done, close bulk editing with the “Close” button—your changes are saved automatically, so you won't lose any work.
|
||||||
|
|
||||||
|
If you hit “Close” while something is still missing, you'll get an error message (just like in single edit mode) showing which values need fixing.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
For example, if all rows are selected and you copy packaging data from one cell and confirm with “OK,”
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
all relevant values will be filled in and you can kick off the calculation with “Calculate and close.”
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Your calculations will then go into a queue and be processed in the background.
|
||||||
119
src/main/resources/static/help/report.md
Normal file
119
src/main/resources/static/help/report.md
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
|
||||||
|
> This guide shows you how to create a report from completed calculations, select the relevant suppliers, interpret the report’s contents (charts, key metrics, assumptions, and transport cost fluctuations), and export the report as an Excel file.
|
||||||
|
|
||||||
|
### Step 1: Switch to the Reporting View
|
||||||
|
|
||||||
|
First, switch to the Reporting view to start a new report.
|
||||||
|
Just click on the **"Reporting"** tab at the top of the app.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Step 2: Create a New Report
|
||||||
|
|
||||||
|
Once you’re in the Reporting view, click the **"Create Report"** button to make a new report.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Step 3: Search for Material or Part Number
|
||||||
|
|
||||||
|
A new window will pop up where you can search for the material or **part number** you need.
|
||||||
|
Start typing the part number into the search box until you see a suggestion that matches what you entered.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Click on the suggested part number to select it.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Step 4: Choose Suppliers for the Report
|
||||||
|
|
||||||
|
After picking the part number, you'll see a list of all suppliers that already have a calculation for that part.
|
||||||
|
Now, select the suppliers you want to include in your report.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
Confirm your selection by clicking **"OK"**.
|
||||||
|
|
||||||
|
Note: As soon as you pick a supplier, some other suppliers might disappear from the list. This happens because not all calculations are directly comparable. So you can only look at a certain subset of suppliers together in the report.
|
||||||
|
|
||||||
|
Click **"OK"** again to generate and display the report.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Step 5: Get an Overview of the Report Structure
|
||||||
|
|
||||||
|
When your report opens, you might see a warning that some of the reports can’t be fully compared—like if target plants or annual purchase quantities are different. At the bottom, you’ll see all the target plants, so you can spot if the calculations are for different locations (for example, Geisern, Aschaffenburg, or a mix).
|
||||||
|
|
||||||
|
At the top of the report, you’ll see:
|
||||||
|
|
||||||
|
- the time period for the calculations
|
||||||
|
- the part number the report is about
|
||||||
|
- several columns—one for each supplier you picked
|
||||||
|
|
||||||
|
You’ll also see a chart that breaks down the cost items for a quick overview:
|
||||||
|
|
||||||
|
- The **gray area** at the bottom is **MEK A**.
|
||||||
|
- The **green area** at the top is **logistics costs**.
|
||||||
|
- There’s also a thin **gray spread** showing logistics cost fluctuations:
|
||||||
|
- the top of the black bar: the highest possible logistics costs (based on past data)
|
||||||
|
- the bottom of the black bar: the lowest possible logistics costs (also from past data)
|
||||||
|
|
||||||
|
### Step 6: Understanding Key Metrics and Cost Breakdown
|
||||||
|
|
||||||
|
Further down in the report, the chart values are laid out in a table. Here, you’ll find:
|
||||||
|
|
||||||
|
- **MEK A price**, **logistics costs**, and **MEK B price**
|
||||||
|
- each shown as **absolute values**
|
||||||
|
- and as **percentages** relative to the MEK B price
|
||||||
|
|
||||||
|
You’ll also get a **cost breakdown**, weighted across all the target plants included in the calculations. This lets you see how each part of the logistics costs affects the total MEK B price.
|
||||||
|
|
||||||
|
The breakdown appears in both absolute numbers and percentages based on the MEK B price.
|
||||||
|
|
||||||
|
### Step 7: Check Transport Cost Fluctuations in Detail
|
||||||
|
|
||||||
|
The logistics cost fluctuations you see in the chart (the black area) are also available as actual numbers in the **"Transport cost fluctuations"** section.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Here you’ll find:
|
||||||
|
|
||||||
|
- the **total values** for the fluctuations
|
||||||
|
- the **percentage values** compared to the calculated MEK B price
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
### Step 8: View Assumptions and Logistics Route
|
||||||
|
|
||||||
|
At the bottom of the report, you’ll see the assumptions used for the calculation. This includes:
|
||||||
|
|
||||||
|
- the **material** used
|
||||||
|
- the **packaging dimensions** you entered
|
||||||
|
|
||||||
|
Expand the **"Target plant"** section to see more details.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Here, you’ll see the **logistics route** used as the basis for the calculation. If you hover your mouse over the route sections, you’ll get more detailed info, like:
|
||||||
|
|
||||||
|
- the **transport rate** used
|
||||||
|
- required **transport time**
|
||||||
|
- the **calculation model** applied
|
||||||
|
- whether the route section is based on a **container rate** or was calculated using a **kilometer cost matrix**
|
||||||
|
|
||||||
|
Below that, you’ll find extra assumptions specific to the target plant, like:
|
||||||
|
|
||||||
|
- **purchase quantity**
|
||||||
|
- **total transport time**
|
||||||
|
- the result of the **container calculation**
|
||||||
|
|
||||||
|
### Step 9: Export the Report as an Excel File
|
||||||
|
|
||||||
|
At the top of the report, you can download it as an **Excel file**.
|
||||||
|
|
||||||
|
The Excel file will include:
|
||||||
|
|
||||||
|
- all the information shown in the report
|
||||||
|
- plus a **simple summary** of the most important metrics and contents.
|
||||||
210
src/main/resources/static/help/single-edit.md
Normal file
210
src/main/resources/static/help/single-edit.md
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
|
||||||
|
> In this application, you’ll edit logistics cost calculations for a specific supplier and part number combination. The detailed view is divided into several sections where you’ll enter material details, pricing info, packaging data, and destination plants. At the end, you’ll start the calculation, and if there are any errors, you’ll be prompted with hints on how to fix them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 1: Open the detailed view of a calculation
|
||||||
|
|
||||||
|
In the overview, click on the pencil icon for the calculation you want to work on to switch to the detailed view for logistics cost calculation.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
At the top of the detailed view, you’ll see supplier information, and below that, the input forms divided into these sections:
|
||||||
|
- Material / part number
|
||||||
|
- Pricing information
|
||||||
|
- Packaging (handling unit, e.g. large load carrier)
|
||||||
|
- Calculation targets (plants to be supplied)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 2: Check and correct material and customs data as needed
|
||||||
|
|
||||||
|
In the first section, you’ll enter information about the part number, especially:
|
||||||
|
- HS code
|
||||||
|
- Customs tariff rate
|
||||||
|
|
||||||
|
When you create a calculation, the tool automatically looks up tariff rates via an API.
|
||||||
|
- If the lookup is clear, the HS code and customs tariff rate are prefilled and locked; you don’t need to enter anything.
|
||||||
|
- If the lookup isn’t clear (shown by a message at the bottom of the section), you’ll need to research and enter the correct customs tariff rate yourself. By default, 3% is prefilled here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3: Enter purchase price and overseas share
|
||||||
|
|
||||||
|
In the second section, you’ll enter pricing information:
|
||||||
|
|
||||||
|
1. Enter the MEK A purchase price.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
This MEK A price should always be considered the FCA price.
|
||||||
|
|
||||||
|
2. If the price you have isn’t an FCA price, but an Ex-Works price, check the “include FCA fee” box.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
This will automatically add a percentage fee to the MEK A price in the calculation.
|
||||||
|
|
||||||
|
3. If you already have an FCA price, uncheck the box so the extra fee isn’t added.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
4. In the “Overseas share” field, enter the percentage of suppliers who ship from overseas (not just for this calculation, but across all suppliers). Enter the number as a percentage, e.g. 70%.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 4: Configure packaging data and handling unit
|
||||||
|
|
||||||
|
Go to the third input section for packaging:
|
||||||
|
|
||||||
|
1. Enter the dimensions of the handling unit (typically the large load carrier; if you have small quantities, this might be the small load carrier—just keep that in mind for later).
|
||||||
|
For example, enter the measurements for a Euro pallet.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
2. Select the correct unit for these measurements (e.g. millimeters, if that’s what you entered).
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
3. Enter the weight of the handling unit and choose the right weight unit (e.g. kilograms).
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
4. Enter the number of individual units inside a handling unit (e.g. 200 pieces per large or small load carrier).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
5. Set the two important checkboxes on the right:
|
||||||
|
- Checkbox 1: “Mixed transport allowed” (the handling unit can be shipped together with other units or part numbers).
|
||||||
|
If you uncheck this box, the part number will always be shipped separately. This affects container utilization calculations:
|
||||||
|
- Unchecked: The tool uses an algorithm to determine the actual container utilization.
|
||||||
|
- Checked: A standard container utilization rate is used.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- Checkbox 2: “Stacking allowed” (units can be stacked).
|
||||||
|
If you uncheck this, only one layer is counted in the container (e.g. for fragile parts), which reduces utilization.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
6. Check both boxes again if the part number can be shipped mixed and stacked.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 5: Add destination plants and routes using the standard logic
|
||||||
|
|
||||||
|
At the bottom, you’ll now set the destinations for this calculation—meaning, the plants that will receive this part number:
|
||||||
|
|
||||||
|
1. In the search box, type in the plant you want (e.g. “Châtellerault”) and select it from the suggestions.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
2. In the window that opens, enter the annual quantity this plant will receive (e.g. 3,000 parts).
|
||||||
|
|
||||||
|
3. At the bottom, pick the route the containers will take to get to the plant (e.g. a route through a specific intermediate warehouse).
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
4. Switch to the second tab if you want to enter custom handling and repackaging costs. By default, the tool calculates these costs automatically.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
5. If your actual handling and repackaging costs are very different from the automatically calculated ones, check the box to confirm the warning and enter your own costs.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
6. Then, enter your individual costs in the three fields that appear (always based on the previously defined handling unit):
|
||||||
|
- Repackaging costs: The cost to repackage from the disposable pallet to the packaging needed at the plant (e.g. €6).
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
- Handling costs: The cost for goods receipt and storage (e.g. €3.12).
|
||||||
|
- Disposal costs: The cost for disposing of disposable packaging (e.g. €0 if there’s no charge).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
7. Confirm everything for this plant by clicking the “OK” button.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The plant now appears in the destinations list.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 6: Add another plant with a custom door-to-door rate
|
||||||
|
|
||||||
|
1. Add another plant, e.g. “Luzzara”.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
2. If no route can be determined from the supplier for this plant, select the option to set a custom rate.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
3. Enter a door-to-door rate that covers all costs from pickup at the supplier to delivery at the plant (e.g. €2,000).
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
4. Enter the lead time (e.g. 47 days) and the plant’s annual quantity (e.g. 1,000 pieces).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
5. Confirm this plant as well by clicking the “OK” button.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Now both plants receiving this part are listed in the destinations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 7: Run the calculation or cancel input
|
||||||
|
|
||||||
|
Once all data is entered, you can:
|
||||||
|
- Use the “Calculate and Close” button to start the calculation and close the form.
|
||||||
|
- Use the “Close” button to exit the form without starting the calculation.
|
||||||
|
|
||||||
|
All your entries are saved continuously, so nothing is lost when closing the form. You can reopen and continue the calculation anytime.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 8: Identify and fix input errors
|
||||||
|
|
||||||
|
To see how error handling works, try making a deliberate mistake, for example:
|
||||||
|
|
||||||
|
1. Accidentally select “centimeters” instead of “millimeters” for the dimensions. This will make the packaging seem way too big.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
2. Then start the calculation.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
You’ll see an error message at the bottom of the screen showing which values need to be fixed.
|
||||||
|
|
||||||
|
3. Correct the wrong unit or the value in question.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
4. Start the calculation again.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The calculation will now be queued and processed. You can see its status in your dashboard.
|
||||||
|
|
@ -7,26 +7,6 @@ VALUES ('USR001', 'john.doe@company.com', 'John', 'Doe', TRUE),
|
||||||
('USR005', 'david.chen@company.com', 'David', 'Chen', TRUE)
|
('USR005', 'david.chen@company.com', 'David', 'Chen', TRUE)
|
||||||
ON DUPLICATE KEY UPDATE email = VALUES(email);
|
ON DUPLICATE KEY UPDATE email = VALUES(email);
|
||||||
|
|
||||||
INSERT INTO sys_group(group_name, group_description)
|
|
||||||
VALUES ('none', 'no rights');
|
|
||||||
INSERT INTO sys_group(group_name, group_description)
|
|
||||||
VALUES ('basic', 'Login, generate reports');
|
|
||||||
INSERT INTO sys_group(group_name, group_description)
|
|
||||||
VALUES ('calculation', 'Login, generate reports, do calculations');
|
|
||||||
INSERT INTO sys_group(group_name, group_description)
|
|
||||||
VALUES ('freight', 'Login, generate reports, edit freight rates');
|
|
||||||
INSERT INTO sys_group(group_name, group_description)
|
|
||||||
VALUES ('packaging', 'Login, generate reports, edit packaging data');
|
|
||||||
INSERT INTO sys_group(group_name, group_description)
|
|
||||||
VALUES ('material', 'Login, generate reports, edit material data');
|
|
||||||
INSERT INTO sys_group(group_name, group_description)
|
|
||||||
VALUES ('super',
|
|
||||||
'Login, generate reports, do calculations, edit freight rates, edit packaging data');
|
|
||||||
INSERT INTO sys_group(group_name, group_description)
|
|
||||||
VALUES ('service', 'Register API Tokens');
|
|
||||||
INSERT INTO sys_group(group_name, group_description)
|
|
||||||
VALUES ('right-management',
|
|
||||||
'Add/Remove users, groups, etc.');
|
|
||||||
|
|
||||||
INSERT INTO sys_user_group_mapping (group_id, user_id)
|
INSERT INTO sys_user_group_mapping (group_id, user_id)
|
||||||
VALUES ((SELECT id FROM sys_group WHERE group_name = 'super'),
|
VALUES ((SELECT id FROM sys_group WHERE group_name = 'super'),
|
||||||
|
|
|
||||||
101
test/conftest.py
Normal file
101
test/conftest.py
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
# conftest.py
|
||||||
|
import pytest
|
||||||
|
from selenium import webdriver
|
||||||
|
from selenium.webdriver.chrome.options import Options
|
||||||
|
from selenium.webdriver.support.ui import WebDriverWait
|
||||||
|
from pathlib import Path
|
||||||
|
import openpyxl
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def browser_options():
|
||||||
|
options = Options()
|
||||||
|
options.add_argument("--start-maximized")
|
||||||
|
# options.add_argument("--headless") # Optional für CI/CD
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="function")
|
||||||
|
def driver(browser_options):
|
||||||
|
"""Erstellt einen neuen Browser pro Test"""
|
||||||
|
driver = webdriver.Chrome(options=browser_options)
|
||||||
|
driver.implicitly_wait(10)
|
||||||
|
yield driver
|
||||||
|
driver.quit()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="function")
|
||||||
|
def wait(driver):
|
||||||
|
"""WebDriverWait Instanz"""
|
||||||
|
return WebDriverWait(driver, 20)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def base_url():
|
||||||
|
"""Basis-URL deiner Anwendung"""
|
||||||
|
return "http://localhost:5173"
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_generate_tests(metafunc):
|
||||||
|
"""Parametrisiert Tests basierend auf Excel-Dateien im testdata Ordner"""
|
||||||
|
if "testcase_file" in metafunc.fixturenames:
|
||||||
|
testdata_dir = Path("testcases")
|
||||||
|
excel_files = list(testdata_dir.glob("*.xlsx"))
|
||||||
|
|
||||||
|
# IDs für bessere Testausgabe
|
||||||
|
ids = [f.stem for f in excel_files]
|
||||||
|
|
||||||
|
metafunc.parametrize("testcase_file", excel_files, ids=ids)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_data(testcase_file):
|
||||||
|
"""Lädt Testdaten aus Excel-Datei"""
|
||||||
|
wb = openpyxl.load_workbook(testcase_file)
|
||||||
|
|
||||||
|
# Eingabedaten aus "in" Sheet
|
||||||
|
input_sheet = wb["in"]
|
||||||
|
input_data = {}
|
||||||
|
groups = {}
|
||||||
|
for row in input_sheet.iter_rows(values_only=True):
|
||||||
|
if row[0]: # Feldname in Spalte A
|
||||||
|
if '_' in row[0]:
|
||||||
|
[remainder, suffix] = row[0].rsplit('_',1)
|
||||||
|
if suffix.isdigit():
|
||||||
|
[prefix, name] = remainder.split('_',1)
|
||||||
|
if prefix not in groups:
|
||||||
|
groups[prefix] = {}
|
||||||
|
if groups[prefix].get(suffix) is None:
|
||||||
|
groups[prefix][suffix] = {}
|
||||||
|
groups[prefix][suffix][name] = row[1]
|
||||||
|
else:
|
||||||
|
input_data[row[0]] = row[1]
|
||||||
|
else:
|
||||||
|
input_data[row[0]] = row[1] # Wert in Spalte B
|
||||||
|
input_data.update(groups)
|
||||||
|
|
||||||
|
# Erwartete Ausgaben aus "out" Sheet
|
||||||
|
output_sheet = wb["out"]
|
||||||
|
expected_data = {}
|
||||||
|
expected_groups = {}
|
||||||
|
for row in input_sheet.iter_rows(values_only=True):
|
||||||
|
if row[0]: # Feldname in Spalte A
|
||||||
|
if '_' in row[0]:
|
||||||
|
[remainder, suffix] = row[0].rsplit('_',1)
|
||||||
|
if suffix.isdigit():
|
||||||
|
[prefix, name] = remainder.split('_',1)
|
||||||
|
if prefix not in expected_groups:
|
||||||
|
expected_groups[prefix] = {}
|
||||||
|
if expected_groups[prefix].get(suffix) is None:
|
||||||
|
expected_groups[prefix][suffix] = {}
|
||||||
|
expected_groups[prefix][suffix][name] = row[1]
|
||||||
|
else:
|
||||||
|
expected_data[row[0]] = row[1]
|
||||||
|
else:
|
||||||
|
expected_data[row[0]] = row[1] # Wert in Spalte B
|
||||||
|
expected_data.update(expected_groups)
|
||||||
|
return {
|
||||||
|
"input": input_data,
|
||||||
|
"expected": expected_data,
|
||||||
|
"filename": testcase_file.name
|
||||||
|
}
|
||||||
46
test/pages/assistant.py
Normal file
46
test/pages/assistant.py
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import time
|
||||||
|
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
|
||||||
|
from conftest import driver
|
||||||
|
from pages.base_page import BasePage
|
||||||
|
|
||||||
|
|
||||||
|
class Assistant(BasePage):
|
||||||
|
"""Page Object für den Assistant"""
|
||||||
|
|
||||||
|
# Mapping von Excel-Feldnamen zu Locators
|
||||||
|
FIELD_MAPPING = {
|
||||||
|
"PART_NUMBER": (By.CSS_SELECTOR, "textarea[name='partNumbers']"),
|
||||||
|
"ANALYZE_BUTTON": (By.CSS_SELECTOR, ".part-number-modal-action > .btn--primary"),
|
||||||
|
"SUPPLIER_NAME": (By.CSS_SELECTOR, ".supplier-headers-searchbar-container .search-input"),
|
||||||
|
"LOAD_FROM_PREVIOUS": (By.CSS_SELECTOR, ".start-calculation-footer-container .checkbox-item"),
|
||||||
|
"CREATE_CALCULATION_BUTTON": (By.CSS_SELECTOR, ".start-calculation-footer-container .btn--secondary"),
|
||||||
|
"DELETE_SUPPLIER_BUTTON": (By.CSS_SELECTOR, ".supplier-headers ~ .item-list .item-list-element .icon-btn-container .icon-btn"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def search_part_numbers(self, data_dict):
|
||||||
|
"""Füllt das Formular mit Daten aus dem Excel"""
|
||||||
|
self.fill_input(*self.FIELD_MAPPING["PART_NUMBER"], data_dict["PART_NUMBER"])
|
||||||
|
self.click_button(*self.FIELD_MAPPING["ANALYZE_BUTTON"])
|
||||||
|
|
||||||
|
def delete_preselected_suppliers(self):
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
button = self.wait_for_clickable(
|
||||||
|
*self.FIELD_MAPPING["DELETE_SUPPLIER_BUTTON"],
|
||||||
|
timeout=1
|
||||||
|
)
|
||||||
|
button.click()
|
||||||
|
time.sleep(0.2)
|
||||||
|
except:
|
||||||
|
# Keine Buttons mehr vorhanden
|
||||||
|
break
|
||||||
|
|
||||||
|
def select_supplier(self, data_dict):
|
||||||
|
self.search_and_select_autosuggest(*self.FIELD_MAPPING["SUPPLIER_NAME"], data_dict["SUPPLIER_NAME"])
|
||||||
|
|
||||||
|
|
||||||
|
def create_calculation(self, data_dict):
|
||||||
|
self.set_checkbox(*self.FIELD_MAPPING["LOAD_FROM_PREVIOUS"], data_dict["LOAD_FROM_PREVIOUS"])
|
||||||
|
self.click_button(*self.FIELD_MAPPING["CREATE_CALCULATION_BUTTON"])
|
||||||
147
test/pages/base_page.py
Normal file
147
test/pages/base_page.py
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
# pages/base_page.py
|
||||||
|
from selenium.webdriver.support.ui import WebDriverWait
|
||||||
|
from selenium.webdriver.support import expected_conditions as EC
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
from selenium.common.exceptions import TimeoutException
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class BasePage:
|
||||||
|
"""Basis-Klasse für alle Page Objects"""
|
||||||
|
|
||||||
|
def __init__(self, driver, wait):
|
||||||
|
self.driver = driver
|
||||||
|
self.wait = wait
|
||||||
|
|
||||||
|
def wait_for_spa_navigation(self, expected_route_part, timeout=2):
|
||||||
|
"""Wartet bis SPA zur erwarteten Route navigiert hat"""
|
||||||
|
WebDriverWait(self.driver, timeout).until(
|
||||||
|
lambda d: expected_route_part in d.current_url
|
||||||
|
)
|
||||||
|
# Zusätzlich auf Vue-Rendering warten
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
def wait_for_element(self, by, value, timeout=2):
|
||||||
|
"""Wartet auf ein Element"""
|
||||||
|
start_time = time.time()
|
||||||
|
logger.info(f"Waiting for element: {by}={value}")
|
||||||
|
|
||||||
|
result = WebDriverWait(self.driver, timeout).until(
|
||||||
|
EC.presence_of_element_located((by, value))
|
||||||
|
)
|
||||||
|
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
logger.info(f"Found element after {elapsed:.2f}s")
|
||||||
|
return result
|
||||||
|
def wait_for_clickable(self, by, value, timeout=2):
|
||||||
|
"""Wartet bis Element klickbar ist"""
|
||||||
|
return WebDriverWait(self.driver, timeout).until(
|
||||||
|
EC.element_to_be_clickable((by, value))
|
||||||
|
)
|
||||||
|
|
||||||
|
def fill_input(self, by, value, text, check_existence=False, timeout=2):
|
||||||
|
if check_existence:
|
||||||
|
# Prüfe ob Element existiert
|
||||||
|
try:
|
||||||
|
element = WebDriverWait(self.driver, timeout).until(
|
||||||
|
EC.presence_of_element_located((by, value))
|
||||||
|
)
|
||||||
|
logger.info(f"Element exists, filling...")
|
||||||
|
except TimeoutException:
|
||||||
|
logger.warning(f"Element does not exist, skipping (check_existence=True)")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
# Normaler Modus - erwarte dass Element existiert
|
||||||
|
element = self.wait_for_element(by, value, timeout)
|
||||||
|
|
||||||
|
# Element existiert - jetzt füllen
|
||||||
|
element.clear()
|
||||||
|
element.send_keys(text)
|
||||||
|
logger.info(f"Filled input with: {text}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def click_button(self, by, value):
|
||||||
|
"""Klickt einen Button"""
|
||||||
|
start_time = time.time()
|
||||||
|
logger.info(f"Clicking button: {by}={value}")
|
||||||
|
|
||||||
|
button = self.wait_for_clickable(by, value)
|
||||||
|
button.click()
|
||||||
|
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
logger.info(f"Clicked after {elapsed:.2f}s")
|
||||||
|
|
||||||
|
def set_checkbox(self, by, value, checked, timeout=2):
|
||||||
|
label = self.wait_for_clickable(by, value, timeout)
|
||||||
|
|
||||||
|
checkbox_input = label.find_element(By.CSS_SELECTOR, "input[type='checkbox']")
|
||||||
|
|
||||||
|
is_checked = checkbox_input.is_selected()
|
||||||
|
|
||||||
|
if is_checked != checked:
|
||||||
|
label.click()
|
||||||
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
|
||||||
|
def select_dropdown_option(self, by, value, option_text, timeout=10):
|
||||||
|
dropdown_button = self.wait_for_element(by, value, timeout=timeout)
|
||||||
|
|
||||||
|
try:
|
||||||
|
current_value = dropdown_button.find_element(
|
||||||
|
By.CSS_SELECTOR,
|
||||||
|
"span.dropdown-trigger-text"
|
||||||
|
).text
|
||||||
|
|
||||||
|
if current_value == option_text:
|
||||||
|
logger.info(f"Dropdown already has value: {option_text}")
|
||||||
|
return
|
||||||
|
except:
|
||||||
|
pass # Falls kein Text gefunden wurde, öffne das Dropdown
|
||||||
|
|
||||||
|
dropdown_button.click()
|
||||||
|
logger.info("Opened dropdown")
|
||||||
|
|
||||||
|
menu = WebDriverWait(self.driver, timeout).until(
|
||||||
|
EC.visibility_of_element_located((By.CSS_SELECTOR, "ul.dropdown-menu"))
|
||||||
|
)
|
||||||
|
logger.info("Dropdown menu visible")
|
||||||
|
|
||||||
|
option_xpath = f"//li[contains(@class, 'dropdown-option')][normalize-space(text())='{option_text}']"
|
||||||
|
option = WebDriverWait(self.driver, timeout).until(
|
||||||
|
EC.element_to_be_clickable((By.XPATH, option_xpath))
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Clicking option: {option_text}")
|
||||||
|
option.click()
|
||||||
|
|
||||||
|
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
def search_and_select_autosuggest(self, by_or_selector, value_or_search_text,
|
||||||
|
search_text=None,
|
||||||
|
suggestion_selector=".suggestion-item",
|
||||||
|
timeout=2):
|
||||||
|
|
||||||
|
if search_text is not None:
|
||||||
|
# Fall: (By.CSS_SELECTOR, ".selector", "search_text")
|
||||||
|
search_input = self.wait_for_element(by_or_selector, value_or_search_text, timeout)
|
||||||
|
text_to_search = search_text
|
||||||
|
else:
|
||||||
|
# Fall: (".selector", "search_text")
|
||||||
|
search_input = self.wait_for_element(By.CSS_SELECTOR, by_or_selector, timeout)
|
||||||
|
text_to_search = value_or_search_text
|
||||||
|
|
||||||
|
search_input.clear()
|
||||||
|
search_input.send_keys(text_to_search)
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
suggestion = WebDriverWait(self.driver, timeout).until(
|
||||||
|
EC.element_to_be_clickable((By.CSS_SELECTOR, suggestion_selector))
|
||||||
|
)
|
||||||
|
suggestion.click()
|
||||||
|
|
||||||
|
time.sleep(0.5)
|
||||||
157
test/pages/calculation_page.py
Normal file
157
test/pages/calculation_page.py
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
# pages/calculation_page.py
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
from pages.base_page import BasePage
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CalculationPage(BasePage):
|
||||||
|
"""Page Object für die Berechnungsformulare"""
|
||||||
|
|
||||||
|
# WICHTIG: Verwende data-v-* Attribute NUR wenn sie WIRKLICH stabil sind
|
||||||
|
# Besser: Positionsbasierte Selektoren mit aussagekräftigen Parent-Elementen
|
||||||
|
|
||||||
|
FIELD_MAPPING = {
|
||||||
|
# Material-Sektion (erste Box)
|
||||||
|
"HS_CODE": (
|
||||||
|
By.XPATH,
|
||||||
|
"//div[contains(@class, 'master-data-item')][1]//div[contains(@class, 'caption-column')][text()='HS code']"
|
||||||
|
"/following-sibling::div//input[@class='input-field']"
|
||||||
|
),
|
||||||
|
"TARIFF_RATE": (
|
||||||
|
By.XPATH,
|
||||||
|
"//div[contains(@class, 'master-data-item')][1]//div[contains(@class, 'caption-column')][contains(text(), 'Tariff rate')]"
|
||||||
|
"/following-sibling::div//input[@class='input-field']"
|
||||||
|
),
|
||||||
|
|
||||||
|
# Price-Sektion (zweite Box)
|
||||||
|
"PRICE": (
|
||||||
|
By.XPATH,
|
||||||
|
"//div[contains(@class, 'master-data-item')][2]//div[contains(@class, 'caption-column')][text()='MEK_A [EUR]']"
|
||||||
|
"/following-sibling::div//input[@class='input-field']"
|
||||||
|
),
|
||||||
|
"OVERSEA_SHARE": (
|
||||||
|
By.XPATH,
|
||||||
|
"//div[contains(@class, 'master-data-item')][2]//div[contains(@class, 'caption-column')][contains(text(), 'Oversea share')]"
|
||||||
|
"/following-sibling::div//input[@class='input-field']"
|
||||||
|
),
|
||||||
|
# Handling Unit-Sektion (dritte Box)
|
||||||
|
"LENGTH": (
|
||||||
|
By.XPATH,
|
||||||
|
"//div[contains(@class, 'master-data-item')][3]//div[contains(@class, 'caption-column')][text()='HU length']"
|
||||||
|
"/following-sibling::div//input[@class='input-field']"
|
||||||
|
),
|
||||||
|
"WIDTH": (
|
||||||
|
By.XPATH,
|
||||||
|
"//div[contains(@class, 'master-data-item')][3]//div[contains(@class, 'caption-column')][text()='HU width']"
|
||||||
|
"/following-sibling::div//input[@class='input-field']"
|
||||||
|
),
|
||||||
|
"HEIGHT": (
|
||||||
|
By.XPATH,
|
||||||
|
"//div[contains(@class, 'master-data-item')][3]//div[contains(@class, 'caption-column')][text()='HU height']"
|
||||||
|
"/following-sibling::div//input[@class='input-field']"
|
||||||
|
),
|
||||||
|
"WEIGHT": (
|
||||||
|
By.XPATH,
|
||||||
|
"//div[contains(@class, 'master-data-item')][3]//div[contains(@class, 'caption-column')][text()='HU weight']"
|
||||||
|
"/following-sibling::div//input[@class='input-field']"
|
||||||
|
),
|
||||||
|
"PIECES_UNIT": (
|
||||||
|
By.XPATH,
|
||||||
|
"//div[contains(@class, 'master-data-item')][3]//div[contains(@class, 'caption-column')][text()='Pieces per HU']"
|
||||||
|
"/following-sibling::div//input[@class='input-field']"
|
||||||
|
),
|
||||||
|
|
||||||
|
# Dropdowns
|
||||||
|
"DIMENSION_UNIT": (
|
||||||
|
By.XPATH,
|
||||||
|
"//div[contains(@class, 'master-data-item')][3]//div[contains(@class, 'caption-column')][text()='Dimension unit']"
|
||||||
|
"/following-sibling::div//button[contains(@class, 'dropdown-trigger')]"
|
||||||
|
),
|
||||||
|
"WEIGHT_UNIT": (
|
||||||
|
By.XPATH,
|
||||||
|
"//div[contains(@class, 'master-data-item')][3]//div[contains(@class, 'caption-column')][text()='Weight unit']"
|
||||||
|
"/following-sibling::div//button[contains(@class, 'dropdown-trigger')]"
|
||||||
|
),
|
||||||
|
|
||||||
|
# Checkboxen
|
||||||
|
"FBA_FEE": (
|
||||||
|
By.XPATH,
|
||||||
|
"//div[contains(@class, 'master-data-item')][2]"
|
||||||
|
"//label[contains(@class, 'checkbox-item')]"
|
||||||
|
"[.//span[contains(@class, 'checkbox-label')][normalize-space(text())='']]"
|
||||||
|
),
|
||||||
|
"MIXED": (
|
||||||
|
By.XPATH,
|
||||||
|
"//label[contains(@class, 'checkbox-item')]"
|
||||||
|
"[.//span[contains(@class, 'checkbox-label')][text()='Mixable']]"
|
||||||
|
),
|
||||||
|
"STACKED": (
|
||||||
|
By.XPATH,
|
||||||
|
"//label[contains(@class, 'checkbox-item')]"
|
||||||
|
"[.//span[contains(@class, 'checkbox-label')][text()='Stackable']]"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
DEST_FIELD_MAPPING = {
|
||||||
|
"NAME": (By.XPATH, "//input[@placeholder='Add new Destination ...']"),
|
||||||
|
"QUANTITY": (By.XPATH,
|
||||||
|
"//div[contains(@class, 'destination-edit-column-caption') and contains(text(), 'Annual quantity')]/following-sibling::div[1]//input[@class='input-field']")
|
||||||
|
"ROUTING": (By.XPATH,
|
||||||
|
"//input[@type='radio' and @name='model' and @value='routing']"),
|
||||||
|
"D2D": (By.XPATH,
|
||||||
|
"//input[@type='radio' and @name='model' and @value='d2d']"),
|
||||||
|
"ROUTE": (By.XPATH,
|
||||||
|
"//div[@class='destination-route-container']//div[contains(@class, 'destination-route-inner-container')][.//span[contains(text(), 'Ireland Su')] and .//span[contains(text(), 'WH ULHA')] and .//span[contains(text(), 'AB')]]")
|
||||||
|
"HANDLING_TAB": (By.XPATH, "//button[@class='tab-header' and text()='Handling & Repackaging']"),
|
||||||
|
"CUSTOM_HANDLING": (By.XPATH,
|
||||||
|
"//div[@class='destination-edit-handling-cost']//label[@class='checkbox-item']/input[@type='checkbox']"),
|
||||||
|
"REPACKING": (By.XPATH,
|
||||||
|
"//div[@class='destination-edit-column-caption' and contains(text(), 'Repackaging cost')]/following-sibling::div[@class='destination-edit-column-data'][1]//input[@class='input-field']"),
|
||||||
|
"HANDLING": (By.XPATH,
|
||||||
|
"//div[@class='destination-edit-column-caption' and contains(text(), 'Handling cost')]/following-sibling::div[@class='destination-edit-column-data'][1]//input[@class='input-field']"),
|
||||||
|
"DISPOSAL": (By.XPATH,
|
||||||
|
"//div[@class='destination-edit-column-caption' and contains(text(), 'Disposal cost')]/following-sibling::div[@class='destination-edit-column-data'][1]//input[@class='input-field']"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Buttons
|
||||||
|
CALCULATE_CLOSE_BUTTON = (By.XPATH, "//button[contains(., 'Calculate & close')]")
|
||||||
|
CLOSE_BUTTON = (By.XPATH, "//button[contains(., 'Close') and not(contains(., 'Calculate'))]")
|
||||||
|
|
||||||
|
def fill_form(self, data_dict):
|
||||||
|
"""Füllt das Formular mit Daten aus dem Excel"""
|
||||||
|
for field_name, locator in self.FIELD_MAPPING.items():
|
||||||
|
|
||||||
|
value = data_dict[field_name]
|
||||||
|
logger.info(f"Filling field: {field_name} = {value}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if field_name in ["FBA_FEE", "STACKED", "MIXED"]:
|
||||||
|
self.set_checkbox(*locator, str(value) == 'True')
|
||||||
|
|
||||||
|
elif field_name in ["DIMENSION_UNIT", "WEIGHT_UNIT"]:
|
||||||
|
self.select_dropdown_option(*locator, str(value))
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.fill_input(*locator, str(value), check_existence=field_name in ["HS_CODE", "TARIFF_RATE"])
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to fill field {field_name}: {e}")
|
||||||
|
self.driver.save_screenshot(f"failed_field_{field_name}.png")
|
||||||
|
raise Exception(f"Could not fill field '{field_name}': {e}") from e
|
||||||
|
|
||||||
|
def add_destination(self, data_dict):
|
||||||
|
self.search_and_select_autosuggest(*self.DEST_FIELD_MAPPING["NAME"], data_dict["NAME"])
|
||||||
|
|
||||||
|
def fill_destination(self, data_dict):
|
||||||
|
self.wait_for_element(*self.DEST_FIELD_MAPPING["QUANTITY"])
|
||||||
|
pass
|
||||||
|
|
||||||
|
def click_calculate_and_close(self):
|
||||||
|
"""Klickt auf 'Calculate & close' Button"""
|
||||||
|
self.click_button(*self.CALCULATE_CLOSE_BUTTON)
|
||||||
|
|
||||||
|
def click_close(self):
|
||||||
|
"""Klickt auf 'Close' Button"""
|
||||||
|
self.click_button(*self.CLOSE_BUTTON)
|
||||||
53
test/pages/dev_page.py
Normal file
53
test/pages/dev_page.py
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
# pages/results_page.py
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
from selenium.webdriver.support import expected_conditions as EC
|
||||||
|
import time
|
||||||
|
|
||||||
|
class DevPage:
|
||||||
|
MODAL_YES_BUTTON = (By.CSS_SELECTOR, "div.modal-dialog-actions button.btn--primary")
|
||||||
|
MODAL_NO_BUTTON = (By.CSS_SELECTOR, "div.modal-dialog-actions button.btn--secondary")
|
||||||
|
|
||||||
|
def __init__(self, driver, wait):
|
||||||
|
self.driver = driver
|
||||||
|
self.wait = wait
|
||||||
|
|
||||||
|
def dev_login(self, base_url, user_name="John"):
|
||||||
|
"""
|
||||||
|
Simuliert Login über /dev Seite
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: Basis-URL der Anwendung
|
||||||
|
user_name: Vorname des Users (z.B. "John", "Sarah", "Mike")
|
||||||
|
"""
|
||||||
|
# Navigiere zur Dev-Seite
|
||||||
|
self.driver.get(f"{base_url}/dev")
|
||||||
|
|
||||||
|
# Warte bis Tabelle geladen ist
|
||||||
|
self.wait.until(
|
||||||
|
EC.presence_of_element_located((By.CSS_SELECTOR, "table.data-table"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Finde die User-Row anhand des Vornamens
|
||||||
|
# Die Tabelle hat Spalten: First name | Last name | E-Mail | ...
|
||||||
|
rows = self.driver.find_elements(By.CSS_SELECTOR, "table.data-table tbody tr.table-row")
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
cells = row.find_elements(By.TAG_NAME, "td")
|
||||||
|
if cells and user_name in cells[0].text: # cells[0] ist "First name"
|
||||||
|
# Klicke auf die Row
|
||||||
|
self.wait.until(EC.element_to_be_clickable(row))
|
||||||
|
row.click()
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise Exception(f"User '{user_name}' nicht in der Dev-User-Tabelle gefunden")
|
||||||
|
|
||||||
|
# Warte auf Modal und klicke "Yes"
|
||||||
|
yes_button = self.wait.until(
|
||||||
|
EC.element_to_be_clickable(self.MODAL_YES_BUTTON)
|
||||||
|
)
|
||||||
|
yes_button.click()
|
||||||
|
|
||||||
|
# Warte bis Modal geschlossen ist
|
||||||
|
self.wait.until(
|
||||||
|
EC.invisibility_of_element_located((By.CSS_SELECTOR, "div.modal-container"))
|
||||||
|
)
|
||||||
29
test/pages/navigation.py
Normal file
29
test/pages/navigation.py
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
# pages/navigation.py
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
|
||||||
|
from pages.assistant import Assistant
|
||||||
|
from pages.base_page import BasePage
|
||||||
|
from pages.calculation_page import CalculationPage
|
||||||
|
from pages.results_page import ResultsPage
|
||||||
|
|
||||||
|
|
||||||
|
class Navigation(BasePage):
|
||||||
|
"""Handhabt die SPA-Navigation"""
|
||||||
|
|
||||||
|
# Locators für Navigationselemente
|
||||||
|
MENU_BUTTON = (By.CSS_SELECTOR, "button.menu-toggle")
|
||||||
|
NEW_CALCULATION_LINK = (By.CSS_SELECTOR, "a[href*='/assistant']")
|
||||||
|
RESULTS_LINK = (By.CSS_SELECTOR, "a[href*='/results']")
|
||||||
|
|
||||||
|
def start_calculation(self, base_url):
|
||||||
|
"""Navigiert zur Berechnungsseite"""
|
||||||
|
self.driver.get(base_url+"/assistant")
|
||||||
|
self.wait_for_spa_navigation("/assistant")
|
||||||
|
return Assistant(self.driver, self.wait)
|
||||||
|
|
||||||
|
|
||||||
|
def navigate_to_results(self):
|
||||||
|
"""Navigiert zur Ergebnisseite"""
|
||||||
|
self.click_button(*self.RESULTS_LINK)
|
||||||
|
self.wait_for_spa_navigation("/results")
|
||||||
|
return ResultsPage(self.driver, self.wait)
|
||||||
35
test/pages/results_page.py
Normal file
35
test/pages/results_page.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
# pages/results_page.py
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
from pages.base_page import BasePage
|
||||||
|
|
||||||
|
|
||||||
|
class ResultsPage(BasePage):
|
||||||
|
"""Page Object für die Ergebnisseite"""
|
||||||
|
|
||||||
|
# Mapping von Excel-Feldnamen zu Locators
|
||||||
|
RESULT_MAPPING = {
|
||||||
|
"gesamtkosten": (By.ID, "result-total-cost"),
|
||||||
|
"zollgebuehr": (By.ID, "result-customs-fee"),
|
||||||
|
"transportkosten": (By.ID, "result-transport-cost"),
|
||||||
|
"mehrwertsteuer": (By.ID, "result-vat"),
|
||||||
|
# Weitere Ergebnisfelder...
|
||||||
|
}
|
||||||
|
|
||||||
|
def wait_for_results(self):
|
||||||
|
"""Wartet bis Ergebnisse geladen sind"""
|
||||||
|
self.wait_for_element(By.CSS_SELECTOR, ".results-container.loaded")
|
||||||
|
|
||||||
|
def read_results(self):
|
||||||
|
"""Liest alle Ergebniswerte aus"""
|
||||||
|
self.wait_for_results()
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
for field_name, locator in self.RESULT_MAPPING.items():
|
||||||
|
try:
|
||||||
|
element = self.wait_for_element(*locator)
|
||||||
|
results[field_name] = element.text
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Fehler beim Lesen von '{field_name}': {e}")
|
||||||
|
results[field_name] = None
|
||||||
|
|
||||||
|
return results
|
||||||
18
test/pytest.ini
Normal file
18
test/pytest.ini
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
[pytest]
|
||||||
|
log_cli = true
|
||||||
|
log_cli_level = INFO
|
||||||
|
log_cli_format = %(asctime)s [%(levelname)s] %(message)s
|
||||||
|
log_cli_date_format = %H:%M:%S
|
||||||
|
testpaths = .
|
||||||
|
python_files = test_*.py
|
||||||
|
python_classes = Test*
|
||||||
|
python_functions = test_*
|
||||||
|
addopts =
|
||||||
|
-v
|
||||||
|
--tb=short
|
||||||
|
--capture=no
|
||||||
|
--html=reports/report.html
|
||||||
|
--self-contained-html
|
||||||
|
markers =
|
||||||
|
smoke: Smoke tests
|
||||||
|
regression: Regression tests
|
||||||
5
test/requirements.txt
Normal file
5
test/requirements.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
pytest==9.0.2
|
||||||
|
selenium==4.39.0
|
||||||
|
openpyxl==3.1.5
|
||||||
|
pytest-html==4.1.1
|
||||||
|
webdriver-manager==4.0.2
|
||||||
70
test/test_calculation.py
Normal file
70
test/test_calculation.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
# tests/test_calculation.py
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pages.dev_page import DevPage
|
||||||
|
from pages.navigation import Navigation
|
||||||
|
from pages.calculation_page import CalculationPage
|
||||||
|
from pages.results_page import ResultsPage
|
||||||
|
|
||||||
|
|
||||||
|
class TestCalculation:
|
||||||
|
"""Testklasse für Berechnungslogik"""
|
||||||
|
|
||||||
|
def test_calculation_workflow(self, driver, wait, base_url, test_data):
|
||||||
|
"""
|
||||||
|
Haupttest: Führt einen kompletten Berechnungsdurchlauf durch
|
||||||
|
Wird automatisch für jede Excel-Datei parametrisiert
|
||||||
|
"""
|
||||||
|
|
||||||
|
login_page = DevPage(driver, wait)
|
||||||
|
login_page.dev_login(base_url, user_name="John")
|
||||||
|
|
||||||
|
|
||||||
|
# Setup assistant
|
||||||
|
nav = Navigation(driver, wait)
|
||||||
|
assistant = nav.start_calculation(base_url)
|
||||||
|
assistant.search_part_numbers(test_data["input"])
|
||||||
|
assistant.delete_preselected_suppliers()
|
||||||
|
assistant.select_supplier(test_data["input"])
|
||||||
|
assistant.create_calculation(test_data["input"])
|
||||||
|
|
||||||
|
# Fill calculation page
|
||||||
|
calc_page = CalculationPage(driver, wait)
|
||||||
|
calc_page.fill_form(test_data["input"])
|
||||||
|
|
||||||
|
for destination in test_data["input"]["DESTINATION"].values():
|
||||||
|
calc_page.add_destination(destination)
|
||||||
|
calc_page.fill_destination(destination)
|
||||||
|
|
||||||
|
# Ergebnisse lesen
|
||||||
|
results_page = ResultsPage(driver, wait)
|
||||||
|
actual_results = results_page.read_results()
|
||||||
|
|
||||||
|
# Assertions: Vergleich mit erwarteten Werten aus "out" Sheet
|
||||||
|
expected = test_data["expected"]
|
||||||
|
|
||||||
|
for field_name, expected_value in expected.items():
|
||||||
|
actual_value = actual_results.get(field_name)
|
||||||
|
|
||||||
|
# Numerische Werte mit Toleranz vergleichen
|
||||||
|
if self._is_numeric(expected_value):
|
||||||
|
expected_num = float(expected_value)
|
||||||
|
actual_num = float(actual_value.replace("€", "").replace(",", ".").strip())
|
||||||
|
|
||||||
|
assert pytest.approx(expected_num, rel=0.01) == actual_num, \
|
||||||
|
f"Feld '{field_name}': Erwartet {expected_num}, erhalten {actual_num}"
|
||||||
|
else:
|
||||||
|
# String-Vergleich
|
||||||
|
assert str(actual_value).strip() == str(expected_value).strip(), \
|
||||||
|
f"Feld '{field_name}': Erwartet '{expected_value}', erhalten '{actual_value}'"
|
||||||
|
|
||||||
|
print(f"✓ Test erfolgreich für {test_data['filename']}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_numeric(value):
|
||||||
|
"""Prüft ob Wert numerisch ist"""
|
||||||
|
try:
|
||||||
|
float(str(value).replace(",", "."))
|
||||||
|
return True
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
return False
|
||||||
Loading…
Add table
Reference in a new issue