Compare commits

...
Sign in to create a new pull request.

37 commits

Author SHA1 Message Date
85f660665a Merge pull request 'dev' (#108) from dev into main
Reviewed-on: #108
2026-01-22 16:52:42 +00:00
Jan
605bcfe0fc Improve Excel mapper geocoding: Add fuzzy search fallback for failed addresses, introduce better cell value handling, and enhance error logging. 2026-01-22 17:25:40 +01:00
03cd1274e9 src/main/java/de/avatic/lcc/service/api/BatchGeoApiService.java aktualisiert
Fixed nullpointer exception in batch geo coding
2026-01-22 09:35:12 +00:00
8e01ef055a Merge pull request 'Implemented deactivated container types, CalculationExecutionService to handle container type activation logic. Fix destinationEdit store to handle null values in data.' (#107) from dev into main
Reviewed-on: #107
2026-01-20 08:57:30 +00:00
Jan
462a960c68 Implemented deactivated container types, CalculationExecutionService to handle container type activation logic. Fix destinationEdit store to handle null values in data. 2026-01-20 09:53:58 +01:00
8be5f34137 Merge pull request 'dev' (#106) from dev into main
Reviewed-on: #106
2026-01-18 21:39:04 +00:00
Jan
b66ac66b54 Bugfixing: Clean up existing drafts in PremisesService and include annual repacking cost in CalculationExecutionService. 2026-01-18 21:24:24 +01:00
Jan
11d32a665e Rename pages-change event to page-change in Pagination component and update related references. 2026-01-18 18:37:07 +01:00
Jan
b5f2df8be7 Rename helppages-change event to pages-change in Pagination component. 2026-01-18 18:15:45 +01:00
Jan
c1e136f914 Merge remote-tracking branch 'origin/dev' into dev 2026-01-18 18:11:32 +01:00
Jan
bdfaef3365 Fixed isWeightExceeded value in database. Fixed shipping freqency rounding in database 2026-01-18 18:11:22 +01:00
ac23dc4728 Merge pull request 'main' (#105) from main into dev
Reviewed-on: #105
2026-01-11 22:27:40 +00:00
bde397e509 Merge pull request 'dev' (#104) from dev into main
Reviewed-on: #104
2026-01-11 22:26:47 +00:00
Jan
8742d24b62 Add help system with markdown-based content, video support, and help menu integration; update related UI components, backend services, and frontend store to enable contextual help functionality. 2026-01-10 19:19:38 +01:00
Jan
22051135ad Fix supplier item layout and styling, 2026-01-06 20:39:07 +01:00
Jan
23bc00d33c Improve logging in DistanceApiService and DistanceMatrixRepository: Add fallback to names when external mapping IDs are null, fix inconsistent spacing, and enhance error messages with better node ID resolution. 2026-01-06 18:17:28 +01:00
Jan
8ef279e735 Enhance error handling in massSetDestinations: Add notifications for failures, logging support, and update modal behavior to prevent data clearing on errors. 2026-01-06 17:40:07 +01:00
Jan
81233db437 Fixing wrong modal title if edited over toolbar 2026-01-06 17:20:46 +01:00
Jan
8d85e4c692 Refactor ShippingFrequencyCalculationService: Adjust container calculation logic to ensure minAnnualFrequency is respected when fillContainer is true. 2026-01-06 16:37:46 +01:00
Jan
9b13261a20 Fixed "Oversea share" -> "Overseas share". Dont show partnumber/Supplier in error message when in single edit mode 2026-01-06 12:34:43 +01:00
Jan
eb5aecb1b5 Bugfix shippingfrequency 2026-01-05 14:10:56 +01:00
Jan
d606e3e33a Add modal for node details with map view and country flag rendering, enhance table interactions with row-click handling 2026-01-04 19:30:34 +01:00
Jan
1ceca3f2f1 Adjust FCA fees calculation to average per destination for consistency with other cost computations 2026-01-04 19:20:34 +01:00
Jan
3a203d1c7e Refactor handling cost calculation logic: simplify null checks using Objects.requireNonNullElse, adjust handling and disposal calculations, and improve clarity in annual cost computations. 2026-01-04 18:59:53 +01:00
Jan
f8d2745d32 Merge branch 'dev' of git.avatic.de:avatic/lcc_tool into dev 2026-01-04 17:40:52 +01:00
Jan
b473e34809 Add page objects and test framework scaffolding for Selenium-based test automation. Includes initial test suites, element locators, and configuration setup. 2026-01-04 17:40:48 +01:00
Jan
63e1574d2f Enhance logging in DistanceApiService to include cached distance details; refactor and improve handling/multiplier logic in cost calculation services. 2026-01-04 17:39:43 +01:00
Renovate-Bot
d079971ec9 Merge pull request 'Update dependency org.springframework.boot:spring-boot-starter-parent to v3.5.9' (#99) from renovate/spring-boot into dev 2025-12-22 00:01:24 +00:00
Renovate-Bot
6c237a83ce Merge pull request 'Update dependency maven to v3.9.12' (#98) from renovate/maven-3.x into dev 2025-12-22 00:01:23 +00:00
Renovate Bot
d3e14fa8f0 Update dependency org.springframework.boot:spring-boot-starter-parent to v3.5.9 2025-12-22 00:01:22 +00:00
Renovate Bot
4cfee73704 Update dependency maven to v3.9.12 2025-12-22 00:01:20 +00:00
Jan
6add528c02 Add import/export functionality for apps, including client-side file handling and backend encryption/decryption logic 2025-12-17 16:06:59 +01:00
Jan
1788a7ef1c Refactor: Pass ContainerCalculationResult into cost calculation services and update ShippingFrequencyCalculationService logic to consider HU per container handling. 2025-12-17 14:29:08 +01:00
Jan
9ac3cb7815 Bugfix: if hu amount is less than min shipping frequency, fix total utilization accordingly 2025-12-17 09:42:19 +01:00
Jan
1be35b5a8d Bugfix: shipping frequency custom calculation. Stacking in container calcualtion 2025-12-16 22:16:03 +01:00
a83c49bc70 Merge pull request 'Reworked excel reporting' (#97) from feature/reporting into dev
Reviewed-on: #97
2025-12-16 19:26:31 +00:00
8f0986c7d8 Merge pull request 'dev: Refactoring Massedit, Bugfixing, Dependency updates.' (#67) from dev into main
Reviewed-on: #67
2025-12-08 13:20:40 +00:00
74 changed files with 3687 additions and 435 deletions

View file

@ -16,4 +16,4 @@
# under the License.
wrapperVersion=3.3.4
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

View file

@ -5,7 +5,7 @@
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.8</version>
<version>3.5.9</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>de.avatic</groupId>
@ -130,6 +130,11 @@
<artifactId>fastexcel</artifactId>
<version>0.19.0</version>
</dependency>
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark</artifactId>
<version>0.22.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>

View file

@ -1,5 +1,6 @@
<template>
<the-notification-system />
<the-help-system />
<the-header></the-header>
<router-view v-slot="slotProps">
<transition name="route" mode="out-in">
@ -13,9 +14,10 @@
import TheHeader from "@/components/layout/TheHeader.vue";
import TheNotificationSystem from "@/components/UI/TheNotificationSystem.vue";
import TheHelpSystem from "@/components/layout/help/TheHelpSystem.vue";
export default {
components: {TheNotificationSystem, TheHeader},
components: {TheHelpSystem, TheNotificationSystem, TheHeader},
}
</script>

View file

@ -1,10 +1,20 @@
<template>
<div>
<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>
@ -18,7 +28,7 @@ import BasicBadge from "@/components/UI/BasicBadge.vue";
export default {
name: "AppListItem",
components: {BasicBadge, IconButton, Box},
emits: ["deleteApp"],
emits: ["deleteApp", "exportApp"],
props: {
app: {
type: Object,
@ -33,6 +43,9 @@ export default {
methods: {
deleteClick() {
this.$emit("deleteApp", this.app.id);
},
exportClick() {
this.$emit("exportApp", this.app.id);
}
}
}
@ -76,7 +89,10 @@ export default {
color: #6b7280;
}
.action-container{
.action-container {
display: flex;
flex-direction: row;
gap: 1.2rem;
align-self: center;
}

View file

@ -128,7 +128,7 @@ export default {
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];
if (scrollKeys.includes(e.keyCode)) {
e.preventDefault();

View file

@ -10,7 +10,7 @@
<PhCaretLeft :size="18" /> Previous
</button>
<!-- First page -->
<!-- First pages -->
<button
v-if="showFirstPage"
class="pagination-btn page-number"
@ -23,7 +23,7 @@
<!-- First ellipsis -->
<span v-if="showFirstEllipsis" class="ellipsis">...</span>
<!-- Page numbers around current page -->
<!-- Page numbers around current pages -->
<button
v-for="pageNum in visiblePages"
:key="pageNum"
@ -37,7 +37,7 @@
<!-- Last ellipsis -->
<span v-if="showLastEllipsis" class="ellipsis">...</span>
<!-- Last page -->
<!-- Last pages -->
<button
v-if="showLastPage"
class="pagination-btn page-number"

View file

@ -1,10 +1,16 @@
<template>
<div class="item-container" :class="{'selected-item': selected}">
<flag :iso="isoCode" size="l"></flag>
<div class="supplier-item-text">
<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 class="supplier-content">
<flag :iso="isoCode" size="l"></flag>
<div class="supplier-item-text">
<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>
<icon-button v-if="showTrash" icon="trash" @click="deleteClick"></icon-button>
</div>
@ -65,14 +71,13 @@ export default {
.item-container {
display: flex;
justify-content: space-between;
justify-content: flex-start; /* Statt space-between */
align-items: center;
padding: 3.6rem 3.6rem;
background: white;
border-radius: 0.8rem;
box-shadow: 0 0.4rem 0.6rem -0.1rem rgba(0, 0, 0, 0.1);
overflow: hidden;
gap: 2.4rem;
flex: 0 0 50rem;
transition: background-color 0.3s ease;
}
@ -97,6 +102,20 @@ export default {
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 {
font-size: 1.6rem;
@ -112,4 +131,8 @@ export default {
color: #6b7280;
}
.item-container > .icon-button {
margin-left: auto;
}
</style>

View file

@ -1,6 +1,8 @@
<template>
<div class="apps-container">
<div class="app-list-actions">
</div>
<div class="app-list-header">
<div>App</div>
<div>Groups</div>
@ -8,13 +10,20 @@
</div>
<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>
<modal :state="modalState">
<add-app @close="closeModal"></add-app>
</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>
@ -27,6 +36,8 @@ import {mapStores} from "pinia";
import {useAppsStore} from "@/store/apps.js";
import Modal from "@/components/UI/Modal.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 {
name: "Apps",
@ -36,7 +47,7 @@ export default {
default: false
}
},
components: {AddApp, Modal, AppListItem, BasicButton},
components: {IconButton, Dropdown, AddApp, Modal, AppListItem, BasicButton},
computed: {
...mapStores(useAppsStore),
apps() {
@ -45,7 +56,8 @@ export default {
},
data() {
return {
modalState: false
modalState: false,
exportedApp: null
}
},
methods: {
@ -57,6 +69,62 @@ export default {
},
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() {
@ -85,6 +153,13 @@ export default {
border-bottom: 0.1rem solid #E3EDFF;
}
.app-list-actions {
display: flex;
justify-content: flex-end;
margin-top: 2rem;
gap: 1.6rem
}
.app-list {
margin-bottom: 2.4rem;
}

View file

@ -1,8 +1,26 @@
<template>
<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"
: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>
@ -12,6 +30,12 @@
import TableView from "@/components/UI/TableView.vue";
import {mapStores} from "pinia";
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 {
name: "Nodes",
@ -28,7 +52,7 @@ export default {
}
}
},
components: {TableView},
components: {Flag, OpenStreetMapEmbed, IconButton, TabContainer, ErrorModal, Modal, TableView},
computed: {
...mapStores(useNodeStore),
},
@ -40,10 +64,16 @@ export default {
await this.nodeStore.setQuery(query);
this.pagination = this.nodeStore.pagination;
return this.nodeStore.nodes;
},
showDetails(node) {
this.node = node;
this.showModal = true;
}
},
data() {
return {
showModal: false,
node: null,
nodeColumns: [
{
key: 'external_mapping_id',
@ -95,4 +125,39 @@ export default {
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>

View file

@ -13,7 +13,7 @@
</div>
<div class="field-group">
<div class="caption-column">Oversea share [%]</div>
<div class="caption-column">Overseas share [%]</div>
<div class="input-column">
<div class="text-container">
<input ref="overseaShareInput" @keydown.enter="handleEnter('overseaShareInput', $event)" :value="overSeaSharePercent" @blur="validateOverSeaShare" class="input-field"

View 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>

View 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>

View 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>

View 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>

View 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>

View file

@ -34,7 +34,7 @@ import {
PhUpload,
PhWarning,
PhX,
PhExclamationMark, PhMapPin, PhEmpty, PhShippingContainer, PhPackage, PhVectorThree, PhTag
PhExclamationMark, PhMapPin, PhEmpty, PhShippingContainer, PhPackage, PhVectorThree, PhTag, PhInfo
} from "@phosphor-icons/vue";
import {setupSessionRefresh} from "@/store/activeuser.js";
@ -81,6 +81,7 @@ app.component("PhMapPin", PhMapPin);
app.component("PhPackage", PhPackage);
app.component("PhVectorThree", PhVectorThree);
app.component("PhTag", PhTag);
app.component("PhInfo", PhInfo);
app.use(router);

View file

@ -1,6 +1,15 @@
<template>
<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">
@ -23,8 +32,15 @@
<textarea v-model="partNumberField" name="partNumbers" cols="140" rows="15"></textarea>
</div>
<div class="part-number-modal-action">
<basic-button @click="parsePartNumbers" icon="CloudArrowUp">Analyze input</basic-button>
<basic-button @click="closeModal('partNumber')" :show-icon="false" variant="secondary">Cancel</basic-button>
<div class="part-number-modal-action-help">
<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>
</modal>
@ -88,11 +104,22 @@ import CreateNewNode from "@/components/layout/node/CreateNewNode.vue";
import Checkbox from "@/components/UI/Checkbox.vue";
import {UrlSafeBase64} from "@/common.js";
import {useNotificationStore} from "@/store/notification.js";
import IconButton from "@/components/UI/IconButton.vue";
import {useHelpStore} from "@/store/help.js";
export default {
name: "CalculationAssistant",
components: {Checkbox, CreateNewNode, Modal, SupplierItem, MaterialItem, BasicButton, AutosuggestSearchbar},
components: {
IconButton,
Checkbox,
CreateNewNode,
Modal,
SupplierItem,
MaterialItem,
BasicButton,
AutosuggestSearchbar
},
computed: {
...mapStores(useNodeStore, useAssistantStore, useNotificationStore),
showPartNumberModal() {
@ -108,6 +135,7 @@ export default {
}
},
methods: {
useHelpStore,
setUseExisting(useExisting) {
this.assistantStore.setCreateEmpty(!useExisting);
},
@ -160,7 +188,7 @@ export default {
parsePartNumbers() {
this.closeModal('partNumber');
if(this.partNumberField.trim().length !== 0)
if (this.partNumberField.trim().length !== 0)
this.assistantStore.getMaterialsAndSuppliers(this.partNumberField);
this.partNumberField = '';
@ -179,6 +207,22 @@ export default {
<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 {
display: flex;
justify-content: space-between;
@ -232,10 +276,17 @@ textarea {
gap: 1.6rem;
}
.part-number-modal-action-help {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.8rem;
}
.part-number-modal-action {
display: flex;
align-items: center;
justify-content: flex-end;
justify-content: space-between;
gap: 1.6rem
}
@ -246,6 +297,13 @@ textarea {
margin-bottom: 1.6rem;
}
.part-number-modal-action-buttons {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 1.6rem
}
.item-list {
display: flex;
list-style: none;

View file

@ -2,7 +2,19 @@
<div class="edit-calculation-container"
:class="{ 'has-selection': hasSelection, 'apply-filter': applyFilter, 'add-all': addAll }">
<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">
<basic-button :show-icon="true"
:disabled="disableButtons"
@ -173,6 +185,8 @@ import DestMassCreate from "@/components/layout/edit/destination/mass/DestMassCr
import ModalDialog from "@/components/UI/ModalDialog.vue";
import destinationEdit from "@/components/layout/edit/destination/DestinationEdit.vue";
import logger from "@/logger.js";
import IconButton from "@/components/UI/IconButton.vue";
import {useHelpStore} from "@/store/help.js";
const COMPONENT_TYPES = {
@ -187,6 +201,7 @@ const COMPONENT_TYPES = {
export default {
name: "MassEdit",
components: {
IconButton,
ModalDialog,
SortButton,
Modal,
@ -267,7 +282,7 @@ export default {
return this.premiseEditStore.showProcessingModal || this.destinationEditStore.showProcessingModal;
},
shownProcessingMessage() {
if(this.premiseEditStore.showProcessingModal)
if (this.premiseEditStore.showProcessingModal)
return "Please wait. Prepare calculation ..."
return this.processingMessage;
@ -298,6 +313,7 @@ export default {
window.removeEventListener('keyup', this.handleKeyUp);
},
methods: {
useHelpStore,
handleKeyDown(event) {
if (event.key === 'Control') {
this.isCtrlPressed = true;
@ -444,6 +460,7 @@ export default {
async closeEditModalAction(action) {
let massUpdate = false;
let success = true;
if (this.modalType === 'amount' || this.modalType === 'routes' || this.modalType === "destinations") {
@ -453,18 +470,21 @@ export default {
const setMatrix = this.$refs.modalComponent?.destMatrix;
if (setMatrix) {
await this.destinationEditStore.massSetDestinations(setMatrix);
success = await this.destinationEditStore.massSetDestinations(setMatrix);
}
} else {
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);
}
@ -490,6 +510,8 @@ export default {
},
fillData(type, id = -1, hideDescription = false) {
this.modalTitle = "Edit ".concat(type);
if (id === -1) {
if (type === 'price')
@ -526,8 +548,6 @@ export default {
} else {
const premise = this.premiseEditStore.getById(id);
this.modalTitle = "Edit ".concat(type);
if (type === "price") {
this.modalProps = {
price: premise.material_cost,
@ -738,4 +758,20 @@ export default {
display: flex;
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>

View file

@ -1,7 +1,22 @@
<template>
<div class="edit-calculation-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">
<basic-button @click="close" :show-icon="false" :disabled="premiseSingleEditStore.showLoadingSpinner"
variant="secondary"> {{ fromMassEdit ? 'Back' : 'Close' }}
@ -107,6 +122,7 @@ import {UrlSafeBase64} from "@/common.js";
import {usePremiseSingleEditStore} from "@/store/premiseSingleEdit.js";
import {useNotificationStore} from "@/store/notification.js";
import Spinner from "@/components/UI/Spinner.vue";
import {useHelpStore} from "@/store/help.js";
export default {
name: "SingleEdit",
@ -161,6 +177,7 @@ export default {
}
},
methods: {
useHelpStore,
async startCalculation() {
this.showCalculationModal = true;
@ -209,6 +226,23 @@ export default {
</script>
<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 {
display: flex;
flex-direction: column;

View file

@ -1,15 +1,23 @@
<template>
<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>
<h3 class="sub-header">Status</h3>
<the-dashboard></the-dashboard>
<the-dashboard></the-dashboard>
<h3 class="sub-header">Drafts</h3>
<div class="calculation-list-container">
@ -79,6 +87,7 @@ import modal from "@/components/UI/Modal.vue";
import {useActiveUserStore} from "@/store/activeuser.js";
import TheDashboard from "@/components/layout/calculation/TheDashboard.vue";
import Box from "@/components/UI/Box.vue";
import {useHelpStore} from "@/store/help.js";
export default {
name: "Calculation",
@ -186,6 +195,7 @@ export default {
await this.executeSearch();
},
methods: {
useHelpStore,
async handleModalAction(action) {
if (action === 'dismiss') {
this.modal.state = false;
@ -361,6 +371,21 @@ export default {
<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 {
font-weight: normal;
margin-bottom: 0;

View file

@ -1,12 +1,26 @@
<template>
<div>
<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">
<basic-badge variant="primary" v-if="period">{{ period }}</basic-badge>
<basic-badge variant="secondary" v-if="partNumber">{{ partNumber }}</basic-badge>
</div>
</h2>
</div>
<div class="header-controls">
<basic-button @click="createReport" icon="file">Create report</basic-button>
<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 BasicBadge from "@/components/UI/BasicBadge.vue";
import {buildDate} from "@/common.js";
import IconButton from "@/components/UI/IconButton.vue";
import {useHelpStore} from "@/store/help.js";
export default {
name: "Reporting",
components: {BasicBadge, Report, ReportChart, Spinner, Box, SelectForReport, BasicButton, Modal},
components: {IconButton, BasicBadge, Report, ReportChart, Spinner, Box, SelectForReport, BasicButton, Modal},
data() {
return {
showModal: false,
@ -79,13 +95,13 @@ export default {
for (let i = 0; i < scale.length; i++) {
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);
}
}
}
return scale.map(s => (s+1)*4);
return scale.map(s => (s + 1) * 4);
},
reports() {
return this.reportsStore.reports
@ -113,6 +129,7 @@ export default {
}
},
methods: {
useHelpStore,
downloadReport() {
this.reportsStore.downloadReport();
},
@ -140,6 +157,22 @@ export default {
<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 {
display: flex;
align-items: center;
@ -150,6 +183,7 @@ export default {
display: flex;
align-items: center;
gap: 0.8rem;
margin-bottom: 3rem;
}
.space-around {

View file

@ -24,6 +24,18 @@ export const useAppsStore = defineStore('apps', {
this.apps = resp.data;
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) {
const url = `${config.backendUrl}/apps`;

View file

@ -1,6 +1,8 @@
import {defineStore} from 'pinia'
import performRequest from "@/backend.js";
import {config} from '@/config'
import logger from "@/logger.js";
import {useNotificationStore} from "@/store/notification.js";
export const useDestinationEditStore = defineStore('destinationEdit', {
state: () => ({
@ -83,30 +85,45 @@ export const useDestinationEditStore = defineStore('destinationEdit', {
async massSetDestinations(updateMatrix) {
this.loading = true;
const toBeAdded = {};
const toBeDeletedMap = new Map();
try {
updateMatrix.forEach(row => {
toBeAdded[row.id] = row.destinations.filter(d => d.selected).map(d => d.id);
toBeDeletedMap.set(row.id, row.destinations.filter(d => !d.selected).map(d => d.id));
});
const toBeAdded = {};
const toBeDeletedMap = new Map();
const url = `${config.backendUrl}/calculation/destination`;
const {
data: data,
headers: headers
} = await performRequest(this, 'POST', url, {'destination_node_ids': toBeAdded});
updateMatrix.forEach(row => {
toBeAdded[row.id] = row.destinations.filter(d => d.selected).map(d => d.id);
toBeDeletedMap.set(row.id, row.destinations.filter(d => !d.selected).map(d => d.id));
});
this.destinations.forEach((destinations, premiseId) => {
const toBeDeleted = toBeDeletedMap.get(premiseId);
const url = `${config.backendUrl}/calculation/destination`;
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]);
});
this.loading = false;
} 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;
}
return true;
},
async massUpdateDestinations(premiseIds) {
this.loading = true;

View 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;
}
}
});

View file

@ -20,7 +20,6 @@ export default defineConfig({
src: 'assets/map.json',
dest: 'assets/'
}
]
})
],
@ -35,4 +34,18 @@ export default defineConfig({
'@': 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
}
}
}
})

View file

@ -18,10 +18,5 @@ public class ShutdownListener {
Runtime runtime = Runtime.getRuntime();
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.error("Thread stack dump:");
Thread.dumpStack();
}
}

View file

@ -2,6 +2,7 @@ package de.avatic.lcc.controller.configuration;
import com.azure.core.annotation.BodyParam;
import de.avatic.lcc.dto.configuration.apps.AppDTO;
import de.avatic.lcc.dto.configuration.apps.AppExchangeDTO;
import de.avatic.lcc.service.apps.AppsService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
@ -32,6 +33,18 @@ public class AppsController {
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}/"})
@PreAuthorize("hasRole('SERVICE')")
public ResponseEntity<Void> deleteApp(@PathVariable Integer id) {

View file

@ -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;
}
}

View file

@ -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;
}
}

View 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;
}
}

View file

@ -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;
}
}
}

View file

@ -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;
}
}
}

View file

@ -215,7 +215,7 @@ public class ContainerCalculationResult {
* @return The total utilization value for the container.
*/
public double getTotalUtilizationByVolume() {
return getHuUtilizationByVolume() * huUnitCount * layer;
return getHuUtilizationByVolume() * huUnitCount;
}
/**
@ -254,4 +254,7 @@ public class ContainerCalculationResult {
return WeightUnit.KG.convertFromG(hu.getWeight()) / maxContainerWeight;
}
public int getHuPerContainer() {
return this.huUnitCount;
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -88,7 +88,7 @@ public class DistanceMatrixRepository {
toId);
logger.info("Updated existing distance entry for nodes {} -> {}",
distance.getFromNodeId(), distance.getToNodeId());
fromId, toId);
} else {
// Insert new entry
String insertQuery = """
@ -111,11 +111,14 @@ public class DistanceMatrixRepository {
distance.getUpdatedAt());
logger.info("Inserted new distance entry for nodes {} -> {}",
distance.getFromNodeId(), distance.getToNodeId());
fromId, toId);
}
} 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 {} -> {}",
distance.getFromNodeId(), distance.getToNodeId(), e);
fromId, toId, e);
throw e;
}
}

View file

@ -177,6 +177,12 @@ public class PremisesService {
premissIds.forEach(id -> {
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);
premiseRepository.updateMaterial(Collections.singletonList(newId), old.getHsCode(), old.getTariffRate(), old.getTariffUnlocked());

View file

@ -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) {
var prop = propertyRepository.getPropertyByMappingId(mappingId);
return doCasting(prop);
}
@SuppressWarnings("unchecked")
private <T> Optional<T> doCasting(Optional<PropertyDTO> prop) {
if (prop.isEmpty())
return Optional.empty();
@ -158,4 +169,6 @@ public class PropertyService {
default -> throw new IllegalArgumentException("Unsupported data type: " + dataType);
};
}
}

View file

@ -1,11 +1,12 @@
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.BatchGeocodingResponse;
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.db.country.IsoCode;
import de.avatic.lcc.model.excel.ExcelNode;
import de.avatic.lcc.util.exception.internalerror.ExcelValidationError;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -17,6 +18,8 @@ import org.springframework.web.util.UriComponentsBuilder;
import java.math.BigDecimal;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@ -45,6 +48,7 @@ public class BatchGeoApiService {
}
ArrayList<BulkInstruction<ExcelNode>> noGeo = new ArrayList<>();
ArrayList<BulkInstruction<ExcelNode>> failedGeoLookups = new ArrayList<>();
int totalSuccessful = 0;
for (var node : nodes) {
@ -53,6 +57,7 @@ public class BatchGeoApiService {
}
}
for (int currentBatch = 0; currentBatch < noGeo.size(); currentBatch += MAX_BATCH_SIZE) {
int end = Math.min(currentBatch + MAX_BATCH_SIZE, noGeo.size());
var chunk = noGeo.subList(currentBatch, end);
@ -73,7 +78,8 @@ public class BatchGeoApiService {
if (!result.getFeatures().isEmpty() &&
(result.getFeatures().getFirst().getProperties().getConfidence().equalsIgnoreCase("high") ||
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 properties = result.getFeatures().getFirst().getProperties();
node.setGeoLng(BigDecimal.valueOf(geometry.getCoordinates().get(0)));
@ -82,11 +88,106 @@ public class BatchGeoApiService {
node.setCountryId(IsoCode.valueOf(properties.getAddress().getCountryRegion().getIso()));
} else {
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) {

View file

@ -78,7 +78,7 @@ public class DistanceApiService {
Optional<Distance> cachedDistance = distanceMatrixRepository.getDistance(from, isUsrFrom, to, isUsrTo);
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;
}
@ -89,7 +89,7 @@ public class DistanceApiService {
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);
if (distanceResponse.distance != null) {
@ -97,7 +97,7 @@ public class DistanceApiService {
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));
}
@ -111,7 +111,7 @@ public class DistanceApiService {
distance.setUpdatedAt(LocalDateTime.now());
distance.setRetries(distance.getRetries() == null ? 0 : distance.getRetries() + 1);
if(cachedDistance.isEmpty()) {
if (cachedDistance.isEmpty()) {
distance.setFromUserNodeId(isUsrFrom ? from.getId() : null);
distance.setFromNodeId(isUsrFrom ? null : from.getId());
distance.setToUserNodeId(isUsrTo ? to.getId() : null);
@ -208,7 +208,7 @@ public class DistanceApiService {
String errorMessage = errorNode.path("error").path("message").asText();
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);
if (errorMessage.contains("NO_ROUTE_FOUND"))

View file

@ -32,18 +32,16 @@ public class TaxationResolverService {
private final CountryRepository countryRepository;
private final EUTaxationApiService eUTaxationApiService;
private final PropertyRepository propertyRepository;
private final ZolltarifnummernApiService zolltarifnummernApiService;
private final NomenclatureService nomenclatureService;
private final CountryPropertyRepository countryPropertyRepository;
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.eUTaxationApiService = eUTaxationApiService;
this.propertyRepository = propertyRepository;
this.zolltarifnummernApiService = zolltarifnummernApiService;
this.nomenclatureService = nomenclatureService;
this.countryPropertyRepository = countryPropertyRepository;
}
@ -104,19 +102,6 @@ public class TaxationResolverService {
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) {

View file

@ -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());
}
}

View file

@ -1,13 +1,23 @@
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.AppExchangeDTO;
import de.avatic.lcc.model.db.users.App;
import de.avatic.lcc.repositories.users.AppRepository;
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.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.List;
import java.util.Optional;
@ -16,14 +26,31 @@ import java.util.UUID;
@Service
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 AppTransformer appTransformer;
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.appTransformer = appTransformer;
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() {
@ -35,7 +62,7 @@ public class AppsService {
var newApp = dto.getId() == null;
String appSecret = null;
if(newApp) {
if (newApp) {
dto.setClientId(generateAppId());
appSecret = generateAppSecret();
dto.setClientSecret(passwordEncoder.encode(appSecret));
@ -43,7 +70,7 @@ public class AppsService {
var id = appRepository.update(appTransformer.toAppEntity(dto));
if(newApp) {
if (newApp) {
dto.setId(id);
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;
}
}

View file

@ -20,6 +20,7 @@ import de.avatic.lcc.repositories.premise.PremiseRepository;
import de.avatic.lcc.repositories.premise.RouteRepository;
import de.avatic.lcc.repositories.premise.RouteSectionRepository;
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.precalculation.PostCalculationCheckService;
import org.slf4j.Logger;
@ -51,9 +52,10 @@ public class CalculationExecutionService {
private final PostCalculationCheckService postCalculationCheckService;
private final CalculationJobDestinationRepository calculationJobDestinationRepository;
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.destinationRepository = destinationRepository;
this.routeRepository = routeRepository;
@ -70,6 +72,7 @@ public class CalculationExecutionService {
this.postCalculationCheckService = postCalculationCheckService;
this.calculationJobDestinationRepository = calculationJobDestinationRepository;
this.calculationJobRouteSectionRepository = calculationJobRouteSectionRepository;
this.propertyService = propertyService;
}
private static ContainerType getBestContainerType(Map<ContainerType, List<SectionInfo>> sectionResults) {
@ -137,15 +140,19 @@ public class CalculationExecutionService {
AirfreightResult airfreightCost = airfreightCalculationService.doCalculation(setId, periodId, premise, destination);
ContainerType usedContainerType = null;
ContainerCalculationResult selectedContainerCalculation = null;
CalculationJobDestination destinationCalculationJob = new CalculationJobDestination();
boolean hasMainRun = true;
BigDecimal leadTime = null;
boolean isWeightExceeded = false;
if (destination.getD2d()) {
var containerCalculation = containerCalculationService.doCalculation(setId, premiseToHuService.createHuFromPremise(premise), ContainerType.FEU, premise.getHuMixable());
sections = List.of(new SectionInfo(null, routeSectionCostCalculationService.doD2dCalculation(setId, periodId, premise, destination, containerCalculation), containerCalculation));
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, selectedContainerCalculation), selectedContainerCalculation));
leadTime = BigDecimal.valueOf(destination.getLeadTimeD2d());
usedContainerType = ContainerType.FEU;
isWeightExceeded = sections.getFirst().result().isWeightPrice();
} else {
var bestContainerTypeResult = getSectionsFromBestContainerType(setId, periodId, destination, premise);
sections = bestContainerTypeResult.sections;
@ -160,7 +167,20 @@ public class CalculationExecutionService {
s.result().setPreRun(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());
@ -168,9 +188,9 @@ public class CalculationExecutionService {
if(destination.getD2d())
destinationCalculationJob.setRateD2D(destination.getRateD2d());
customCost = customCostCalculationService.doCalculation(setId, premise, destination, sections);
handlingCost = handlingCostCalculationService.doCalculation(setId, premise, destination, hasMainRun);
inventoryCost = inventoryCostCalculationService.doCalculation(setId, premise, destination, leadTime);
customCost = customCostCalculationService.doCalculation(setId, premise, destination, sections, selectedContainerCalculation);
handlingCost = handlingCostCalculationService.doCalculation(setId, premise, destination, hasMainRun, selectedContainerCalculation);
inventoryCost = inventoryCostCalculationService.doCalculation(setId, premise, destination, leadTime, selectedContainerCalculation);
destinationCalculationJob.setContainerType(usedContainerType);
@ -203,16 +223,20 @@ public class CalculationExecutionService {
destinationCalculationJob.setAnnualCustomCost(customCost.getAnnualCost());
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.setLayerStructure(null); //TODO generate layer structure
destinationCalculationJob.setHuCount(sections.getFirst().containerResult().getHuUnitCount());
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()
.add(destinationCalculationJob.getAnnualDisposalCost())
.add(destinationCalculationJob.getAnnualRepackingCost())
.add(destinationCalculationJob.getAnnualCapitalCost())
.add(destinationCalculationJob.getAnnualStorageCost())
.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) {
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();
List<RouteSection> routeSections = routeSectionRepository.getByRouteId(route.getId());
@ -246,11 +280,12 @@ public class CalculationExecutionService {
// Get container calculation
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()) {
if (!containerType.equals(ContainerType.TRUCK)) {
if (!containerType.equals(ContainerType.TRUCK) && active.get(containerType)) {
var sectionInfo = new ArrayList<SectionInfo>();
@ -264,10 +299,10 @@ public class CalculationExecutionService {
}
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) {
}
}

View file

@ -70,8 +70,8 @@ public class CalculationJobProcessorManagementService {
validPeriod = validityPeriodRepository.getByDate(request.getDate());
}
request.getPremiseIds().forEach(premiseId -> preCalculationCheckService.doPrecheck(premiseId, validSet, validPeriod, request.getDate()));
boolean multiRequest = request.getPremiseIds().size() > 1;
request.getPremiseIds().forEach(premiseId -> preCalculationCheckService.doPrecheck(premiseId, validSet, validPeriod, request.getDate(), multiRequest));
if (validSet.isEmpty() || validPeriod.isEmpty())

View file

@ -49,7 +49,7 @@ public class ContainerCalculationService {
* @param containerType The type of container to be loaded
* @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 maxContainerLoad = BigDecimal.valueOf(getMaxContainerLoad(containerType, setId));
@ -60,7 +60,7 @@ public class ContainerCalculationService {
var solutionHorizontal = solveLayer(SolutionType.HORIZONTAL, dimensions, containerType.getLength(), containerType.getWidth());
var solutionVertical = solveLayer(SolutionType.VERTICAL, dimensions, containerType.getWidth(), containerType.getLength());
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)) {
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());

View file

@ -37,18 +37,7 @@ public class CustomCostCalculationService {
this.shippingFrequencyCalculationService = shippingFrequencyCalculationService;
}
private BigDecimal getContainerShare(Premise premise, 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) {
public CustomResult doCalculation(Integer setId, Premise premise, Destination destination, List<SectionInfo> sections, ContainerCalculationResult containerCalculationResult) {
var destUnion = countryPropertyRepository.getByMappingIdAndCountryId(CountryPropertyMappingId.UNION, setId, destination.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);
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;
}
private CustomResult getCustomCalculationResult(Integer setId, Premise premise, Destination destination, BigDecimal containerShare, double huAnnualAmount, BigDecimal transportationCost, BigDecimal transportationChanceCost, BigDecimal transportationRiskCost) {
var shippingFrequency = shippingFrequencyCalculationService.doCalculation(setId, huAnnualAmount);
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, containerCalculationResult.getHuPerContainer(), !premise.getHuMixable());
var customFee = Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.CUSTOM_FEE, setId).orElseThrow().getCurrentValue());
var tariffRate = premise.getTariffRate();
@ -87,7 +77,7 @@ public class CustomCostCalculationService {
var customValue = materialCost.add(fcaFee).add(transportationCost);
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 customRiskValue = materialCost.add(fcaFee).add(transportationRiskCost);

View file

@ -1,5 +1,6 @@
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.db.packaging.LoadCarrierType;
import de.avatic.lcc.model.db.packaging.PackagingDimension;
@ -13,6 +14,7 @@ import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Objects;
@Service
public class HandlingCostCalculationService {
@ -30,32 +32,46 @@ public class HandlingCostCalculationService {
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);
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 destinationDisposal = destination.getDisposalCost();
var destinationRepacking = destination.getRepackingCost();
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 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 disposal = destinationDisposal != null ? destinationDisposal : (addRepackingAndDisposalCost ? BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.DISPOSAL, setId).orElseThrow().getCurrentValue())) : BigDecimal.ZERO);
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.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 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,
getRepackingCost(setId, hu, loadCarrierType, addRepackingAndDisposalCost, destinationRepacking).multiply(huAnnualAmount),
handling.multiply(huAnnualAmount),
destinationDisposal == null ? BigDecimal.ZERO : (disposal.multiply(huAnnualAmount)), //TODO: disposal SLC, ignore?
huAnnualAmount.multiply((handling.add(booking).add(release).add(dispatch).add(getRepackingCost(setId, hu, loadCarrierType, addRepackingAndDisposalCost, destinationRepacking)))).multiply(wageFactor));
annualRepacking,
annualHandling,
annualDisposal,
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 destinationDisposal = destination.getDisposalCost();
var destinationRepacking = destination.getRepackingCost();
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 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 booking = BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.BOOKING, setId).orElseThrow().getCurrentValue()));
var annualRepacking = 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 annualRepacking = Objects.requireNonNullElse(destinationRepacking, getRepackingCost(setId, hu, type, addRepackingAndDisposalCost, destinationRepacking).multiply(wageFactor)).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));

View file

@ -1,5 +1,6 @@
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.db.packaging.PackagingDimension;
import de.avatic.lcc.model.db.premises.Premise;
@ -30,7 +31,7 @@ public class InventoryCostCalculationService {
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;
@ -53,7 +54,7 @@ public class InventoryCostCalculationService {
var dailyAmount = annualAmount.divide(BigDecimal.valueOf(365), 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 stockedInventory = opStock.add(safetyStock);
var inTransportStock = dailyAmount.multiply(leadTime);
@ -71,7 +72,8 @@ public class InventoryCostCalculationService {
}
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);
return spaceCostPerHu;
}

View file

@ -40,8 +40,9 @@ public class RouteSectionCostCalculationService {
private final ChangeRiskFactorCalculationService changeRiskFactorCalculationService;
private final NodeRepository nodeRepository;
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.matrixRateRepository = matrixRateRepository;
this.routeNodeRepository = routeNodeRepository;
@ -50,6 +51,7 @@ public class RouteSectionCostCalculationService {
this.changeRiskFactorCalculationService = changeRiskFactorCalculationService;
this.nodeRepository = nodeRepository;
this.userNodeRepository = userNodeRepository;
this.shippingFrequencyCalculationService = shippingFrequencyCalculationService;
}
public CalculationJobRouteSection doD2dCalculation(Integer setId, Integer periodId, Premise premise, Destination destination, ContainerCalculationResult containerCalculation) {
@ -94,7 +96,10 @@ public class RouteSectionCostCalculationService {
containerCalculation.getMaxContainerWeight(),
BigDecimal.valueOf(containerCalculation.getTotalUtilizationByVolume()),
BigDecimal.valueOf(containerCalculation.getHuUtilizationByWeight()),
utilization);
utilization,
shippingFrequencyCalculationService.doCalculation(setId, huAnnualAmount.doubleValue(), containerCalculation.getHuPerContainer(), !premise.getHuMixable()),
huAnnualAmount.doubleValue(),
containerCalculation);
result.setCbmPrice(!containerCalculation.isWeightExceeded());
result.setWeightPrice(containerCalculation.isWeightExceeded());
@ -177,7 +182,10 @@ public class RouteSectionCostCalculationService {
containerCalculation.getMaxContainerWeight(),
BigDecimal.valueOf(containerCalculation.getTotalUtilizationByVolume()),
BigDecimal.valueOf(containerCalculation.getTotalUtilizationByWeight()),
utilization);
utilization,
shippingFrequencyCalculationService.doCalculation(setId, huAnnualAmount.doubleValue(), containerCalculation.getHuPerContainer(), !premise.getHuMixable()),
huAnnualAmount.doubleValue(),
containerCalculation);
result.setCbmPrice(!containerCalculation.isWeightExceeded());
result.setWeightPrice(containerCalculation.isWeightExceeded());
@ -211,7 +219,11 @@ public class RouteSectionCostCalculationService {
int maxContainerWeight,
BigDecimal totalVolumeUtilization,
BigDecimal totalWeightUtilization,
BigDecimal propertyUtilization) {
BigDecimal propertyUtilization,
double shippingFrequency,
double annualHuAmount,
ContainerCalculationResult containerCalculationResult
) {
BigDecimal utilization;
@ -221,14 +233,26 @@ public class RouteSectionCostCalculationService {
BigDecimal cbmRate = rate.divide(BigDecimal.valueOf(containerType.getVolume()), 10, RoundingMode.HALF_UP);
BigDecimal weightRate = rate.divide(BigDecimal.valueOf(maxContainerWeight), 10, RoundingMode.HALF_UP);
if (huMixable) {
volumePrice = cbmRate.divide(propertyUtilization, 10, RoundingMode.HALF_UP);
weightPrice = weightRate.divide(BigDecimal.valueOf(1), 10, RoundingMode.HALF_UP);
utilization = weightExceeded ? BigDecimal.ONE : propertyUtilization;
} 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);
weightPrice = weightRate.divide(totalWeightUtilization, 10, RoundingMode.HALF_UP);
utilization = weightExceeded ? totalWeightUtilization : totalVolumeUtilization;
}
return new PriceCalculationResult(volumePrice, weightPrice, utilization);

View file

@ -13,26 +13,20 @@ public class ShippingFrequencyCalculationService {
this.propertyRepository = propertyRepository;
}
public int doCalculation(Integer setId, int huAnnualAmount) {
var minAnnualFrequency = Integer.parseInt(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.FREQ_MIN, setId).orElseThrow().getCurrentValue());
var maxAnnualFrequency = Integer.parseInt(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.FREQ_MAX, setId).orElseThrow().getCurrentValue());
public double doCalculation(Integer setId, double huAnnualAmount, int maxHuPerContainer, boolean fillContainer) {
int minAnnualFrequency = Integer.parseInt(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.FREQ_MIN, 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 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());
}
}

View file

@ -140,26 +140,51 @@ public class NodeExcelMapper {
validateConstraints(row);
entity.setExternalMappingId(row.getCell(NodeHeader.MAPPING_ID.ordinal()).getStringCellValue());
entity.setName(row.getCell(NodeHeader.NAME.ordinal()).getStringCellValue());
entity.setAddress(row.getCell(NodeHeader.ADDRESS.ordinal()).getStringCellValue());
entity.setCountryId(IsoCode.valueOf(row.getCell(NodeHeader.COUNTRY.ordinal()).getStringCellValue()));
entity.setExternalMappingId(getCellValueAsString(row.getCell(NodeHeader.MAPPING_ID.ordinal())));
entity.setName(getCellValueAsString(row.getCell(NodeHeader.NAME.ordinal())));
entity.setAddress(getCellValueAsString(row.getCell(NodeHeader.ADDRESS.ordinal())));
entity.setCountryId(IsoCode.valueOf(getCellValueAsString(row.getCell(NodeHeader.COUNTRY.ordinal()))));
entity.setGeoLat(mapGeoCoordinate(CellUtil.getCell(row, NodeHeader.GEO_LATITUDE.ordinal())));
entity.setGeoLng(mapGeoCoordinate(CellUtil.getCell(row, NodeHeader.GEO_LONGITUDE.ordinal())));
entity.setSource(Boolean.valueOf(row.getCell(NodeHeader.IS_SOURCE.ordinal()).getStringCellValue()));
entity.setIntermediate(Boolean.valueOf(row.getCell(NodeHeader.IS_INTERMEDIATE.ordinal()).getStringCellValue()));
entity.setDestination(Boolean.valueOf(row.getCell(NodeHeader.IS_DESTINATION.ordinal()).getStringCellValue()));
entity.setSource(Boolean.valueOf(getCellValueAsString(row.getCell(NodeHeader.IS_SOURCE.ordinal()))));
entity.setIntermediate(Boolean.valueOf(getCellValueAsString(row.getCell(NodeHeader.IS_INTERMEDIATE.ordinal()))));
entity.setDestination(Boolean.valueOf(getCellValueAsString(row.getCell(NodeHeader.IS_DESTINATION.ordinal()))));
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()));
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()));
return new BulkInstruction<>(entity, BulkInstructionType.valueOf(getCellValueAsString(row.getCell(NodeHeader.OPERATION.ordinal()))));
}
/**
* 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 -> "";
};
}

View 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
);
}
}

View file

@ -69,12 +69,12 @@ public class PreCalculationCheckService {
this.userNodeRepository = userNodeRepository;
}
public void doPrecheck(Integer premiseId, Optional<PropertySet> set, Optional<ValidityPeriod> period) {
doPrecheck(premiseId, set, period, LocalDate.now());
public void doPrecheck(Integer premiseId, Optional<PropertySet> set, Optional<ValidityPeriod> period, boolean showCalcInfo) {
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();
supplierCheck(premise);
@ -88,46 +88,46 @@ public class PreCalculationCheckService {
var partNumber = material.get().getPartNumber();
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);
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) {
var node = nodeRepository.getByDestinationId(destination.getId()).orElseThrow();
destinationCheck(destination, node, supplierName, partNumber);
destinationCheck(destination, node, supplierName, partNumber, showCalcInfo);
var routes = routeRepository.getByDestinationId(destination.getId());
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)
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)) {
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)) {
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);
routes.stream().filter(Route::getSelected).findAny().ifPresent(r -> {
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 -> {
var fromRouteNode = routeNodeRepository.getFromNodeBySectionId(section.getId());
var toRouteNode = routeNodeRepository.getToNodeBySectionId(section.getId());
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()) {
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()) {
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) {
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",
fromRouteNode.getExternalMappingId(),
toRouteNode.getExternalMappingId(),
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)
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)
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.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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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);
@ -306,26 +306,26 @@ public class PreCalculationCheckService {
Optional<Integer> feuLoad = propertyService.getProperty(SystemPropertyMappingId.TEU_LOAD);
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())
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 teuFitsYX = (hu.getWidth() < ContainerType.TEU.getLength() && hu.getLength() < ContainerType.TEU.getWidth());
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) {
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.getHsCode() == null || premise.getHsCode().length() < 10)
@ -337,7 +337,7 @@ public class PreCalculationCheckService {
// }
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);
}
}

View file

@ -14,7 +14,6 @@ public class AppTransformer {
dto.setId(entity.getId());
dto.setName(entity.getName());
dto.setClientSecret(entity.getClientSecret());
dto.setClientId(entity.getClientId());
dto.setGroups(entity.getGroups().stream().map(Group::getName).toList());
@ -38,4 +37,26 @@ public class AppTransformer {
group.setName(name);
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;
}
}

View file

@ -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 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 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);

View file

@ -4,8 +4,8 @@ import de.avatic.lcc.util.exception.base.InternalErrorException;
public class PremiseValidationError extends InternalErrorException {
public PremiseValidationError(String message, String supplierName, String partNumber) {
super("Calculation data validation failed.", String.format("%s (Part number:%s - %s)",message, partNumber, supplierName));
public PremiseValidationError(String message, String supplierName, String partNumber, boolean showCalcInfo) {
super("Calculation data validation failed.", showCalcInfo ? String.format("%s (Part number: %s - %s)" , message, partNumber, supplierName) : message);
}
public PremiseValidationError(String message) {

View 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.
![Screenshot 1](images/image1.jpg)
### 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.
![Screenshot 2](images/image2.jpg)
### 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.
![Screenshot 3](images/image3.jpg)
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.
![Screenshot 4](images/image4.jpg)
![Screenshot 5](images/image5.jpg)
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.
![Screenshot 6](images/image6.jpg)
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.
![Screenshot 7](images/image7.jpg)
You can remove a single part number from the list the same way.
![Screenshot 8](images/image8.jpg)
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.
![Screenshot 9](images/image9.jpg)
Enter the name of the supplier you want, pick them from the results list, and click **“Bietvorschlag”** to add them.
![Screenshot 10](images/image10.jpg)
![Screenshot 11](images/image11.jpg)
The supplier will then be assigned to the selected part numbers.
### Step 8: Create a New Supplier
If you dont find the supplier youre looking for, you can add a new one by clicking the **“Create New Supplier”** button.
![Screenshot 12](images/image12.jpg)
![Screenshot 13](images/image13.jpg)
In the dialog that opens, type in the new suppliers name and then enter their address.
![Screenshot 14](images/image14.jpg)
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.
![Screenshot 15](images/image15.jpg)
A map will be shown so you can confirm the address was correctly recognized.
![Screenshot 16](images/image16.jpg)
Once all details are correct, click the **“Create”** button to add the new supplier and insert them directly into your current form.
![Screenshot 17](images/image17.jpg)
### Step 9: Review the Number of Calculations to be Created
After youve 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, theres 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.
![Screenshot 18](images/image18.jpg)
### Step 11: Generate Calculations
Finally, click the **“Create”** button to set up all calculations for your selected combinations of part numbers and suppliers.
![Screenshot 19](images/image19.jpg)
Your new logistics calculations will now be available in the system for further editing.

View 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.
![Screenshot 1](images/image1.jpg)
### 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 youve 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. Theres a search bar above the table so you can filter the list.
![Screenshot 2](images/image2.jpg)
You can use this search bar to filter by **supplier** or **part number**. The table updates automatically as you type.
Theres also a status dropdown to choose which calculations you want to see. For example, you can filter to show only **completed** calculations.
![Screenshot 3](images/image3.jpg)
![Screenshot 5](images/image4.jpg)
### Step 4: Navigating the Table
At the bottom of the table, youll find controls for **page navigation** so you can flip through multiple pages of calculations.
![Screenshot 6](images/image5.jpg)
![Screenshot 7](images/image6.jpg)
### 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.
![Screenshot 8](images/image7.jpg)
![Screenshot 9](images/image8.jpg)
You can even select calculations across different pages—the selection is **persistent** as you move from page to page.
![Screenshot 10](images/image9.jpg)
![Screenshot 12](images/image10.jpg)
Once youve selected at least one calculation, a **toolbar** pops up at the bottom of the screen. It shows how many calculations youve picked and gives you buttons for available actions, as well as a button to clear your selection.
![Screenshot 13](images/image11.jpg)
![Screenshot 14](images/image12.jpg)
### 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,
![Screenshot 15](images/image13.jpg)
![Screenshot 19](images/image14.jpg)
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**,
![Screenshot 20](images/image15.jpg)
![Screenshot 21](images/image16.jpg)
youll see an error message. This message tells you that completed calculations cant be edited directly. Youll 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,
![Screenshot 22](images/image17.jpg)
![Screenshot 23](images/image18.jpg)
youll get an error message if any of the selected calculations cant 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.
![Screenshot 24](images/image19.jpg)
### Step 9: Archiving Multiple Calculations
If you select several calculations and click the **Archive** button in the toolbar,
![Screenshot 25](images/image20.jpg)
youll see a message letting you know that only **completed** calculations can be archived. If youve selected drafts too, they wont be archived.
Click the **Archive** button in the message to archive the selected completed calculations. Or, hit **Cancel** to cancel the action.
![Screenshot 26](images/image21.jpg)

View 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.
![Screenshot 1](images/image1.jpg)
After picking multiple calculations, you can begin editing.
![Screenshot 2](images/image2.jpg)
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, theres a pencil icon you can click to open that specific calculation in single edit mode.
![Screenshot 3](images/image3.jpg)
When you click the pencil, youll 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.
![Screenshot 4](images/image4.jpg)
Theres also an X icon in each row. Clicking this removes the row from the mass edit view, but it doesnt 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,
![Screenshot 5](images/image5.jpg)
the same packaging input window opens as in single edit mode.
![Screenshot 6](images/image6.jpg)
You can enter the values as usual in this window.
![Screenshot 7](images/image7.jpg)
![Screenshot 8](images/image8.jpg)
Here, youll enter for example the data for a Euro pallet,
![Screenshot 9](images/image9.jpg)
![Screenshot 10](images/image10.jpg)
including the weight
![Screenshot 11](images/image11.jpg)
and the number of units per package.
Important
![Screenshot 12](images/image12.jpg)
is to
![Screenshot 13](images/image13.jpg)
pick the right dimensions.
![Screenshot 14](images/image14.jpg)
![Screenshot 15](images/image15.jpg)
Once youre done, hit “OK” to confirm your entry,
![Screenshot 16](images/image16.jpg)
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.
![Screenshot 17](images/image17.jpg)
![Screenshot 18](images/image18.jpg)
As soon as you select more than one row, an editing bar will pop up at the bottom.
![Screenshot 19](images/image19.jpg)
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
![Screenshot 20](images/image20.jpg)
opens the price input window.
![Screenshot 21](images/image21.jpg)
You might enter a price of eighty-five euros here,
![Screenshot 22](images/image22.jpg)
along with a seventy percent Oversea Share. Then you check FCA fee.
![Screenshot 23](images/image23.jpg)
![Screenshot 24](images/image24.jpg)
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.
![Screenshot 25](images/image25.jpg)
### 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.
![Screenshot 26](images/image26.jpg)
This will select all rows with that material number. While holding Ctrl, your cursor will change to show youre in multi-select mode.
The same approach works for suppliers: If you hold Ctrl and click on a supplier,
![Screenshot 27](images/image27.jpg)
all rows with that supplier get selected.
If you want to combine different suppliers or materials, start by holding Ctrl
![Screenshot 28](images/image28.jpg)
and clicking the first material or supplier cell.
Then, hold both Ctrl and Shift and click on another material number.
![Screenshot 29](images/image29.jpg)
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
![Screenshot 30](images/image30.jpg)
you can edit the packaging together, for example.
![Screenshot 31](images/image31.jpg)
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
![Screenshot 32](images/image32.jpg)
and then click “OK,”
![Screenshot 33](images/image33.jpg)
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.
![Screenshot 34](images/image34.jpg)
Start by selecting the target calculations.
![Screenshot 35](images/image35.jpg)
![Screenshot 36](images/image36.jpg)
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),
![Screenshot 37](images/image37.jpg)
the values from that cell will be pulled into the input window.
You can tweak these values if needed
![Screenshot 38](images/image38.jpg)
before confirming them with “OK.”
![Screenshot 39](images/image39.jpg)
The updated values will only go into the rows you picked earlier. The original source calculation stays unchanged.
![Screenshot 40](images/image40.jpg)

View 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.
![Screenshot 1](images/image1.jpg)
This video explains what the warning means and how to handle it.
![Screenshot 2](images/image2.jpg)
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.
![Screenshot 3](images/image3.jpg)
A summary will pop up showing all your material and supplier combinations.
![Screenshot 4](images/image4.jpg)
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).
![Screenshot 5](images/image5.jpg)
You can then search for another plant (like “Geisa”)
![Screenshot 6](images/image6.jpg)
and add that one too.
![Screenshot 7](images/image7.jpg)
***
### 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.
![Screenshot 8](images/image8.jpg)
For example, you can set it up so a specific combo is delivered to both destinations.
![Screenshot 9](images/image9.jpg)
Set checkmarks for all the combinations as needed.
![Screenshot 10](images/image10.jpg)
![Screenshot 17](images/image11.jpg)
***
### Step 4: Apply Destinations and Auto-fill Cells
After assigning your destinations, click the “OK” button at the bottom to process the data.
![Screenshot 18](images/image12.jpg)
![Screenshot 20](images/image13.jpg)
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
![Screenshot 21](images/image14.jpg)
and confirm your changes again with “OK”.
![Screenshot 22](images/image15.jpg)
![Screenshot 26](images/image16.jpg)
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.
![Screenshot 27](images/image17.jpg)
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.
![Screenshot 28](images/image18.jpg)
![Screenshot 30](images/image19.jpg)
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.
![Screenshot 31](images/image20.jpg)
![Screenshot 32](images/image21.jpg)
You can type values directly into each cell
![Screenshot 33](images/image22.jpg)
or select several rows at once
![Screenshot 34](images/image23.jpg)
![Screenshot 37](images/image24.jpg)
and enter the value in the input field at the top.
![Screenshot 38](images/image25.jpg)
![Screenshot 39](images/image26.jpg)
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.
![Screenshot 40](images/image27.jpg)
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.
![Screenshot 41](images/image28.jpg)
![Screenshot 49](images/image29.jpg)
***
### Step 7: Edit Handling, Repackaging Costs, and Routes Using Tabs
At the top, youll see tabs for editing handling and repackaging costs, as well as routes, for all selected calculations.
![Screenshot 50](images/image30.jpg)
![Screenshot 53](images/image31.jpg)
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.
![Screenshot 54](images/image32.jpg)
![Screenshot 55](images/image33.jpg)
Pick the appropriate route for every combination.
![Screenshot 56](images/image34.jpg)
![Screenshot 60](images/image35.jpg)
Besides predefined routes, you can also use Door-to-Door routing.
![Screenshot 61](images/image36.jpg)
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
![Screenshot 62](images/image37.jpg)
and enter the Door-to-Door rate and lead time.
![Screenshot 63](images/image38.jpg)
![Screenshot 64](images/image39.jpg)
Click “OK” to confirm.
![Screenshot 65](images/image40.jpg)
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.
![Screenshot 66](images/image41.jpg)
First, acknowledge the warning by checking the box to enable editing.
![Screenshot 67](images/image42.jpg)
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.
![Screenshot 68](images/image43.jpg)
Then, as with annual quantities, enter a value in the input bar at the top
![Screenshot 69](images/image44.jpg)
![Screenshot 70](images/image45.jpg)
and use the checkmark to apply it to all the selected rows.
![Screenshot 71](images/image46.jpg)
![Screenshot 73](images/image47.jpg)
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.
![Screenshot 76](images/image48.jpg)
This will add that supplier to your current selection.
![Screenshot 77](images/image49.jpg)
You can then enter values at the top again. Empty cells will be left as they are; only cells where youve typed something new will be changed. Existing values (like “300”) stay the same.
![Screenshot 78](images/image50.jpg)
![Screenshot 79](images/image51.jpg)
This kind of selection is especially handy in the annual quantity menu.
![Screenshot 80](images/image52.jpg)
![Screenshot 81](images/image53.jpg)
When youre done, click the “OK” button to save your changes.
![Screenshot 82](images/image54.jpg)
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.
![Screenshot 83](images/image55.jpg)
For example, if all rows are selected and you copy packaging data from one cell and confirm with “OK,”
![Screenshot 84](images/image56.jpg)
![Screenshot 85](images/image57.jpg)
all relevant values will be filled in and you can kick off the calculation with “Calculate and close.”
![Screenshot 86](images/image58.jpg)
Your calculations will then go into a queue and be processed in the background.

View file

@ -0,0 +1,119 @@
> This guide shows you how to create a report from completed calculations, select the relevant suppliers, interpret the reports 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.
![Screenshot 1](images/image1.jpg)
### Step 2: Create a New Report
Once youre in the Reporting view, click the **"Create Report"** button to make a new report.
![Screenshot 2](images/image2.jpg)
### 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.
![Screenshot 3](images/image3.jpg)
Click on the suggested part number to select it.
![Screenshot 4](images/image4.jpg)
### 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.
![Screenshot 5](images/image5.jpg)
![Screenshot 7](images/image6.jpg)
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.
![Screenshot 8](images/image7.jpg)
### Step 5: Get an Overview of the Report Structure
When your report opens, you might see a warning that some of the reports cant be fully compared—like if target plants or annual purchase quantities are different. At the bottom, youll 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, youll see:
- the time period for the calculations
- the part number the report is about
- several columns—one for each supplier you picked
Youll 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**.
- Theres 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, youll find:
- **MEK A price**, **logistics costs**, and **MEK B price**
- each shown as **absolute values**
- and as **percentages** relative to the MEK B price
Youll 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.
![Screenshot 9](images/image8.jpg)
Here youll find:
- the **total values** for the fluctuations
- the **percentage values** compared to the calculated MEK B price
![Screenshot 10](images/image9.jpg)
![Screenshot 11](images/image10.jpg)
### Step 8: View Assumptions and Logistics Route
At the bottom of the report, youll 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.
![Screenshot 12](images/image11.jpg)
Here, youll see the **logistics route** used as the basis for the calculation. If you hover your mouse over the route sections, youll 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, youll 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.

View file

@ -0,0 +1,210 @@
> In this application, youll edit logistics cost calculations for a specific supplier and part number combination. The detailed view is divided into several sections where youll enter material details, pricing info, packaging data, and destination plants. At the end, youll start the calculation, and if there are any errors, youll 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.
![Screenshot 1](images/image1.jpg)
At the top of the detailed view, youll 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, youll 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 dont need to enter anything.
- If the lookup isnt clear (shown by a message at the bottom of the section), youll 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, youll enter pricing information:
1. Enter the MEK A purchase price.
![Screenshot 2](images/image2.jpg)
This MEK A price should always be considered the FCA price.
2. If the price you have isnt an FCA price, but an Ex-Works price, check the “include FCA fee” box.
![Screenshot 3](images/image3.jpg)
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 isnt added.
![Screenshot 4](images/image4.jpg)
![Screenshot 5](images/image5.jpg)
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%.
![Screenshot 6](images/image6.jpg)
---
### 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.
![Screenshot 7](images/image7.jpg)
![Screenshot 8](images/image8.jpg)
2. Select the correct unit for these measurements (e.g. millimeters, if thats what you entered).
![Screenshot 9](images/image9.jpg)
![Screenshot 12](images/image10.jpg)
3. Enter the weight of the handling unit and choose the right weight unit (e.g. kilograms).
![Screenshot 13](images/image11.jpg)
![Screenshot 14](images/image12.jpg)
4. Enter the number of individual units inside a handling unit (e.g. 200 pieces per large or small load carrier).
![Screenshot 15](images/image13.jpg)
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.
![Screenshot 16](images/image14.jpg)
- 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.
![Screenshot 17](images/image15.jpg)
6. Check both boxes again if the part number can be shipped mixed and stacked.
![Screenshot 18](images/image16.jpg)
![Screenshot 19](images/image17.jpg)
---
### Step 5: Add destination plants and routes using the standard logic
At the bottom, youll 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.
![Screenshot 20](images/image18.jpg)
![Screenshot 21](images/image19.jpg)
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).
![Screenshot 22](images/image20.jpg)
![Screenshot 23](images/image21.jpg)
4. Switch to the second tab if you want to enter custom handling and repackaging costs. By default, the tool calculates these costs automatically.
![Screenshot 24](images/image22.jpg)
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.
![Screenshot 25](images/image23.jpg)
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).
![Screenshot 26](images/image24.jpg)
![Screenshot 27](images/image25.jpg)
- 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 theres no charge).
![Screenshot 28](images/image26.jpg)
7. Confirm everything for this plant by clicking the “OK” button.
![Screenshot 29](images/image27.jpg)
The plant now appears in the destinations list.
![Screenshot 30](images/image28.jpg)
---
### Step 6: Add another plant with a custom door-to-door rate
1. Add another plant, e.g. “Luzzara”.
![Screenshot 31](images/image29.jpg)
![Screenshot 32](images/image30.jpg)
2. If no route can be determined from the supplier for this plant, select the option to set a custom rate.
![Screenshot 33](images/image31.jpg)
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).
![Screenshot 34](images/image32.jpg)
![Screenshot 35](images/image33.jpg)
4. Enter the lead time (e.g. 47 days) and the plants annual quantity (e.g. 1,000 pieces).
![Screenshot 36](images/image34.jpg)
5. Confirm this plant as well by clicking the “OK” button.
![Screenshot 37](images/image35.jpg)
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.
![Screenshot 38](images/image36.jpg)
![Screenshot 39](images/image37.jpg)
2. Then start the calculation.
![Screenshot 40](images/image38.jpg)
Youll 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.
![Screenshot 41](images/image39.jpg)
![Screenshot 42](images/image40.jpg)
4. Start the calculation again.
![Screenshot 43](images/image41.jpg)
The calculation will now be queued and processed. You can see its status in your dashboard.

View file

@ -7,26 +7,6 @@ VALUES ('USR001', 'john.doe@company.com', 'John', 'Doe', TRUE),
('USR005', 'david.chen@company.com', 'David', 'Chen', TRUE)
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)
VALUES ((SELECT id FROM sys_group WHERE group_name = 'super'),

101
test/conftest.py Normal file
View 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
View 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
View 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)

View 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
View 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
View 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)

View 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
View 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
View 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
View 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