Merge pull request 'dev' (#104) from dev into main

Reviewed-on: #104
This commit is contained in:
Jan Weber 2026-01-11 22:26:47 +00:00
commit bde397e509
147 changed files with 6556 additions and 1468 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

@ -1,6 +1,6 @@
services:
mysql:
image: mysql:8.0
image: mysql:8.4
container_name: lcc-mysql-local
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}

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>
@ -28,7 +28,7 @@
</scm>
<properties>
<java.version>23</java.version>
<spring-cloud-azure.version>5.24.0</spring-cloud-azure.version>
<spring-cloud-azure.version>5.24.1</spring-cloud-azure.version>
<mockito.version>5.20.0</mockito.version>
<flyway.version>11.18.0</flyway.version>
</properties>
@ -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>
@ -44,10 +46,12 @@ html.modal-open {
color: #002F54;
}
.sub-header {
font-weight: normal;
font-size: 1.4rem;
color: #6B869C;
margin: 1.6rem 0;
}
html {

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);
}
}
}
@ -77,6 +90,9 @@ export default {
}
.action-container {
display: flex;
flex-direction: row;
gap: 1.2rem;
align-self: center;
}

View file

@ -133,9 +133,12 @@ export default {
.box-content.collapsed {
max-height: 0;
opacity: 0;
margin-top: 0;
margin-bottom: 0;
padding-top: 0;
padding-bottom: 0;
margin: 0 !important; /* ← !important um alle margins zu überschreiben */
padding: 0 !important;
overflow: hidden;
}
</style>

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 helppages -->
<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 helppages -->
<button
v-for="pageNum in visiblePages"
:key="pageNum"
@ -37,7 +37,7 @@
<!-- Last ellipsis -->
<span v-if="showLastEllipsis" class="ellipsis">...</span>
<!-- Last page -->
<!-- Last helppages -->
<button
v-if="showLastPage"
class="pagination-btn page-number"
@ -90,7 +90,7 @@ export default {
default: 5
}
},
emits: ['page-change'],
emits: ['helppages-change'],
computed: {
visiblePages() {
const delta = Math.floor(this.maxVisiblePages / 2);
@ -130,7 +130,7 @@ export default {
methods: {
goToPage(pageNumber) {
if (pageNumber >= 1 && pageNumber <= this.pageCount && pageNumber !== this.page) {
this.$emit('page-change', pageNumber);
this.$emit('helppages-change', pageNumber);
}
}
}

View file

@ -116,7 +116,7 @@ export default {
case 'warning':
return 'secondary'
case 'info':
return 'primary'
return 'secondary'
case 'error':
return 'exception'
default:

View file

@ -1,10 +1,16 @@
<template>
<div class="item-container" :class="{'selected-item': selected}">
<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-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

@ -20,6 +20,9 @@
<div class="calculation-list-status-cell">
<basic-badge :variant="variant" :icon="variantIcon">{{ premise.state }}</basic-badge>
</div>
<div class="calculation-list-date-cell">
{{ buildDate(this.premise.created_at, true)}}
</div>
<div class="calculation-list-actions-cell">
<icon-button :disabled="!isDraft" icon="pencil-simple" @click="editClick" help-text="Edit this calculation"
help-text-position="left"></icon-button>
@ -39,7 +42,7 @@ import Checkbox from "@/components/UI/Checkbox.vue";
import {mapStores} from "pinia";
import {usePremiseStore} from "@/store/premise.js";
import Flag from "@/components/UI/Flag.vue";
import {UrlSafeBase64} from "@/common.js";
import {buildDate, UrlSafeBase64} from "@/common.js";
export default {
name: "CalculationListItem",
@ -68,7 +71,7 @@ export default {
return 'grey';
} else if (this.premise.state === 'COMPLETED') {
return 'primary';
} else if (this.premise.state === 'EXCEPTION') {
} else if (this.premise.state === 'COMPLETED' && this.premise.calculation_state === 'EXCEPTION') {
return 'exception';
} else {
return 'grey';
@ -95,6 +98,7 @@ export default {
}
},
methods: {
buildDate,
updateCheckBox(checked) {
this.$emit('updateCheckbox', {checked: checked, id: this.id});
},
@ -124,7 +128,7 @@ export default {
.calculation-list-row {
display: grid;
grid-template-columns: 6rem 1fr 2fr 14rem 10rem;
grid-template-columns: 6rem 1fr 2fr 14rem 20rem 10rem;
gap: 1.6rem;
padding: 1.6rem;
border-bottom: 0.16rem solid #f3f4f6;
@ -203,6 +207,14 @@ export default {
justify-content: start;
}
.calculation-list-date-cell {
display: flex;
justify-content: start;
color: #6b7280;
font-size: 1.4rem;
line-height: 1.4;
}
.calculation-list-actions-cell {
display: flex;
gap: 0.8rem;

View file

@ -0,0 +1,212 @@
<template>
<div class="dashboard-container">
<!-- Total Calculations -->
<box>
<div class="dashboard-box">
<div class="dashboard-box-icon dashboard-box-icon--primary">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 256 256">
<polygon points="32 80 128 136 224 80 128 24 32 80" fill="currentColor" opacity="0.2"/>
<polygon points="32 128 128 184 224 128 128 72 32 128" fill="currentColor" opacity="0.2"/>
<polygon points="32 176 128 232 224 176 128 120 32 176" fill="currentColor" opacity="0.2"/>
<rect width="256" height="256" fill="none"/><polyline points="32 176 128 232 224 176" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><polyline points="32 128 128 184 224 128" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><polygon points="32 80 128 136 224 80 128 24 32 80" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
</div>
<div class="dashboard-box-info">
<div v-if="completed !== null" class="dashboard-box-number">{{ completed }}</div>
<div v-else class="dashboard-box-number"><spinner size="s"/></div>
<div class="dashboard-box-number-text">Completed</div>
</div>
</div>
</box>
<!-- Draft Calculations -->
<box>
<div class="dashboard-box">
<div class="dashboard-box-icon dashboard-box-icon--primary">
<svg xmlns="http://www.w3.org/2000/svg" width="42" height="42" viewBox="0 0 24 24">
<rect x="9" y="-1" width="8" height="24" fill="currentcolor" opacity="0.2" transform="rotate(45 13 11)"/>
<path d="M14.078 4.232l-12.64 12.639-1.438 7.129 7.127-1.438 12.641-12.64-5.69-5.69zm-10.369 14.893l-.85-.85 11.141-11.125.849.849-11.14 11.126zm2.008 2.008l-.85-.85 11.141-11.125.85.85-11.141 11.125zm18.283-15.444l-2.816 2.818-5.691-5.691 2.816-2.816 5.691 5.689z"/></svg>
</div>
<div class="dashboard-box-info">
<div v-if="drafts !== null" class="dashboard-box-number">{{ drafts }}</div>
<div v-else class="dashboard-box-number"><spinner size="s"/></div>
<div class="dashboard-box-number-text">Drafts</div>
</div>
</div>
</box>
<!-- Running Calculations -->
<box>
<div class="dashboard-box">
<div class="dashboard-box-icon dashboard-box-icon--primary">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24">
<circle cx="14" cy="14" r="10" fill="currentcolor" opacity="0.2"/>
<path fill="currentcolor" d="M15.91 13.34l2.636-4.026-.454-.406-3.673 3.099c-.675-.138-1.402.068-1.894.618-.736.823-.665 2.088.159 2.824.824.736 2.088.665 2.824-.159.492-.55.615-1.295.402-1.95zm-3.91-10.646v-2.694h4v2.694c-1.439-.243-2.592-.238-4 0zm8.851 2.064l1.407-1.407 1.414 1.414-1.321 1.321c-.462-.484-.964-.927-1.5-1.328zm-18.851 4.242h8v2h-8v-2zm-2 4h8v2h-8v-2zm3 4h7v2h-7v-2zm21-3c0 5.523-4.477 10-10 10-2.79 0-5.3-1.155-7.111-3h3.28c1.138.631 2.439 1 3.831 1 4.411 0 8-3.589 8-8s-3.589-8-8-8c-1.392 0-2.693.369-3.831 1h-3.28c1.811-1.845 4.321-3 7.111-3 5.523 0 10 4.477 10 10z"/>
</svg> </div>
<div class="dashboard-box-info">
<div v-if="running !== null" class="dashboard-box-number">{{ running }}</div>
<div v-else class="dashboard-box-number"><spinner size="s"/></div>
<div class="dashboard-box-number-text">Running</div>
</div>
</div>
</box>
<!-- Failed Calculations -->
<box>
<div class="dashboard-box">
<div class="dashboard-box-icon dashboard-box-icon--primary">
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M24 4L44 40H4L24 4Z" fill="currentColor" opacity="0.2"/>
<path d="M24 4L44 40H4L24 4Z" stroke="currentColor" stroke-width="3" stroke-linejoin="round" fill="none"/>
<path d="M24 18V28" stroke="currentColor" stroke-width="3" stroke-linecap="round"/>
<circle cx="24" cy="34" r="1.5" fill="currentColor"/>
</svg>
</div>
<div class="dashboard-box-info">
<div v-if="failed !== null" class="dashboard-box-number">{{ failed }}</div>
<div v-else class="dashboard-box-number"><spinner size="s"/></div>
<div class="dashboard-box-number-text">Failed</div>
</div>
</div>
</box>
</div>
</template>
<script>
import Box from "@/components/UI/Box.vue";
import { PhStack, PhPencilSimple, PhHourglassMedium, PhWarning } from "@phosphor-icons/vue";
import {useDashboardStore} from "@/store/dashboard.js";
import {mapStores} from "pinia";
import {usePremiseStore} from "@/store/premise.js";
import {useActiveUserStore} from "@/store/activeuser.js";
import Spinner from "@/components/UI/Spinner.vue";
export default {
name: "TheDashboard",
components: {
Spinner,
PhStack,
PhPencilSimple,
PhHourglassMedium,
PhWarning,
Box
},
computed: {
...mapStores(useDashboardStore),
completed() {
return this.dashboardStore.completed
},
drafts() {
return this.dashboardStore.drafts
},
running() {
return this.dashboardStore.running
},
failed() {
return this.dashboardStore.failed
}
},
created() {
this.dashboardStore.load();
},
mounted() {
this.dashboardStore.startPulling();
},
unmounted() {
this.dashboardStore.stopPulling();
}
}
</script>
<style scoped>
.dashboard-container {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1.6rem;
margin: 1.6rem 0 3.6rem 0;
}
.dashboard-box {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
padding: 2rem 2.4rem;
gap: 1.6rem;
}
.dashboard-box-icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.dashboard-box-more-link {
color: #002F54;
font-weight: 400;
font-size: 1.2rem;
text-decoration: none;
text-transform: none;
}
.dashboard-box-icon--primary {
color: #002F54;
}
.dashboard-box-icon--draft {
color: #6b7280;
}
.dashboard-box-icon--running {
color: #3b82f6;
}
.dashboard-box-icon--error {
color: #BC2B72;
}
.dashboard-box-info {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.dashboard-box-number {
font-weight: 500;
color: #002F54;
font-size: 3.2rem;
line-height: 1;
}
.dashboard-box-number-text {
font-weight: 400;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.05rem;
font-size: 1.4rem;
line-height: 1.4;
}
/* Responsive Design */
@media (max-width: 1024px) {
.dashboard-container {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 640px) {
.dashboard-container {
grid-template-columns: 1fr;
}
.dashboard-box {
padding: 1.6rem 2rem;
}
.dashboard-box-number {
font-size: 2.8rem;
}
}
</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,14 +10,21 @@
</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>
<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>
</template>
@ -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

@ -178,15 +178,16 @@ export default {
}
if (this.property.data_type === 'INT') {
this.value = parseNumberFromString(this.value, 0);
this.value = parseNumberFromString(this.value, 0, true);
}
if (this.property.data_type === 'PERCENTAGE') {
this.value = parseNumberFromString(this.value, 4);
this.value = parseNumberFromString(this.value, 4, true);
}
if (this.property.data_type === 'CURRENCY') {
this.value = parseNumberFromString(this.value, 2);
this.value = parseNumberFromString(this.value, 2, true);
console.log(this.property.name, " parsed from 'currency' property: '", this.value, "'")
}
}
}

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

@ -58,7 +58,7 @@ export default {
},
repackaging: {
get() {
return this.destination?.repackaging_costs?.toFixed(2) ?? '0.00';
return this.destination?.repackaging_costs?.toFixed(2) ?? '';
},
set(value) {
return this.destination && (this.destination.repackaging_costs = value);
@ -66,7 +66,7 @@ export default {
},
handling: {
get() {
return this.destination?.handling_costs?.toFixed(2) ?? '0.00';
return this.destination?.handling_costs?.toFixed(2) ?? '';
},
set(value) {
return this.destination && (this.destination.handling_costs = value);
@ -74,7 +74,7 @@ export default {
},
disposal: {
get() {
return this.destination?.disposal_costs?.toFixed(2) ?? '0.00';
return this.destination?.disposal_costs?.toFixed(2) ?? '';
},
set(value) {
return this.destination && (this.destination.disposal_costs = value);

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

@ -7,42 +7,53 @@
<report-chart
title=""
:mek_a="report.costs.mek_a.total"
:logistics_costs="report.risk.mek_b.total-report.costs.mek_a.total"
:chance_cost="report.risk.opportunity_scenario.total"
:risk_cost="report.risk.risk_scenario.total"
:logistics_costs="report.overview.mek_b.total-report.costs.mek_a.total"
:chance_cost="report.overview.opportunity_scenario.total"
:risk_cost="report.overview.risk_scenario.total"
:scale="chartScale"
></report-chart>
</div>
<!-- summary -->
<div class="box-gap">
<collapsible-box :is-collapsable="false" variant="border" title="Overview" size="m" :stretch-content="true">
<collapsible-box :is-collapsable="false" variant="border" title="Summary" size="m" :stretch-content="true">
<div class="report-content-container--3-col">
<div class="report-content-row">
<div class="report-content-row-highlight">MEK B</div>
<div class="report-content-data-cell report-content-row-highlight">{{ report.risk.mek_b.total.toFixed(2) }} </div>
<div class="report-content-data-cell"></div>
</div>
<div class="report-content-row">
<div>Opportunity scenario</div>
<div class="report-content-data-cell">{{ report.risk.opportunity_scenario.total.toFixed(2) }} </div>
<div>MEK A</div>
<div class="report-content-data-cell">{{ report.overview.mek_a.total.toFixed(2) }} </div>
<div class="report-content-data-cell">{{
`${(report.risk.opportunity_scenario.percentage * 100).toFixed(2)} %`
`${(report.overview.mek_a.percentage * 100).toFixed(2)} %`
}}
</div>
</div>
<div class="report-content-row">
<div>Risk scenario</div>
<div class="report-content-data-cell">{{ report.risk.risk_scenario.total.toFixed(2) }} </div>
<div class="report-content-data-cell">{{ `${(report.risk.risk_scenario.percentage * 100).toFixed(2)} %`}}</div>
<div>Logistics cost</div>
<div class="report-content-data-cell">{{ report.overview.logistics.total.toFixed(2) }} </div>
<div class="report-content-data-cell">{{
`${(report.overview.logistics.percentage * 100).toFixed(2)} %`
}}
</div>
</div>
<div class="report-content-row">
<div class="report-content-row-highlight">MEK B</div>
<div class="report-content-data-cell report-content-row-highlight">{{
report.overview.mek_b.total.toFixed(2)
}}
</div>
<div class="report-content-data-cell report-content-row-highlight">{{
`${(report.overview.mek_b.percentage * 100).toFixed(2)} %`
}}
</div>
</div>
</div>
</div>
</collapsible-box>
</div>
<!-- weighted cost breakdown-->
<div class="box-gap">
<collapsible-box :is-collapsable="false" variant="border" title="Weighted cost breakdown" size="m"
:stretch-content="true">
@ -142,57 +153,106 @@
<div class="report-content-data-cell">{{ (report.costs.capital.percentage * 100).toFixed(2) }}</div>
</div>
<div class="report-content-row">
<div class="report-content-row-highlight">Total</div>
<div class="report-content-data-cell report-content-row-highlight">{{
report.costs.total.total.toFixed(2)
}}
</div>
<div class="report-content-data-cell report-content-row-highlight">
{{ (report.costs.total.percentage * 100).toFixed(2) }}
</div>
</div>
</div>
</collapsible-box>
</div>
<!-- all time high/low container rate-->
<div class="box-gap">
<collapsible-box :is-collapsable="true" variant="border" title="Transport costs fluctuations"
:initially-collapsed="true"
:stretch-content="true">
<div class="box-gap" :key="premise.id" v-for="(premise, idx) in report.premises">
<div class="report-content-container--3-col-2">
<collapsible-box class="report-content-container" variant="border" :title="premise.destination.name"
:stretch-content="true" :initially-collapsed="true">
<div>
<report-route :sections="premise.sections" :destination="premise.destination"
:route-section-scale="routeSectionScale[idx]"></report-route>
<div class="report-content-row">
<div></div>
<div class="report-content-data-header-cell">total [&euro;]</div>
<div class="report-content-data-header-cell">of MEK B [%]</div>
</div>
<div class="report-sub-header">General</div>
<div class="report-content-row">
<div class="">Current scenario</div>
<div class="report-content-data-cell">{{
report.overview.mek_b.total.toFixed(2)
}}
</div>
<div class="report-content-data-cell">{{
`${(report.overview.mek_b.percentage * 100).toFixed(2)}`
}}
</div>
</div>
<div class="report-content-row">
<div>Opportunity scenario</div>
<div class="report-content-data-cell">{{ report.overview.opportunity_scenario.total.toFixed(2) }} </div>
<div class="report-content-data-cell">{{
`${(report.overview.opportunity_scenario.percentage * 100).toFixed(2)}`
}}
</div>
</div>
<div class="report-content-row">
<div>Risk scenario</div>
<div class="report-content-data-cell">{{ report.overview.risk_scenario.total.toFixed(2) }} </div>
<div class="report-content-data-cell">
{{ `${(report.overview.risk_scenario.percentage * 100).toFixed(2)}` }}
</div>
</div>
</div>
</collapsible-box>
</div>
<!-- material and handling unit-->
<div class="box-gap">
<collapsible-box :is-collapsable="false" variant="border" title="Material" size="m"
:stretch-content="true">
<div class="report-content-container--2-col">
<div class="report-content-row">
<div>Annual Quantity</div>
<div class="report-content-data-cell">{{ premise.annual_quantity }}</div>
<div>Part number</div>
<div class="report-content-data-cell"> {{ report.material.part_number }}</div>
</div>
<div class="report-content-row">
<div>HS code</div>
<div class="report-content-data-cell">{{ premise.hs_code }}</div>
<div class="report-content-data-cell"> {{ report.premises.hs_code }}</div>
</div>
<div class="report-content-row">
<div>Tariff rate</div>
<div class="report-content-data-cell">{{ (premise.tariff_rate * 100).toFixed(2) }}%</div>
<div class="report-content-data-cell"> {{ (report.premises.tariff_rate * 100).toFixed(2) }}%</div>
</div>
<div class="report-content-row">
<div>Oversea share</div>
<div class="report-content-data-cell">{{ (premise.oversea_share * 100).toFixed(2) }}%</div>
<div class="report-content-data-cell">{{ (report.premises.oversea_share * 100).toFixed(2) }}%</div>
</div>
<div class="report-content-row" v-if="(premise.air_freight_share ?? null) !== null">
<div class="report-content-row" v-if="(report.premises.air_freight_share ?? null) !== null">
<div>Airfreight share</div>
<div class="report-content-data-cell">{{ (premise.air_freight_share * 100).toFixed(2) }}%</div>
<div class="report-content-data-cell">{{ (report.premises.air_freight_share * 100).toFixed(2) }}%</div>
</div>
<div class="report-content-row">
<div>Transit time [days]</div>
<div class="report-content-data-cell">{{ premise.transport_time }}</div>
</div>
<div class="report-content-row">
<div>Safety stock [w-days]</div>
<div class="report-content-data-cell">{{ premise.safety_stock }}</div>
<div class="report-content-data-cell">{{ report.premises.safety_stock }}</div>
</div>
</div>
@ -202,59 +262,101 @@
<div class="report-content-container--2-col">
<div class="report-content-row">
<div>Dimensions [{{ premise.dimension_unit }}]</div>
<div class="report-content-data-cell">{{ toFixedDimension(premise.length, premise.dimension_unit) }} x
{{ toFixedDimension(premise.width, premise.dimension_unit) }} x
{{ toFixedDimension(premise.height, premise.dimension_unit) }}
<div>Dimensions [{{ report.premises.dimension_unit }}]</div>
<div class="report-content-data-cell">{{
toFixedDimension(report.premises.length, report.premises.dimension_unit)
}} x
{{ toFixedDimension(report.premises.width, report.premises.dimension_unit) }} x
{{ toFixedDimension(report.premises.height, report.premises.dimension_unit) }}
</div>
</div>
<div class="report-content-row">
<div>Weight [{{ premise.weight_unit }}]</div>
<div class="report-content-data-cell">{{ toFixedWeight(premise.weight, premise.weight_unit) }}</div>
<div>Weight [{{ report.premises.weight_unit }}]</div>
<div class="report-content-data-cell">{{
toFixedWeight(report.premises.weight, report.premises.weight_unit)
}}
</div>
</div>
<div class="report-content-row">
<div>Unit count</div>
<div class="report-content-data-cell">{{ premise.hu_unit_count }}</div>
<div class="report-content-data-cell">{{ report.premises.hu_unit_count }}</div>
</div>
<div class="report-content-row">
<div>Mixed transport</div>
<div class="report-content-data-cell">{{ premise.mixed ? 'Yes' : 'No' }}</div>
<div class="report-content-data-cell">{{ report.premises.mixable ? 'Yes' : 'No' }}</div>
</div>
</div>
</collapsible-box>
</div>
<!-- destinations -->
<div class="box-gap" :key="destination.id" v-for="(destination, idx) in report.destinations">
<collapsible-box class="report-content-container" variant="border" :title="destination.destination.name"
:stretch-content="true" :initially-collapsed="true">
<div>
<report-route :sections="destination.sections" :destination="destination.destination"
:route-section-scale="routeSectionScale[idx]"></report-route>
<div class="report-sub-header">General</div>
<div class="report-content-container--2-col">
<div class="report-content-row">
<div>Annual Quantity</div>
<div class="report-content-data-cell">{{ destination.annual_quantity }}</div>
</div>
<div class="report-content-row">
<div>Transit time [days]</div>
<div class="report-content-data-cell">{{ destination.transport_time }}</div>
</div>
</div>
<div class="report-sub-header">Container</div>
<div class="report-content-container--2-col">
<div class="report-content-row">
<div>Stacked layers</div>
<div class="report-content-data-cell">{{ hasMainRun(premise.sections) ? premise.layer : '-' }}</div>
<div class="report-content-data-cell">{{
hasMainRunOrD2D(destination.sections) ? destination.layer : '-'
}}
</div>
</div>
<div class="report-content-row">
<div>Container unit count</div>
<div class="report-content-data-cell">
{{ hasMainRun(premise.sections) ? (premise.unit_count * premise.hu_unit_count) : '-' }}
{{
hasMainRunOrD2D(destination.sections) ? (destination.unit_count * report.premises.hu_unit_count) : '-'
}}
</div>
</div>
<div class="report-content-row">
<div>Container type</div>
<div class="report-content-data-cell">
{{ hasMainRun(premise.sections) ? getContainerTypeName(premise.container_type) : '-' }}
{{ hasMainRunOrD2D(destination.sections) ? getContainerTypeName(destination.container_type) : '-' }}
</div>
</div>
<div class="report-content-row">
<div>Limiting factor</div>
<div class="report-content-data-cell">
{{ hasMainRun(premise.sections) ? premise.weight_exceeded ? 'Weight' : 'Volume' : '-' }}
{{ hasMainRunOrD2D(destination.sections) ? destination.weight_exceeded ? 'Weight' : 'Volume' : '-' }}
</div>
</div>
@ -293,8 +395,8 @@ export default {
}
},
methods: {
hasMainRun(sections) {
return sections.some(section => section.transport_type === 'SEA' || section.transport_type === 'RAIL');
hasMainRunOrD2D(sections) {
return sections.some(section => section.transport_type === 'SEA' || section.transport_type === 'RAIL' || section.rate_type === 'D2D');
},
shorten(text, length) {
if (text !== null && text !== undefined && text.length > length) {
@ -390,6 +492,14 @@ export default {
font-size: 1.2rem;
}
.report-content-container--3-col-2 {
display: grid;
grid-template-columns: 5fr 3fr 3fr;
gap: 1rem;
margin-top: 1.6rem;
font-size: 1.2rem;
}
.report-content-row {
display: contents;
color: #6B869C;

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">
<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">
<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);
},
@ -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">
<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,
@ -210,7 +225,7 @@ export default {
modalType: null,
modalProps: null,
editIds: null,
processingMessage: "Please wait. Calculating ...",
processingMessage: "Please wait. Processing ...",
showCalculationModal: false,
isInitialLoad: true,
modalDialogShow: false,
@ -267,6 +282,9 @@ export default {
return this.premiseEditStore.showProcessingModal || this.destinationEditStore.showProcessingModal;
},
shownProcessingMessage() {
if (this.premiseEditStore.showProcessingModal)
return "Please wait. Prepare calculation ..."
return this.processingMessage;
}
},
@ -295,6 +313,7 @@ export default {
window.removeEventListener('keyup', this.handleKeyUp);
},
methods: {
useHelpStore,
handleKeyDown(event) {
if (event.key === 'Control') {
this.isCtrlPressed = true;
@ -441,6 +460,7 @@ export default {
async closeEditModalAction(action) {
let massUpdate = false;
let success = true;
if (this.modalType === 'amount' || this.modalType === 'routes' || this.modalType === "destinations") {
@ -450,16 +470,19 @@ export default {
const setMatrix = this.$refs.modalComponent?.destMatrix;
if (setMatrix) {
await this.destinationEditStore.massSetDestinations(setMatrix);
success = await this.destinationEditStore.massSetDestinations(setMatrix);
}
} else {
massUpdate = true
}
}
if (success) {
// Clear data
this.fillData(this.modalType);
this.modalType = null;
} else return;
if (massUpdate) {
await this.destinationEditStore.massUpdateDestinations(this.editIds);
@ -487,6 +510,8 @@ export default {
},
fillData(type, id = -1, hideDescription = false) {
this.modalTitle = "Edit ".concat(type);
if (id === -1) {
if (type === 'price')
@ -523,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,
@ -735,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">
<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",
@ -147,7 +163,7 @@ export default {
if (this.premiseSingleEditStore.routing)
return "Please wait. Routing ..."
return "Please wait. Calculating ...";
return "Please wait. Prepare calculation ...";
}
},
watch: {
@ -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,9 +1,25 @@
<template>
<div>
<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>
<h3 class="sub-header">Drafts</h3>
<div class="calculation-list-container">
<the-calculation-search @execute-search="updateFilter"/>
@ -17,6 +33,7 @@
<div>Material</div>
<div>Supplier</div>
<div>Status</div>
<div>Created at</div>
<div>Action</div>
</div>
<transition name="list-container" mode="out-in">
@ -44,7 +61,6 @@
<list-edit :show="showListEdit" :select-count="premiseStore.selectedIds.length"
@action="handleMultiselectAction"></list-edit>
</div>
@ -68,10 +84,16 @@ import {UrlSafeBase64} from "@/common.js";
import Pagination from "@/components/UI/Pagination.vue";
import ModalDialog from "@/components/UI/ModalDialog.vue";
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",
components: {
Box,
TheDashboard,
ModalDialog,
Pagination,
ListEdit,
@ -79,9 +101,64 @@ export default {
CalculationListItem, Checkbox, NotificationBar, IconButton, BasicBadge, TheCalculationSearch, Flag
},
computed: {
...mapStores(usePremiseStore),
...mapStores(usePremiseStore, useActiveUserStore),
showListEdit() {
return this.premiseStore.globallySomeChecked;
},
greeting() {
const now = new Date();
const hour = now.getHours();
// Get day of year as seed for consistent random selection throughout the day
const dayOfYear = Math.floor((now - new Date(now.getFullYear(), 0, 0)) / 86400000);
let greetings = [];
// Morning: 5-12
if (hour >= 5 && hour < 12) {
greetings = [
`Good morning, ${this.username}`,
`Morning, ${this.username}!`,
`Good morning, ${this.username}. Ready to calculate?`,
`Morning ${this.username}, what's on the agenda today?`
];
}
// Afternoon: 12-18
else if (hour >= 12 && hour < 18) {
greetings = [
`Good afternoon, ${this.username}`,
`Hi ${this.username}, welcome back`,
`Afternoon, ${this.username}!`,
`Hello ${this.username}, let's continue`,
`Hi ${this.username}, ready to work?`
];
}
// Evening: 18-22
else if (hour >= 18 && hour < 22) {
greetings = [
`Good evening, ${this.username}`,
`Evening, ${this.username}!`,
`Hi ${this.username}, still working hard?`,
`Good evening, ${this.username}, almost done for today?`
];
}
// Night: 22-5
else {
greetings = [
`Working late, ${this.username}?`,
`Hi ${this.username}, burning the midnight oil?`,
`Hello ${this.username}`,
`Still here, ${this.username}?`,
`Hi ${this.username}, don't stay up too late`
];
}
// Use day of year as seed for consistent selection
const index = dayOfYear % greetings.length;
return greetings[index];
},
username() {
return this.activeUserStore.username;
}
},
data() {
@ -118,6 +195,7 @@ export default {
await this.executeSearch();
},
methods: {
useHelpStore,
async handleModalAction(action) {
if (action === 'dismiss') {
this.modal.state = false;
@ -293,6 +371,36 @@ 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;
font-size: 2.4rem;
color: #002F54;
}
.page-sub-header {
font-weight: normal;
font-size: 1.8rem;
color: #6B869C;
margin-bottom: 2.4rem;
}
.space-around {
margin: 3rem;
}
@ -372,7 +480,7 @@ export default {
.calculation-list-header {
display: grid;
grid-template-columns: 6rem 1fr 2fr 14rem 10rem;
grid-template-columns: 6rem 1fr 2fr 14rem 20rem 10rem;
gap: 1.6rem;
padding: 1.6rem;
background-color: #ffffff;

View file

@ -1,12 +1,26 @@
<template>
<div>
<div class="header-container">
<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,
@ -75,12 +91,12 @@ export default {
},
routeSectionScale() {
const reports = this.reportsStore.reports;
const scale = new Array(reports.map(r => r.premises.length).reduce((max, n) => Math.max(n, max), 0)).fill(0);
const scale = new Array(reports.map(r => r.destinations.length).reduce((max, n) => Math.max(n, max), 0)).fill(0);
for (let i = 0; i < scale.length; i++) {
for (const report of reports) {
if(report.premises.length > i) {
scale[i] = Math.max(scale[i], report.premises[i].sections.length);
if (report.destinations.length > i) {
scale[i] = Math.max(scale[i], report.destinations[i].sections.length);
}
}
}
@ -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

@ -62,6 +62,11 @@ export const useActiveUserStore = defineStore('activeUser', {
if (state.user === null)
return false;
return state.user.groups?.includes("super") || state.user.groups?.includes("freight");
},
username(state) {
if (state.user === null)
return null;
return state.user.firstname; //+ ' ' + state.user.lastname;
}
},

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

@ -43,7 +43,7 @@ export const useBulkOperationStore = defineStore('bulkOperation', {
useNotificationStore().addNotification({
title: 'Bulk operation',
message: 'All your bulk operations have been completed.',
type: 'success',
variant: 'info',
icon: 'stack',
})

View file

@ -0,0 +1,74 @@
import {defineStore} from 'pinia'
import performRequest from "@/backend.js";
import {config} from '@/config'
import {useNotificationStore} from "@/store/notification.js";
export const useDashboardStore = defineStore('dashboard', {
state: () => ({
stats: null,
pullInterval: 3000,
pullTimer: null,
}),
getters: {
completed(state) {
if (state.stats)
return state.stats.completed;
return null;
},
running(state) {
if (state.stats)
return state.stats.running;
return null;
},
drafts(state) {
if (state.stats)
return state.stats.drafts;
return null;
},
failed(state) {
if (state.stats)
return state.stats.failed;
return null;
}
},
actions: {
async load() {
const url = `${config.backendUrl}/dashboard`;
const resp = await performRequest(this, 'GET', url, null);
if(this.stats?.running && this.stats.running > 0 && resp.data.running === 0) {
useNotificationStore().addNotification({
title: 'Calculation',
message: 'All your calculations have been completed.',
variant: 'info',
icon: 'calculator',
})
}
this.stats = resp.data;
},
startPulling() {
if (this.pullTimer) return
this.pullTimer = setTimeout(async () => {
await this.pull()
}, this.pullInterval)
},
stopPulling() {
if (this.pullTimer) {
clearTimeout(this.pullTimer)
this.pullTimer = null
}
},
async pull() {
await this.load();
this.stopPulling();
this.startPulling();
}
}
});

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,6 +85,8 @@ export const useDestinationEditStore = defineStore('destinationEdit', {
async massSetDestinations(updateMatrix) {
this.loading = true;
try {
const toBeAdded = {};
const toBeDeletedMap = new Map();
@ -105,8 +109,20 @@ export const useDestinationEditStore = defineStore('destinationEdit', {
this.destinations.set(premiseId, [...filtered, ...data[premiseId]]);
});
} 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

@ -33,6 +33,9 @@ export const useNotificationStore = defineStore('notification', {
return this.notifications.pop();
},
addNotification(notification) {
console.log("add notification", notification, this.notifications.length)
this.notifications.push({
icon: notification.icon ?? null,
message: notification.message ?? 'Unknown notification',

View file

@ -16,10 +16,7 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
*/
loading: false,
/**
* set to true while the store sets the selected/deselected field in the premises.
*/
selectedLoading: false,
processing: false,
throwsException: true,
}
@ -113,7 +110,7 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
},
showProcessingModal(state) {
return state.processDestinationMassEdit;
return state.processing;
},
/**
@ -187,7 +184,9 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
},
async startCalculation() {
const body = this.premisses.map(p => p.id);
this.processing = true;
const body = {premise_ids: this.premisses.map(p => p.id)};
const url = `${config.backendUrl}/calculation/start/`;
let error = null;
@ -196,6 +195,7 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
error = e.errorObj;
})
this.processing = false;
return error;
},

View file

@ -69,7 +69,7 @@ export const usePremiseSingleEditStore = defineStore('premiseSingleEdit', {
async startCalculation() {
this.calculating = true;
const body = [this.premise?.id];
const body = {premise_ids:[this.premise?.id]};
const url = `${config.backendUrl}/calculation/start/`;
let error = null;

View file

@ -22,8 +22,8 @@ export const useReportsStore = defineStore('reports', {
let max = 0;
state.reports.forEach(report => {
max = Math.max(report.risk.mek_b.total, max);
max = Math.max(report.risk.risk_scenario.total, max);
max = Math.max(report.overview.mek_b.total, max);
max = Math.max(report.overview.risk_scenario.total, max);
})
const magnitude = Math.pow(10, Math.floor(Math.log10(max)));
@ -37,7 +37,7 @@ export const useReportsStore = defineStore('reports', {
return;
const params = new URLSearchParams();
params.append('material', this.materialId);
params.append('materials', [this.materialId]);
params.append('sources', this.supplierIds);
params.append('userSources', this.userSupplierIds);
@ -74,14 +74,14 @@ export const useReportsStore = defineStore('reports', {
this.showComparableWarning = false;
for (const [idx, report] of this.reports.entries()) {
for (const otherReport of this.reports.slice(idx + 1)) {
if (report.premises.length !== otherReport.premises.length) {
if (report.destinations.length !== otherReport.destinations.length) {
this.showComparableWarning = true;
break;
}
for (const premise of report.premises) {
for (const premise of report.destinations) {
const otherPremise = otherReport.premises.find(otherPremise => otherPremise.destination.external_mapping_id === premise.destination.external_mapping_id);
const otherPremise = otherReport.destinations.find(otherPremise => otherPremise.destination.external_mapping_id === premise.destination.external_mapping_id);
if((otherPremise ?? null) == null) {
this.showComparableWarning = true;

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

@ -1,33 +1,41 @@
package de.avatic.lcc.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.TaskExecutor;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;
@Configuration
@EnableAsync
@EnableScheduling
public class AsyncConfig {
@Bean(name = "calculationExecutor")
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(16);
executor.setMaxPoolSize(32);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("calc-");
executor.initialize();
return executor;
@Bean(name = "calculationJobScheduler")
public ThreadPoolTaskScheduler calculationJobScheduler(
@Value("${calculation.job.processor.pool-size:1}") int poolSize,
@Value("${calculation.job.processor.thread-name-prefix:calc-job-}") String threadNamePrefix) {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(poolSize);
scheduler.setThreadNamePrefix(threadNamePrefix);
scheduler.setWaitForTasksToCompleteOnShutdown(true);
scheduler.setAwaitTerminationSeconds(60);
scheduler.initialize();
return scheduler;
}
@Bean(name = "customLookupExecutor")
public Executor customLookupExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(16);
executor.setCorePoolSize(2);
executor.setMaxPoolSize(32);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("lookup-");

View file

@ -42,7 +42,7 @@ import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.security.web.csrf.CsrfTokenRequestHandler;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
import org.springframework.util.StringUtils;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
@ -111,7 +111,7 @@ public class SecurityConfig {
.exceptionHandling(ex -> ex
.defaultAuthenticationEntryPointFor(
new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
new AntPathRequestMatcher("/api/**")
PathPatternRequestMatcher.withDefaults().matcher("/api/**")
)
)
.csrf(csrf -> csrf

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

@ -133,7 +133,7 @@ public class GlobalExceptionHandler {
public ResponseEntity<ErrorResponseDTO> handlePremiseValidationException(PremiseValidationError exception) {
ErrorDTO error = new ErrorDTO(
exception.getClass().getName(),
"Validation error",
"Almost there - just need to fix a few things",
exception.getMessage(),
Arrays.asList(exception.getStackTrace())
);

View file

@ -0,0 +1,26 @@
package de.avatic.lcc.controller.calculation;
import de.avatic.lcc.dto.calculation.execution.CalculationProcessingOverviewDTO;
import de.avatic.lcc.service.calculation.execution.CalculationJobProcessorManagementService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/dashboard")
public class DashboardController {
private final CalculationJobProcessorManagementService calculationJobProcessorManagementService;
public DashboardController(CalculationJobProcessorManagementService calculationJobProcessorManagementService) {
this.calculationJobProcessorManagementService = calculationJobProcessorManagementService;
}
@GetMapping({"/", ""})
@PreAuthorize("hasAnyRole('SUPER', 'CALCULATION')")
public ResponseEntity<CalculationProcessingOverviewDTO> getDashboardData() {
return ResponseEntity.ok(calculationJobProcessorManagementService.getCalculationOverview());
}
}

View file

@ -1,7 +1,6 @@
package de.avatic.lcc.controller.calculation;
import de.avatic.lcc.dto.calculation.CalculationStatus;
import de.avatic.lcc.dto.calculation.DestinationDTO;
import de.avatic.lcc.dto.calculation.PremiseDTO;
import de.avatic.lcc.dto.calculation.ResolvePremiseDTO;
@ -15,10 +14,15 @@ import de.avatic.lcc.dto.calculation.edit.destination.DestinationUpdateDTO;
import de.avatic.lcc.dto.calculation.edit.masterData.MaterialUpdateDTO;
import de.avatic.lcc.dto.calculation.edit.masterData.PackagingUpdateDTO;
import de.avatic.lcc.dto.calculation.edit.masterData.PriceUpdateDTO;
import de.avatic.lcc.dto.calculation.execution.CalculationProcessingStateRequestDTO;
import de.avatic.lcc.dto.calculation.execution.CalculationProcessingStateResponseDTO;
import de.avatic.lcc.dto.calculation.execution.CalculationStartRequestDTO;
import de.avatic.lcc.dto.calculation.execution.CalculationStartResponseDTO;
import de.avatic.lcc.service.access.DestinationService;
import de.avatic.lcc.service.access.PremisesService;
import de.avatic.lcc.service.calculation.PremiseCreationService;
import de.avatic.lcc.service.calculation.PremiseSearchStringAnalyzerService;
import de.avatic.lcc.service.calculation.execution.CalculationJobProcessorManagementService;
import de.avatic.lcc.util.exception.badrequest.InvalidArgumentException;
import de.avatic.lcc.util.exception.base.BadRequestException;
import jakarta.validation.Valid;
@ -45,12 +49,14 @@ public class PremiseController {
private final PremisesService premisesServices;
private final PremiseCreationService premiseCreationService;
private final DestinationService destinationService;
private final CalculationJobProcessorManagementService calculationJobProcessorManagementService;
public PremiseController(PremiseSearchStringAnalyzerService premiseSearchStringAnalyzerService, PremisesService premisesServices, PremiseCreationService premiseCreationService, DestinationService destinationService) {
public PremiseController(PremiseSearchStringAnalyzerService premiseSearchStringAnalyzerService, PremisesService premisesServices, PremiseCreationService premiseCreationService, DestinationService destinationService, CalculationJobProcessorManagementService calculationJobProcessorManagementService) {
this.premiseSearchStringAnalyzerService = premiseSearchStringAnalyzerService;
this.premisesServices = premisesServices;
this.premiseCreationService = premiseCreationService;
this.destinationService = destinationService;
this.calculationJobProcessorManagementService = calculationJobProcessorManagementService;
}
@GetMapping({"/view", "/view/"})
@ -77,13 +83,13 @@ public class PremiseController {
public ResponseEntity<PremiseSearchResultDTO> findMaterialsAndSuppliers(@RequestParam String search) {
try {
// String decodedValue = URLDecoder.decode(search, StandardCharsets.UTF_8);
return ResponseEntity.ok(premiseSearchStringAnalyzerService.findMaterialAndSuppliers(search));
} catch (Exception e) {
throw new BadRequestException("Bad string encoding", "Unable to decode request", e);
}
}
@PostMapping({"/create", "/create/"})
@PreAuthorize("hasAnyRole('SUPER', 'CALCULATION')")
public ResponseEntity<List<PremiseDetailDTO>> createPremises(@RequestBody @Valid CreatePremiseDTO dto) {
@ -135,21 +141,16 @@ public class PremiseController {
@PutMapping({"/start", "/start/"})
@PreAuthorize("hasAnyRole('SUPER', 'CALCULATION')")
public ResponseEntity<Void> startCalculation(@RequestBody List<Integer> premiseIds) {
premisesServices.startCalculation(premiseIds);
return ResponseEntity.ok().build();
public ResponseEntity<CalculationStartResponseDTO> startCalculation(@RequestBody @Valid CalculationStartRequestDTO requestDTO) {
var response = calculationJobProcessorManagementService.startCalculation(requestDTO);
return ResponseEntity.ok(response);
}
/**
* Retrieves the current status of a specific calculation processing operation.
*
* @param id The unique identifier of the operation (processing_id) to check its status.
* @return A ResponseEntity with the bulk processing status payload.
*/
@GetMapping({"/status/{processing_id}", "/status/{processing_id}/"})
@PreAuthorize("hasAnyRole('SUPER', 'CALCULATION')")
public ResponseEntity<CalculationStatus> getCalculationStatus(@PathVariable("processing_id") Integer id) {
return ResponseEntity.ok(premisesServices.getCalculationStatus(id));
@GetMapping({"/status/", "/status"})
@PreAuthorize("hasAnyRole('SUPER', 'CALCULATION', 'BASIC')")
public ResponseEntity<CalculationProcessingStateResponseDTO> getCalculationStatus(@RequestBody CalculationProcessingStateRequestDTO requestDTO) {
return ResponseEntity.ok(calculationJobProcessorManagementService.getCalculationStatus(requestDTO));
}
@ -215,5 +216,4 @@ public class PremiseController {
}
}

View file

@ -2,8 +2,10 @@ 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;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@ -16,21 +18,35 @@ public class AppsController {
private final AppsService appsService;
public AppsController(AppsService appsService) {
this.appsService = appsService;
}
@GetMapping({"", "/"})
@PreAuthorize("hasRole('SERVICE')")
public ResponseEntity<List<AppDTO>> listApps() {
return ResponseEntity.ok(appsService.listApps());
}
@PostMapping({"", "/"})
@PreAuthorize("hasRole('SERVICE')")
public ResponseEntity<AppDTO> updateApp(@RequestBody AppDTO dto) {
return ResponseEntity.ok(appsService.updateApp(dto));
}
@GetMapping({"/export/{id}", "/export/{id}/"})
@PreAuthorize("hasRole('SERVICE')")
public ResponseEntity<AppExchangeDTO> exportApp(@PathVariable Integer id) {
return ResponseEntity.ok(appsService.exportApp(id));
}
@PostMapping({"/import/", "/import"})
@PreAuthorize("hasRole('SERVICE')")
public ResponseEntity<Boolean> importApp(@RequestBody AppExchangeDTO dto) {
return ResponseEntity.ok(appsService.importApp(dto));
}
@DeleteMapping({"/{id}", "/{id}/"})
@PreAuthorize("hasRole('SERVICE')")
public ResponseEntity<Void> deleteApp(@PathVariable Integer id) {
appsService.deleteApp(id);
return ResponseEntity.ok().build();

View file

@ -5,6 +5,7 @@ import de.avatic.lcc.repositories.error.DumpRepository;
import de.avatic.lcc.repositories.pagination.SearchQueryPagination;
import jakarta.validation.constraints.Min;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@ -20,11 +21,13 @@ public class CalculationDumpController {
}
@GetMapping({"/dump/{id}", "/dump/{id}/"})
@PreAuthorize("hasRole('SERVICE')")
public ResponseEntity<CalculationJobDumpDTO> getDump(@PathVariable Integer id) {
return ResponseEntity.ok(dumpRepository.getDump(id));
}
@GetMapping({"/dump/", "/dump"})
@PreAuthorize("hasRole('SERVICE')")
public ResponseEntity<List<CalculationJobDumpDTO>> listDumps(
@RequestParam(defaultValue = "20") @Min(1) int limit,
@RequestParam(defaultValue = "1") @Min(1) int page) {

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

@ -1,48 +0,0 @@
package de.avatic.lcc.controller.maps;
import com.azure.core.credential.AccessToken;
import com.azure.identity.DefaultAzureCredentialBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.OffsetDateTime;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/api/maps")
public class AzureMapsController {
@Value("${azure.maps.client.id}")
private String mapsClientId;
@Value("${azure.maps.subscription.key}")
private String mapsSubscriptionKey;
@GetMapping("/token")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<Map<String, Object>> getAzureMapsToken() {
try {
// Verwende die DefaultAzureCredential für die Authentifizierung
var credential = new DefaultAzureCredentialBuilder().build();
// Fordere ein Token für Azure Maps an
AccessToken token = credential.getToken(
new com.azure.core.credential.TokenRequestContext()
.addScopes("https://atlas.microsoft.com/.default")
).block();
Map<String, Object> response = new HashMap<>();
response.put("token", token.getToken());
response.put("expiresOn", token.getExpiresAt().toEpochSecond());
return ResponseEntity.ok(response);
} catch (Exception e) {
return ResponseEntity.internalServerError().build();
}
}
}

View file

@ -2,17 +2,17 @@ package de.avatic.lcc.controller.report;
import de.avatic.lcc.dto.generic.NodeDTO;
import de.avatic.lcc.dto.report.ReportDTO;
import de.avatic.lcc.dto.report.ReportSearchRequestDTO;
import de.avatic.lcc.service.report.ExcelReportingService;
import de.avatic.lcc.service.report.ReportingService;
import jakarta.validation.constraints.Min;
import org.apache.coyote.Response;
import org.springframework.core.io.InputStreamResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@ -24,7 +24,6 @@ import java.util.List;
@RequestMapping("/api/reports")
public class ReportingController {
//TODO: rollenbeschränkung
private final ReportingService reportingService;
private final ExcelReportingService excelReportingService;
@ -41,6 +40,19 @@ public class ReportingController {
this.excelReportingService = excelReportingService;
}
@GetMapping({"/", ""})
@PreAuthorize("hasAnyRole('SUPER', 'CALCULATION', 'BASIC')")
public ResponseEntity<String> listReports(@RequestBody(required = false) ReportSearchRequestDTO filter,
@RequestParam(defaultValue = "20") @Min(1) int limit,
@RequestParam(defaultValue = "1") @Min(1) int page) {
//TODO implement me
return ResponseEntity.ok().build();
}
/**
* Fetches a list of suppliers for reporting purposes, based on material ID.
*
@ -69,13 +81,13 @@ public class ReportingController {
/**
* Downloads an Excel report for the given material and source nodes.
*
* @param materialId The ID of the material for which the report will be downloaded.
* @param materialIds The IDs of the materials for which the report will be downloaded.
* @param nodeIds A list of node IDs (sources) to include in the downloaded report.
* @return The Excel file as an attachment in the response.
*/
@GetMapping({"/download", "/download/"})
@PreAuthorize("hasAnyRole('SUPER', 'CALCULATION', 'BASIC')")
public ResponseEntity<InputStreamResource> downloadReport(@RequestParam(value = "material") Integer materialId, @RequestParam(value = "sources", required = false) List<Integer> nodeIds, @RequestParam(value = "userSources", required = false) List<Integer> userNodeIds) {
public ResponseEntity<InputStreamResource> downloadReport(@RequestParam(value = "materials") List<Integer> materialIds, @RequestParam(value = "sources", required = false) List<Integer> nodeIds, @RequestParam(value = "userSources", required = false) List<Integer> userNodeIds) {
HttpHeaders headers = new HttpHeaders();
headers.add("Content-Disposition", "attachment; filename=lcc_report.xlsx");
@ -84,6 +96,6 @@ public class ReportingController {
.ok()
.headers(headers)
.contentType(MediaType.parseMediaType("application/vnd.ms-excel"))
.body(new InputStreamResource(excelReportingService.generateExcelReport(materialId, nodeIds, userNodeIds)));
.body(new InputStreamResource(excelReportingService.generateExcelReport(materialIds, nodeIds, userNodeIds)));
}
}

View file

@ -9,7 +9,6 @@ public enum BulkFileType {
this.fileType = fileType;
}
public String getFileType() {
return fileType;
}

View file

@ -1,14 +0,0 @@
package de.avatic.lcc.dto.calculation;
import de.avatic.lcc.model.db.calculations.CalculationJobState;
import java.util.Map;
public class CalculationStatus {
private Integer processId;
private Map<Integer, CalculationJobState> jobStates;
private Map<Integer, String> exceptionMessages;
}

View file

@ -1,9 +1,13 @@
package de.avatic.lcc.dto.calculation;
import com.fasterxml.jackson.annotation.JsonProperty;
import de.avatic.lcc.dto.generic.MaterialDTO;
import de.avatic.lcc.dto.generic.NodeDTO;
import de.avatic.lcc.model.db.calculations.CalculationJobState;
import de.avatic.lcc.model.db.users.User;
import java.time.LocalDateTime;
public class PremiseDTO {
private int id;
@ -14,8 +18,11 @@ public class PremiseDTO {
private PremiseState state;
//TODO premise calculation result here
// add owner information here (for superuser)
@JsonProperty("calculation_state")
private CalculationJobState calculationStatus;
@JsonProperty("created_at")
private LocalDateTime createdAt;
private User owner;
@ -58,4 +65,20 @@ public class PremiseDTO {
public void setState(PremiseState state) {
this.state = state;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public CalculationJobState getCalculationStatus() {
return calculationStatus;
}
public void setCalculationStatus(CalculationJobState calculationStatus) {
this.calculationStatus = calculationStatus;
}
}

View file

@ -1,8 +0,0 @@
package de.avatic.lcc.dto.calculation;
public class TransitNodeDTO {
private Integer id;
}

View file

@ -0,0 +1,45 @@
package de.avatic.lcc.dto.calculation.execution;
public class CalculationProcessingOverviewDTO {
private Integer completed;
private Integer running;
private Integer drafts;
private Integer failed;
public Integer getCompleted() {
return completed;
}
public void setCompleted(Integer completed) {
this.completed = completed;
}
public Integer getRunning() {
return running;
}
public void setRunning(Integer running) {
this.running = running;
}
public Integer getDrafts() {
return drafts;
}
public void setDrafts(Integer drafts) {
this.drafts = drafts;
}
public Integer getFailed() {
return failed;
}
public void setFailed(Integer failed) {
this.failed = failed;
}
}

View file

@ -0,0 +1,19 @@
package de.avatic.lcc.dto.calculation.execution;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
public class CalculationProcessingStateRequestDTO {
@JsonProperty("calculation_ids")
private List<Integer> calculationIds;
public List<Integer> getCalculationIds() {
return calculationIds;
}
public void setCalculationIds(List<Integer> calculationIds) {
this.calculationIds = calculationIds;
}
}

View file

@ -0,0 +1,16 @@
package de.avatic.lcc.dto.calculation.execution;
import java.util.Map;
public class CalculationProcessingStateResponseDTO {
Map<Integer, String> states;
public Map<Integer, String> getStates() {
return states;
}
public void setStates(Map<Integer, String> states) {
this.states = states;
}
}

View file

@ -0,0 +1,33 @@
package de.avatic.lcc.dto.calculation.execution;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.LocalDate;
import java.util.List;
public class CalculationStartRequestDTO {
@JsonProperty("premise_ids")
@Size(min = 1)
List<@NotNull Integer> premiseIds;
LocalDate date;
public List<Integer> getPremiseIds() {
return premiseIds;
}
public void setPremiseIds(List<Integer> premiseIds) {
this.premiseIds = premiseIds;
}
public LocalDate getDate() {
return date;
}
public void setDate(LocalDate date) {
this.date = date;
}
}

View file

@ -0,0 +1,16 @@
package de.avatic.lcc.dto.calculation.execution;
import java.util.List;
public class CalculationStartResponseDTO {
private List<Integer> calculationIds;
public List<Integer> getCalculationIds() {
return calculationIds;
}
public void setCalculationIds(List<Integer> calculationIds) {
this.calculationIds = calculationIds;
}
}

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

@ -1,10 +1,10 @@
package de.avatic.lcc.dto.generic;
public enum ContainerType {
FEU(12030, 2350, 2390, 67.7, 24,21),
TEU(5890 ,2350,2390, 33.0, 11,10),
HC(12030, 2350, 2690, 76.4, 24,21),
TRUCK(13600,2450, 2650, 88.3, 34, 33);
FEU(12030, 2350, 2390, 67.7, 24,21, "40' GP"),
TEU(5890 ,2350,2390, 33.0, 11,10, "20' GP"),
HC(12030, 2350, 2690, 76.4, 24,21,"40' HC"),
TRUCK(13600,2450, 2650, 88.3, 34, 33, "Truck");
private final int length;
private final int width;
@ -12,14 +12,16 @@ public enum ContainerType {
private final double volume;
private final int euroPalletCount;
private final int industrialPalletCount;
private final String description;
ContainerType(int length, int width, int height, double volume, int euroPalletCount, int industrialPalletCount) {
ContainerType(int length, int width, int height, double volume, int euroPalletCount, int industrialPalletCount, String description) {
this.length = length;
this.width = width;
this.height = height;
this.volume = volume;
this.euroPalletCount = euroPalletCount;
this.industrialPalletCount = industrialPalletCount;
this.description = description;
}
public int getLength() {
@ -40,4 +42,8 @@ public enum ContainerType {
return palletType == PalletType.EURO_PALLET ? euroPalletCount : industrialPalletCount;
}
public String getDescription() {
return description;
}
}

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

@ -23,10 +23,13 @@ public class ReportDTO {
@JsonProperty("costs")
public Map<String, ReportEntryDTO> cost;
@JsonProperty("risk")
public Map<String, ReportEntryDTO> risk;
@JsonProperty("overview")
public Map<String, ReportEntryDTO> overview;
@JsonProperty("premises")
private ReportPremisesDTO premises;
@JsonProperty("destinations")
public List<ReportDestinationDTO> destinations;
public NodeDTO getSupplier() {
@ -49,12 +52,12 @@ public class ReportDTO {
this.cost = cost;
}
public Map<String, ReportEntryDTO> getRisk() {
return risk;
public Map<String, ReportEntryDTO> getOverview() {
return overview;
}
public void setRisk(Map<String, ReportEntryDTO> risk) {
this.risk = risk;
public void setOverview(Map<String, ReportEntryDTO> overview) {
this.overview = overview;
}
public List<ReportDestinationDTO> getDestinations() {
@ -88,4 +91,14 @@ public class ReportDTO {
public void setEndDate(LocalDateTime endDate) {
this.endDate = endDate;
}
public ReportPremisesDTO getPremises() {
return premises;
}
public void setPremises(ReportPremisesDTO premises) {
this.premises = premises;
}
}

View file

@ -17,50 +17,18 @@ public class ReportDestinationDTO {
/* general */
@JsonProperty("annual_quantity")
private Integer annualQuantity;
@JsonProperty("hs_code")
private String hsCode;
@JsonProperty("tariff_rate")
private Number tariffRate;
@JsonProperty("oversea_share")
private Double overseaShare;
@JsonProperty("air_freight_share")
private Double airFreightShare;
@JsonProperty("transport_time")
private Double transportTime;
@JsonProperty("safety_stock")
private Integer safetyStock;
/* packaging */
@JsonProperty("annual_quantity")
private Integer annualQuantity;
private Double width;
private Double height;
private Double length;
private Double weight;
@JsonProperty("dimension_unit")
private DimensionUnit dimensionUnit;
@JsonProperty("weight_unit")
private WeightUnit weightUnit;
@JsonProperty("hu_unit_count")
private Integer huUnitCount;
private Integer layer;
/* container */
private Integer layer;
@JsonProperty("unit_count")
private Number unitCount;
@ -75,8 +43,6 @@ public class ReportDestinationDTO {
@JsonProperty("container_rate")
private Number rate;
private Boolean mixed;
public Integer getId() {
return id;
@ -110,110 +76,6 @@ public class ReportDestinationDTO {
this.annualQuantity = annualQuantity;
}
public String getHsCode() {
return hsCode;
}
public void setHsCode(String hsCode) {
this.hsCode = hsCode;
}
public Number getTariffRate() {
return tariffRate;
}
public void setTariffRate(Number tariffRate) {
this.tariffRate = tariffRate;
}
public Double getOverseaShare() {
return overseaShare;
}
public void setOverseaShare(Double overseaShare) {
this.overseaShare = overseaShare;
}
public Double getAirFreightShare() {
return airFreightShare;
}
public void setAirFreightShare(Double airFreightShare) {
this.airFreightShare = airFreightShare;
}
public Double getTransportTime() {
return transportTime;
}
public void setTransportTime(Double transportTime) {
this.transportTime = transportTime;
}
public Integer getSafetyStock() {
return safetyStock;
}
public void setSafetyStock(Integer safetyStock) {
this.safetyStock = safetyStock;
}
public Double getWidth() {
return width;
}
public void setWidth(Double width) {
this.width = width;
}
public Double getHeight() {
return height;
}
public void setHeight(Double height) {
this.height = height;
}
public Double getLength() {
return length;
}
public void setLength(Double length) {
this.length = length;
}
public Double getWeight() {
return weight;
}
public void setWeight(Double weight) {
this.weight = weight;
}
public DimensionUnit getDimensionUnit() {
return dimensionUnit;
}
public void setDimensionUnit(DimensionUnit dimensionUnit) {
this.dimensionUnit = dimensionUnit;
}
public WeightUnit getWeightUnit() {
return weightUnit;
}
public void setWeightUnit(WeightUnit weightUnit) {
this.weightUnit = weightUnit;
}
public Integer getHuUnitCount() {
return huUnitCount;
}
public void setHuUnitCount(Integer huUnitCount) {
this.huUnitCount = huUnitCount;
}
public Integer getLayer() {
return layer;
}
@ -262,11 +124,11 @@ public class ReportDestinationDTO {
this.rate = rate;
}
public Boolean getMixed() {
return mixed;
public Double getTransportTime() {
return transportTime;
}
public void setMixed(Boolean mixed) {
this.mixed = mixed;
public void setTransportTime(Double transportTime) {
this.transportTime = transportTime;
}
}

View file

@ -0,0 +1,155 @@
package de.avatic.lcc.dto.report;
import com.fasterxml.jackson.annotation.JsonProperty;
import de.avatic.lcc.model.db.utils.DimensionUnit;
import de.avatic.lcc.model.db.utils.WeightUnit;
public class ReportPremisesDTO {
@JsonProperty("hs_code")
private String hsCode;
@JsonProperty("tariff_rate")
private Number tariffRate;
@JsonProperty("oversea_share")
private Number overseaShare;
@JsonProperty("air_freight_share")
private Number airFreightShare;
@JsonProperty("safety_stock")
private Number safetyStock;
/* packaging */
private Double width;
private Double height;
private Double length;
private Double weight;
@JsonProperty("dimension_unit")
private DimensionUnit dimensionUnit;
@JsonProperty("weight_unit")
private WeightUnit weightUnit;
@JsonProperty("hu_unit_count")
private Integer huUnitCount;
@JsonProperty("mixable")
private Boolean mixable;
public String getHsCode() {
return hsCode;
}
public void setHsCode(String hsCode) {
this.hsCode = hsCode;
}
public Number getTariffRate() {
return tariffRate;
}
public void setTariffRate(Number tariffRate) {
this.tariffRate = tariffRate;
}
public Double getWidth() {
return width;
}
public void setWidth(Double width) {
this.width = width;
}
public Double getHeight() {
return height;
}
public void setHeight(Double height) {
this.height = height;
}
public Double getLength() {
return length;
}
public void setLength(Double length) {
this.length = length;
}
public Double getWeight() {
return weight;
}
public void setWeight(Double weight) {
this.weight = weight;
}
public DimensionUnit getDimensionUnit() {
return dimensionUnit;
}
public void setDimensionUnit(DimensionUnit dimensionUnit) {
this.dimensionUnit = dimensionUnit;
}
public WeightUnit getWeightUnit() {
return weightUnit;
}
public void setWeightUnit(WeightUnit weightUnit) {
this.weightUnit = weightUnit;
}
public Integer getHuUnitCount() {
return huUnitCount;
}
public void setHuUnitCount(Integer huUnitCount) {
this.huUnitCount = huUnitCount;
}
public Number getOverseaShare() {
return overseaShare;
}
public void setOverseaShare(Number overseaShare) {
this.overseaShare = overseaShare;
}
public Number getAirFreightShare() {
return airFreightShare;
}
public void setAirFreightShare(Number airFreightShare) {
this.airFreightShare = airFreightShare;
}
public Number getSafetyStock() {
return safetyStock;
}
public void setSafetyStock(Number safetyStock) {
this.safetyStock = safetyStock;
}
public Boolean getMixable() {
return mixable;
}
public void setMixable(Boolean mixable) {
this.mixable = mixable;
}
}

View file

@ -0,0 +1,13 @@
package de.avatic.lcc.dto.report;
import java.util.List;
public class ReportSearchRequestDTO {
List<Integer> supplierIds;
List<Integer> materialIds;
}

View file

@ -3,4 +3,14 @@ package de.avatic.lcc.model.bulk.header;
public interface HeaderProvider {
String getHeader();
default int occupiedCells() {
return 1;
}
default int getColumn() { throw new UnsupportedOperationException();}
default boolean useOrdinal() {
return true;
}
}

View file

@ -17,7 +17,7 @@ public enum PackagingHeader implements HeaderProvider {
HU_DIMENSION_UNIT("HU Dimension unit"),
HU_WEIGHT("HU gross weight"),
HU_WEIGHT_UNIT("HU gross weight unit"),
HU_UNIT_COUNT("Units/HU [pieces]");
HU_UNIT_COUNT("SHU Units/HU [SHU pieces]");
private final String header;

View file

@ -0,0 +1,49 @@
package de.avatic.lcc.model.bulk.header;
public enum ReportSummaryHeader implements HeaderProvider{
MATERIAL("Material", 0, 1),
SUPPLIER("Supplier", 1, 1),
MEK_A("MEK A", 2, 2),
LOGISTICS_COST("Logistics costs", 4, 2),
MEK_B("MEK B", 6, 2),
TRANSPORT("Transport", 8, 1),
HANDLING("Handling", 9, 1),
STORAGE("Storage", 10, 1),
REPACKAGING("Repackaging", 11, 1),
DISPOSAL("Disposal", 12, 1),
CAPITAL("Capital", 13, 1),
CUSTOM("Custom", 14, 1),
FCA_FEE("FCA Fee", 15, 1),
AIR_FREIGHT("Air freight", 16, 1);
private final int occupiedCells;
private final String header;
private final int column;
ReportSummaryHeader(String header, int column, int occupiedCells) {
this.occupiedCells = occupiedCells;
this.header = header;
this.column = column;
}
@Override
public boolean useOrdinal() {
return false;
}
@Override
public String getHeader() {
return header;
}
@Override
public int occupiedCells() {
return occupiedCells;
}
@Override
public int getColumn() {
return column;
}
}

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

@ -20,6 +20,35 @@ public class CalculationJob {
private Integer userId;
private CalculationJobPriority priority;
private Integer retries;
private Integer errorId;
public Integer getErrorId() {
return errorId;
}
public void setErrorId(Integer errorId) {
this.errorId = errorId;
}
public CalculationJobPriority getPriority() {
return priority;
}
public void setPriority(CalculationJobPriority priority) {
this.priority = priority;
}
public Integer getRetries() {
return retries;
}
public void setRetries(Integer retries) {
this.retries = retries;
}
public Integer getId() {
return id;

View file

@ -0,0 +1,5 @@
package de.avatic.lcc.model.db.calculations;
public enum CalculationJobPriority {
LOW, MEDIUM, HIGH
}

View file

@ -40,6 +40,7 @@ public class Distance {
private DistanceMatrixState state;
private Integer retries;
private Integer fromNodeId;
@ -144,4 +145,12 @@ public class Distance {
public void setToUserNodeId(Integer toUserNodeId) {
this.toUserNodeId = toUserNodeId;
}
public Integer getRetries() {
return retries;
}
public void setRetries(Integer retries) {
this.retries = retries;
}
}

View file

@ -1,5 +1,5 @@
package de.avatic.lcc.model.db.nodes;
public enum DistanceMatrixState {
VALID, STALE
VALID, STALE, EXCEPTION
}

View file

@ -31,6 +31,8 @@ public class Destination {
private Integer countryId;
private BigDecimal distanceD2d;
public Integer getLeadTimeD2d() {
return leadTimeD2d;
}
@ -134,4 +136,12 @@ public class Destination {
public void setDisposalCost(BigDecimal disposalCost) {
this.disposalCost = disposalCost;
}
public BigDecimal getDistanceD2d() {
return distanceD2d;
}
public void setDistanceD2d(BigDecimal distanceD2d) {
this.distanceD2d = distanceD2d;
}
}

View file

@ -27,6 +27,8 @@ public class RouteSection {
private Integer toRouteNodeId;
private Double distance;
public RateType getRateType() {
return rateType;
}
@ -114,4 +116,12 @@ public class RouteSection {
public void setToRouteNodeId(Integer toRouteNodeId) {
this.toRouteNodeId = toRouteNodeId;
}
public Double getDistance() {
return distance;
}
public void setDistance(Double distance) {
this.distance = distance;
}
}

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

@ -29,10 +29,10 @@ public class DistanceMatrixRepository {
String fromCol = isUsrFrom ? "from_user_node_id" : "from_node_id";
String toCol = isUsrTo ? "to_user_node_id" : "to_node_id";
String query = "SELECT * FROM distance_matrix WHERE " + fromCol + " = ? AND " + toCol + " = ? AND state = ?";
String query = "SELECT * FROM distance_matrix WHERE " + fromCol + " = ? AND " + toCol + " = ?";
var distance = jdbcTemplate.query(query, new DistanceMapper(),
src.getId(), dest.getId(), DistanceMatrixState.VALID.name());
src.getId(), dest.getId());
if (distance.isEmpty())
return Optional.empty();
@ -40,6 +40,12 @@ public class DistanceMatrixRepository {
return Optional.of(distance.getFirst());
}
@Transactional
public void updateRetries(Integer id) {
String query = "UPDATE distance_matrix SET retries = retries + 1 WHERE id = ?";
jdbcTemplate.update(query, id);
}
@Transactional
public void saveDistance(Distance distance) {
try {
@ -82,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 = """
@ -105,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;
}
}
@ -119,10 +128,20 @@ public class DistanceMatrixRepository {
public Distance mapRow(ResultSet rs, int rowNum) throws SQLException {
Distance entity = new Distance();
entity.setFromNodeId(rs.getInt("from_node_id"));
entity.setToNodeId(rs.getInt("to_node_id"));
entity.setFromNodeId(rs.getInt("from_user_node_id"));
entity.setToNodeId(rs.getInt("to_user_node_id"));
entity.setId(rs.getInt("id"));
var fromNodeId = rs.getInt("from_node_id");
entity.setFromNodeId(rs.wasNull() ? null : fromNodeId);
var toNodeId = rs.getInt("to_node_id");
entity.setToNodeId(rs.wasNull() ? null : toNodeId);
var fromUserNodeId = rs.getInt("from_user_node_id");
entity.setFromUserNodeId(rs.wasNull() ? null : fromUserNodeId);
var toUserNodeId = rs.getInt("to_user_node_id");
entity.setToUserNodeId(rs.wasNull() ? null : toUserNodeId);
entity.setDistance(rs.getBigDecimal("distance"));
entity.setFromGeoLng(rs.getBigDecimal("from_geo_lng"));
entity.setFromGeoLat(rs.getBigDecimal("from_geo_lat"));
@ -131,6 +150,9 @@ public class DistanceMatrixRepository {
entity.setState(DistanceMatrixState.valueOf(rs.getString("state")));
entity.setUpdatedAt(rs.getTimestamp("updated_at").toLocalDateTime());
var retries = rs.getInt("retries");
entity.setRetries(rs.wasNull() ? 0 : retries);
return entity;
}
}

View file

@ -75,6 +75,7 @@ public class MaterialRepository {
return jdbcTemplate.query(query, new MaterialMapper(), params.toArray());
}
@Transactional
public Optional<Material> getByPartNumber(String partNumber) {
if (partNumber == null) {
return Optional.empty();

View file

@ -5,6 +5,7 @@ import de.avatic.lcc.model.db.ValidityTuple;
import de.avatic.lcc.model.db.nodes.Node;
import de.avatic.lcc.repositories.pagination.SearchQueryPagination;
import de.avatic.lcc.repositories.pagination.SearchQueryResult;
import de.avatic.lcc.util.exception.internalerror.DatabaseException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
@ -105,8 +106,8 @@ public class NodeRepository {
entities = jdbcTemplate.query(query, new NodeMapper(), pagination.getLimit(), pagination.getOffset());
totalCount = jdbcTemplate.queryForObject(countQuery, Integer.class);
} else {
entities = jdbcTemplate.query(query, new NodeMapper(), "%" + filter + "%", "%" + filter + "%", "%" + filter + "%", pagination.getLimit(), pagination.getOffset());
totalCount = jdbcTemplate.queryForObject(countQuery, Integer.class, "%" + filter + "%", "%" + filter + "%", "%" + filter + "%");
entities = jdbcTemplate.query(query, new NodeMapper(), "%" + filter + "%", "%" + filter + "%", "%" + filter + "%", "%" + filter + "%", pagination.getLimit(), pagination.getOffset());
totalCount = jdbcTemplate.queryForObject(countQuery, Integer.class, "%" + filter + "%", "%" + filter + "%", "%" + filter + "%", "%" + filter + "%");
}
@ -124,7 +125,7 @@ public class NodeRepository {
queryBuilder.append(" AND node.is_deprecated = FALSE");
}
if (filter != null) {
queryBuilder.append(" AND (node.name LIKE ? OR node.address LIKE ? OR country.iso_code LIKE ?)");
queryBuilder.append(" AND (node.name LIKE ? OR node.external_mapping_id LIKE ? OR node.address LIKE ? OR country.iso_code LIKE ?)");
}
return queryBuilder.toString();
@ -142,7 +143,7 @@ public class NodeRepository {
queryBuilder.append(" AND node.is_deprecated = FALSE");
}
if (filter != null) {
queryBuilder.append(" AND (node.name LIKE ? OR node.address LIKE ? OR country.iso_code LIKE ?)");
queryBuilder.append(" AND (node.name LIKE ? OR node.external_mapping_id LIKE ? OR node.address LIKE ? OR country.iso_code LIKE ?)");
}
queryBuilder.append(" ORDER BY node.id LIMIT ? OFFSET ?");
return queryBuilder.toString();
@ -165,6 +166,9 @@ public class NodeRepository {
return Optional.empty();
}
if(node.isUserNode())
throw new DatabaseException("Cannot update user node in node repository.");
String updateNodeSql = """
UPDATE node SET
country_id = ?,
@ -389,6 +393,25 @@ public class NodeRepository {
@Transactional
public List<Node> getByDistance(Node node, Integer regionRadius) {
if(node.isUserNode()) {
String query = """
SELECT * FROM node
WHERE is_deprecated = FALSE AND
(
6371 * acos(
cos(radians(?)) *
cos(radians(geo_lat)) *
cos(radians(geo_lng) - radians(?)) +
sin(radians(?)) *
sin(radians(geo_lat))
)
) <= ?
""";
return jdbcTemplate.query(query, new NodeMapper(), node.getGeoLat(), node.getGeoLng(), node.getGeoLat(), regionRadius);
}
String query = """
SELECT * FROM node
WHERE is_deprecated = FALSE AND id != ? AND

View file

@ -1,6 +1,7 @@
package de.avatic.lcc.repositories.calculation;
import de.avatic.lcc.model.db.calculations.CalculationJob;
import de.avatic.lcc.model.db.calculations.CalculationJobPriority;
import de.avatic.lcc.model.db.calculations.CalculationJobState;
import de.avatic.lcc.util.exception.internalerror.DatabaseException;
import org.springframework.jdbc.core.JdbcTemplate;
@ -10,6 +11,8 @@ import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.sql.*;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@Repository
@ -46,6 +49,81 @@ public class CalculationJobRepository {
return keyHolder.getKey() != null ? keyHolder.getKey().intValue() : null;
}
/**
* Fetches and locks the next available calculation job based on priority and state.
* Uses pessimistic locking (SELECT FOR UPDATE) to ensure thread-safety across multiple instances.
* <p>
* Priority order:
* 1. CREATED with HIGH priority
* 2. CREATED with MEDIUM priority
* 3. CREATED with LOW priority
* 4. EXCEPTION with retries < 3
*
* @return Optional containing the locked job, or empty if no job available
*/
@Transactional
public Optional<CalculationJob> fetchAndLockNextJob() {
String sql = """
SELECT * FROM calculation_job
WHERE (job_state = 'CREATED')
OR (job_state = 'EXCEPTION' AND retries < 3)
ORDER BY
CASE
WHEN job_state = 'CREATED' AND priority = 'HIGH' THEN 1
WHEN job_state = 'CREATED' AND priority = 'MEDIUM' THEN 2
WHEN job_state = 'CREATED' AND priority = 'LOW' THEN 3
WHEN job_state = 'EXCEPTION' THEN 4
END,
calculation_date
LIMIT 1
FOR UPDATE SKIP LOCKED
""";
var jobs = jdbcTemplate.query(sql, new CalculationJobMapper());
if (jobs.isEmpty()) {
return Optional.empty();
}
CalculationJob job = jobs.getFirst();
// Update state to SCHEDULED
String updateSql = "UPDATE calculation_job SET job_state = ?, retries = retries + 1 WHERE id = ?";
jdbcTemplate.update(updateSql, CalculationJobState.SCHEDULED.name(), job.getId());
job.setJobState(CalculationJobState.SCHEDULED);
return Optional.of(job);
}
/**
* Marks a job as completed successfully
*/
@Transactional
public void markAsValid(Integer id) {
String sql = "UPDATE calculation_job SET job_state = ? WHERE id = ?";
var affectedRows = jdbcTemplate.update(sql, CalculationJobState.VALID.name(), id);
if (1 != affectedRows) {
throw new DatabaseException("Unable to mark calculation job as valid with id " + id);
}
}
/**
* Marks a job as failed and increments retry counter
*/
@Transactional
public void markAsException(Integer id, Integer errorId) {
String sql = "UPDATE calculation_job SET job_state = ?, error_id = ?, retries = retries + 1 WHERE id = ?";
var affectedRows = jdbcTemplate.update(sql, CalculationJobState.EXCEPTION.name(), errorId, id);
if (1 != affectedRows) {
throw new DatabaseException("Unable to mark calculation job as exception with id " + id);
}
}
/* ****************************** check ******************** */
@Transactional
public Optional<CalculationJob> getCalculationJob(Integer id) {
String query = "SELECT * FROM calculation_job WHERE id = ?";
@ -98,7 +176,6 @@ public class CalculationJobRepository {
}
@Transactional
public void setStateTo(Integer id, CalculationJobState calculationJobState) {
String sql = "UPDATE calculation_job SET job_state = ? WHERE id = ?";
@ -131,6 +208,39 @@ public class CalculationJobRepository {
jdbcTemplate.update(sql, CalculationJobState.INVALIDATED.name(), id);
}
@Transactional
public CalculationJobState getLastStateFor(Integer premiseId) {
String sql = "SELECT job_state FROM calculation_job WHERE premise_id = ? ORDER BY calculation_date DESC LIMIT 1";
var result = jdbcTemplate.query(sql, (rs, rowNum) -> CalculationJobState.valueOf(rs.getString("job_state")), premiseId);
if (result.isEmpty())
return null;
return result.getFirst();
}
@Transactional
public Map<Integer, CalculationJobState> getStateFor(List<Integer> ids) {
return null;
}
public Integer getFailedJobByUserId(Integer userId) {
String sql = "SELECT COUNT(*) FROM calculation_job WHERE user_id = ? AND job_state = 'EXCEPTION' AND calculation_date > DATE_SUB(NOW(), INTERVAL 3 DAY)";
return jdbcTemplate.queryForObject(sql, Integer.class, userId);
}
public Integer getSelfScheduledJobCountByUserId(Integer userId) {
String sql = "SELECT COUNT(*) FROM calculation_job WHERE user_id = ? AND ( job_state = 'SCHEDULED' or job_state = 'CREATED' )";
return jdbcTemplate.queryForObject(sql, Integer.class, userId);
}
private static class CalculationJobMapper implements RowMapper<CalculationJob> {
@Override
@ -146,6 +256,14 @@ public class CalculationJobRepository {
entity.setUserId(rs.getInt("user_id"));
entity.setCalculationDate(rs.getTimestamp("calculation_date").toLocalDateTime());
entity.setPriority(CalculationJobPriority.valueOf(rs.getString("priority")));
entity.setRetries(rs.getInt("retries"));
Integer errorId = rs.getInt("error_id");
if (!rs.wasNull()) {
entity.setErrorId(errorId);
}
return entity;
}

View file

@ -67,7 +67,7 @@ public class DestinationRepository {
}
@Transactional
public void update(Integer id, Integer annualAmount, BigDecimal repackingCost, BigDecimal disposalCost, BigDecimal handlingCost, Boolean isD2d, BigDecimal d2dRate, BigDecimal d2dLeadTime) {
public void update(Integer id, Integer annualAmount, BigDecimal repackingCost, BigDecimal disposalCost, BigDecimal handlingCost, Boolean isD2d, BigDecimal d2dRate, BigDecimal d2dLeadTime, BigDecimal distanceD2d) {
if (id == null) {
throw new InvalidArgumentException("ID cannot be null");
}
@ -99,6 +99,9 @@ public class DestinationRepository {
setClauses.add("lead_time_d2d = :d2dLeadTime");
parameters.put("d2dLeadTime", setD2d ? d2dLeadTime : null);
setClauses.add("distance_d2d = :distanceD2d");
parameters.put("distanceD2d", distanceD2d);
if (annualAmount != null) {
setClauses.add("annual_amount = :annualAmount");
@ -268,7 +271,7 @@ public class DestinationRepository {
public Integer insert(Destination destination) {
KeyHolder keyHolder = new GeneratedKeyHolder();
String query = "INSERT INTO premise_destination (annual_amount, premise_id, destination_node_id, country_id, rate_d2d, lead_time_d2d, is_d2d, repacking_cost, handling_cost, disposal_cost, geo_lat, geo_lng) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
String query = "INSERT INTO premise_destination (annual_amount, premise_id, destination_node_id, country_id, rate_d2d, lead_time_d2d, is_d2d, repacking_cost, handling_cost, disposal_cost, geo_lat, geo_lng, distance_d2d) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
jdbcTemplate.update(connection -> {
var ps = connection.prepareStatement(query, Statement.RETURN_GENERATED_KEYS);
@ -297,6 +300,8 @@ public class DestinationRepository {
ps.setBigDecimal(11, destination.getGeoLat());
ps.setBigDecimal(12, destination.getGeoLng());
ps.setBigDecimal(13, destination.getDistanceD2d());
return ps;
}, keyHolder);
@ -363,6 +368,8 @@ public class DestinationRepository {
entity.setGeoLng(rs.getBigDecimal("geo_lng"));
entity.setCountryId(rs.getInt("country_id"));
entity.setDistanceD2d(rs.getBigDecimal("distance_d2d"));
return entity;
}
}

View file

@ -710,6 +710,17 @@ public class PremiseRepository {
return unlockedIds;
}
public Integer getPremiseCompletedCountByUserId(Integer userId) {
String sql = "SELECT COUNT(*) FROM premise WHERE user_id = ? AND state = 'COMPLETED'";
return jdbcTemplate.queryForObject(sql, Integer.class, userId);
}
public Integer getPremiseDraftCountByUserId(Integer userId) {
String sql = "SELECT COUNT(*) FROM premise WHERE user_id = ? AND state = 'DRAFT'";
return jdbcTemplate.queryForObject(sql, Integer.class, userId);
}
/**
* Encapsulates SQL query building logic
@ -843,7 +854,7 @@ public class PremiseRepository {
entity.setState(de.avatic.lcc.dto.calculation.PremiseState.valueOf(rs.getString("p.state")));
entity.setOwnerId(rs.getInt("p.user_id"));
entity.setCreatedAt(rs.getTimestamp("p.created_at").toLocalDateTime());
entity.setUpdatedAt(rs.getTimestamp("p.created_at").toLocalDateTime()); // Note: This looks like a bug, should be "p.updated_at"
entity.setUpdatedAt(rs.getTimestamp("p.updated_at").toLocalDateTime());
}
private Material mapMaterial(ResultSet rs) throws SQLException {

View file

@ -9,6 +9,7 @@ import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
@ -45,8 +46,8 @@ public class RouteSectionRepository {
}
public Integer insert(RouteSection premiseRouteSection) {
String sql = "INSERT INTO premise_route_section (premise_route_id, from_route_node_id, to_route_node_id, list_position, transport_type, rate_type, is_pre_run, is_main_run, is_post_run, is_outdated) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
String sql = "INSERT INTO premise_route_section (premise_route_id, from_route_node_id, to_route_node_id, list_position, transport_type, rate_type, is_pre_run, is_main_run, is_post_run, is_outdated, distance) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
@ -61,6 +62,7 @@ public class RouteSectionRepository {
ps.setBoolean(8, premiseRouteSection.getMainRun());
ps.setBoolean(9, premiseRouteSection.getPostRun());
ps.setBoolean(10, premiseRouteSection.getOutdated());
ps.setBigDecimal(11, premiseRouteSection.getDistance() == null ? null : BigDecimal.valueOf(premiseRouteSection.getDistance()));
return ps;
}, keyHolder);
@ -92,6 +94,9 @@ public class RouteSectionRepository {
entity.setPostRun(rs.getBoolean("is_post_run"));
entity.setOutdated(rs.getBoolean("is_outdated"));
var distance = rs.getBigDecimal("distance");
entity.setDistance(rs.wasNull() ? null : distance.doubleValue());
return entity;
}
}

View file

@ -11,7 +11,9 @@ import org.springframework.transaction.annotation.Transactional;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
/**
* Repository class for managing and querying property sets.
@ -72,8 +74,12 @@ public class PropertySetRepository {
*
* @return The {@link PropertySet} object in the valid state, or null if none exists.
*/
public PropertySet getValidSet() {
return jdbcTemplate.queryForObject("SELECT id, start_date, end_date, state FROM property_set WHERE state = ?", new PropertySetMapper(), ValidityPeriodState.VALID.name());
public Optional<PropertySet> getValidSet() {
var queryResult = jdbcTemplate.query("SELECT id, start_date, end_date, state FROM property_set WHERE state = ?", new PropertySetMapper(), ValidityPeriodState.VALID.name());
if(queryResult.isEmpty()) return Optional.empty();
return Optional.ofNullable(queryResult.getFirst());
}
/**
@ -82,7 +88,7 @@ public class PropertySetRepository {
* @return the ID of the valid {@link PropertySet}.
*/
public Integer getValidSetId() {
return getValidSet().getId();
return getValidSet().orElseThrow().getId();
}
@ -148,6 +154,21 @@ public class PropertySetRepository {
return set.getFirst();
}
public Optional<PropertySet> getByDate(LocalDate date) {
String query = """
SELECT id, start_date, end_date, state
FROM property_set
WHERE DATE(start_date) <= ?
AND (end_date IS NULL OR DATE(end_date) >= ?)
ORDER BY start_date DESC
LIMIT 1
""";
var propertySets = jdbcTemplate.query(query, new PropertySetMapper(), date, date);
return propertySets.isEmpty() ? Optional.empty() : Optional.of(propertySets.getFirst());
}
/**
* Mapper class for converting SQL query results into {@link PropertySet} objects.
*/

View file

@ -12,6 +12,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
@ -327,6 +328,20 @@ public class ValidityPeriodRepository {
jdbcTemplate.update(sql, increase);
}
public Optional<ValidityPeriod> getByDate(LocalDate date) {
String query = """
SELECT * FROM validity_period
WHERE DATE(start_date) <= ?
AND (end_date IS NULL OR DATE(end_date) >= ?)
ORDER BY start_date DESC
LIMIT 1
""";
var periods = jdbcTemplate.query(query, new ValidityPeriodMapper(), date, date);
return periods.isEmpty() ? Optional.empty() : Optional.of(periods.getFirst());
}
/**
* Maps rows of a {@link ResultSet} to {@link ValidityPeriod} objects.

View file

@ -10,6 +10,7 @@ import de.avatic.lcc.model.db.premises.route.*;
import de.avatic.lcc.repositories.NodeRepository;
import de.avatic.lcc.repositories.premise.*;
import de.avatic.lcc.repositories.users.UserNodeRepository;
import de.avatic.lcc.service.calculation.DistanceService;
import de.avatic.lcc.service.calculation.RoutingService;
import de.avatic.lcc.service.transformer.premise.DestinationTransformer;
import de.avatic.lcc.service.users.AuthorizationService;
@ -35,8 +36,9 @@ public class DestinationService {
private final PremiseRepository premiseRepository;
private final UserNodeRepository userNodeRepository;
private final AuthorizationService authorizationService;
private final DistanceService distanceService;
public DestinationService(DestinationRepository destinationRepository, DestinationTransformer destinationTransformer, RouteRepository routeRepository, RouteSectionRepository routeSectionRepository, RouteNodeRepository routeNodeRepository, RoutingService routingService, NodeRepository nodeRepository, PremiseRepository premiseRepository, UserNodeRepository userNodeRepository, AuthorizationService authorizationService) {
public DestinationService(DestinationRepository destinationRepository, DestinationTransformer destinationTransformer, RouteRepository routeRepository, RouteSectionRepository routeSectionRepository, RouteNodeRepository routeNodeRepository, RoutingService routingService, NodeRepository nodeRepository, PremiseRepository premiseRepository, UserNodeRepository userNodeRepository, AuthorizationService authorizationService, DistanceService distanceService) {
this.destinationRepository = destinationRepository;
this.destinationTransformer = destinationTransformer;
this.routeRepository = routeRepository;
@ -47,6 +49,7 @@ public class DestinationService {
this.premiseRepository = premiseRepository;
this.userNodeRepository = userNodeRepository;
this.authorizationService = authorizationService;
this.distanceService = distanceService;
}
@ -199,11 +202,30 @@ public class DestinationService {
destinationUpdateDTO.getDisposalCost() == null ? null : BigDecimal.valueOf(destinationUpdateDTO.getDisposalCost().doubleValue()),
destinationUpdateDTO.getHandlingCost() == null ? null : BigDecimal.valueOf(destinationUpdateDTO.getHandlingCost().doubleValue()),
destinationUpdateDTO.getD2d(), destinationUpdateDTO.getRateD2d() == null ? null : BigDecimal.valueOf(destinationUpdateDTO.getRateD2d().doubleValue()),
destinationUpdateDTO.getLeadtimeD2d() == null ? null : BigDecimal.valueOf(destinationUpdateDTO.getLeadtimeD2d())
destinationUpdateDTO.getLeadtimeD2d() == null ? null : BigDecimal.valueOf(destinationUpdateDTO.getLeadtimeD2d()),
destinationUpdateDTO.getD2d() ? getD2dDistance(id) : null
);
}
private BigDecimal getD2dDistance(Integer destinationId) {
var dest = destinationRepository.getById(destinationId);
if(dest.isPresent()) {
var premise = premiseRepository.getPremiseById(dest.get().getPremiseId());
if(premise.isPresent()) {
boolean isUserNode = premise.get().getSupplierNodeId() == null;
var from = isUserNode ? userNodeRepository.getById(premise.get().getUserSupplierNodeId()) : nodeRepository.getById(premise.get().getSupplierNodeId());
var to = nodeRepository.getById(dest.get().getDestinationNodeId());
return BigDecimal.valueOf(distanceService.getDistanceForNode(from.orElseThrow(), to.orElseThrow()));
}
}
return null;
}
private Map<RouteIds, List<RouteInformation>> findRoutes(List<Premise> premisses, Map<Integer, List<Integer>> routingRequest) {
Map<RouteIds, List<RouteInformation>> routes = new HashMap<>();
@ -267,6 +289,7 @@ public class DestinationService {
premiseRouteSection.setFromRouteNodeId(fromNodeId);
premiseRouteSection.setToRouteNodeId(toNodeId);
routeSectionRepository.insert(premiseRouteSection);
fromNodeId = toNodeId;

View file

@ -1,45 +1,28 @@
package de.avatic.lcc.service.access;
import de.avatic.lcc.dto.calculation.CalculationStatus;
import de.avatic.lcc.dto.calculation.ResolvePremiseDTO;
import de.avatic.lcc.dto.calculation.PremiseDTO;
import de.avatic.lcc.dto.calculation.ResolvePremiseDTO;
import de.avatic.lcc.dto.calculation.edit.PremiseDetailDTO;
import de.avatic.lcc.dto.calculation.edit.masterData.MaterialUpdateDTO;
import de.avatic.lcc.dto.calculation.edit.masterData.PackagingUpdateDTO;
import de.avatic.lcc.dto.calculation.edit.masterData.PriceUpdateDTO;
import de.avatic.lcc.model.db.calculations.CalculationJob;
import de.avatic.lcc.model.db.calculations.CalculationJobState;
import de.avatic.lcc.model.db.premises.Premise;
import de.avatic.lcc.model.db.premises.PremiseState;
import de.avatic.lcc.repositories.calculation.CalculationJobDestinationRepository;
import de.avatic.lcc.repositories.calculation.CalculationJobRepository;
import de.avatic.lcc.repositories.calculation.CalculationJobRouteSectionRepository;
import de.avatic.lcc.repositories.pagination.SearchQueryPagination;
import de.avatic.lcc.repositories.pagination.SearchQueryResult;
import de.avatic.lcc.repositories.premise.PremiseRepository;
import de.avatic.lcc.repositories.properties.PropertySetRepository;
import de.avatic.lcc.repositories.rates.ValidityPeriodRepository;
import de.avatic.lcc.service.calculation.execution.CalculationExecutionService;
import de.avatic.lcc.service.calculation.execution.CalculationStatusService;
import de.avatic.lcc.service.precalculation.PostCalculationCheckService;
import de.avatic.lcc.service.precalculation.PreCalculationCheckService;
import de.avatic.lcc.service.transformer.generic.DimensionTransformer;
import de.avatic.lcc.service.transformer.premise.PremiseTransformer;
import de.avatic.lcc.service.users.AuthorizationService;
import de.avatic.lcc.util.exception.base.InternalErrorException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutionException;
@Service
public class PremisesService {
@ -51,33 +34,14 @@ public class PremisesService {
private final PremiseTransformer premiseTransformer;
private final DimensionTransformer dimensionTransformer;
private final DestinationService destinationService;
private final CalculationJobRepository calculationJobRepository;
private final PropertySetRepository propertySetRepository;
private final ValidityPeriodRepository validityPeriodRepository;
private final CalculationStatusService calculationStatusService;
private final CalculationExecutionService calculationExecutionService;
private final PreCalculationCheckService preCalculationCheckService;
private final CalculationJobDestinationRepository calculationJobDestinationRepository;
private final CalculationJobRouteSectionRepository calculationJobRouteSectionRepository;
private final PostCalculationCheckService postCalculationCheckService;
private final AuthorizationService authorizationService;
public PremisesService(PremiseRepository premiseRepository, PremiseTransformer premiseTransformer, DimensionTransformer dimensionTransformer, DestinationService destinationService, CalculationJobRepository calculationJobRepository, PropertySetRepository propertySetRepository, ValidityPeriodRepository validityPeriodRepository, CalculationStatusService calculationStatusService, CalculationExecutionService calculationExecutionService, PreCalculationCheckService preCalculationCheckService, CalculationJobDestinationRepository calculationJobDestinationRepository, CalculationJobRouteSectionRepository calculationJobRouteSectionRepository, PostCalculationCheckService postCalculationCheckService, AuthorizationService authorizationService) {
public PremisesService(PremiseRepository premiseRepository, PremiseTransformer premiseTransformer, DimensionTransformer dimensionTransformer, DestinationService destinationService, AuthorizationService authorizationService) {
this.premiseRepository = premiseRepository;
this.premiseTransformer = premiseTransformer;
this.dimensionTransformer = dimensionTransformer;
this.destinationService = destinationService;
this.calculationJobRepository = calculationJobRepository;
this.propertySetRepository = propertySetRepository;
this.validityPeriodRepository = validityPeriodRepository;
this.calculationStatusService = calculationStatusService;
this.calculationExecutionService = calculationExecutionService;
this.preCalculationCheckService = preCalculationCheckService;
this.calculationJobDestinationRepository = calculationJobDestinationRepository;
this.calculationJobRouteSectionRepository = calculationJobRouteSectionRepository;
this.postCalculationCheckService = postCalculationCheckService;
this.authorizationService = authorizationService;
}
@ -102,95 +66,6 @@ public class PremisesService {
}
public void startCalculation(List<Integer> premises) {
var admin = authorizationService.isSuper();
var userId = authorizationService.getUserId();
if (!admin)
premiseRepository.checkOwner(premises, userId);
var validSetId = propertySetRepository.getValidSetId();
var validPeriodId = validityPeriodRepository.getValidPeriodId().orElseThrow(() -> new InternalErrorException("no set of transport rates found that is VALID"));
var checkResult = premises.stream().map(premiseId -> preCalculationCheckService.doPrecheck(premiseId, validSetId, validPeriodId)).toList();
CompletableFuture.allOf(checkResult.toArray(new CompletableFuture[0])).join();
var calculationIds = new ArrayList<Integer>();
premises.forEach(p -> {
var existingJob = calculationJobRepository.findJob(p, validSetId, validPeriodId);
if (existingJob.isPresent()) {
if (CalculationJobState.EXCEPTION == existingJob.get().getJobState()) {
calculationJobRepository.setStateTo(existingJob.get().getId(), CalculationJobState.CREATED);
calculationIds.add(existingJob.get().getId());
} else if (CalculationJobState.SCHEDULED == existingJob.get().getJobState() && existingJob.get().getCalculationDate().plusMinutes(15).isBefore(LocalDateTime.now())) {
calculationJobRepository.reschedule(existingJob.get().getId());
calculationIds.add(existingJob.get().getId());
}
} else {
CalculationJob job = new CalculationJob();
job.setPremiseId(p);
job.setJobState(CalculationJobState.CREATED);
job.setCalculationDate(java.time.LocalDateTime.now());
job.setUserId(userId);
job.setPropertySetId(validSetId);
job.setValidityPeriodId(validPeriodId);
calculationIds.add(calculationJobRepository.insert(job));
}
});
premiseRepository.setStatus(premises, PremiseState.COMPLETED);
calculationIds.forEach(this::scheduleCalculation);
try {
var futures = calculationIds.stream().map(calculationExecutionService::launchJobCalculation).toList();
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
for (var future : futures) {
var jobResult = future.get();
if (jobResult.getState().equals(CalculationJobState.EXCEPTION)) {
calculationJobRepository.setStateTo(jobResult.getJobId(), CalculationJobState.EXCEPTION);
throw new InternalErrorException("Calculation failed", "Calculation was not successful. Please contact Administrator.", new Exception(jobResult.getException()));
} else {
postCalculationCheckService.doPostcheck(jobResult);
for (var destinationInfo : jobResult.getDestinationInfos()) {
destinationInfo.destinationCalculationJob().setCalculationJobId(jobResult.getJobId());
var destinationId = calculationJobDestinationRepository.insert(destinationInfo.destinationCalculationJob());
for (var sectionInfo : destinationInfo.sectionInfo()) {
var section = sectionInfo.result();
section.setCalculationJobDestinationId(destinationId);
calculationJobRouteSectionRepository.insert(section);
}
}
calculationJobRepository.setStateTo(jobResult.getJobId(), CalculationJobState.VALID);
log.info("Calculation job {} finished", jobResult.getJobId());
}
}
} catch (CompletionException | InterruptedException | ExecutionException e) {
throw new InternalErrorException("Task execution of calculation failed", "Task execution of calculation was not successful. Please contact Administrator.", e);
}
}
public void scheduleCalculation(Integer id) {
calculationJobRepository.setStateTo(id, CalculationJobState.SCHEDULED);
}
public CalculationStatus getCalculationStatus(Integer processId) {
return calculationStatusService.getCalculationStatus(processId);
}
public void updatePackaging(PackagingUpdateDTO packagingDTO) {
var admin = authorizationService.isSuper();
var userId = authorizationService.getUserId();

View file

@ -1,5 +1,7 @@
package de.avatic.lcc.service.api;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.avatic.lcc.model.azuremaps.route.RouteDirectionsResponse;
import de.avatic.lcc.model.db.nodes.Distance;
import de.avatic.lcc.model.db.nodes.DistanceMatrixState;
@ -10,6 +12,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
@ -25,14 +28,18 @@ public class DistanceApiService {
private final DistanceMatrixRepository distanceMatrixRepository;
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;
private final GeoApiService geoApiService;
@Value("${azure.maps.subscription.key}")
private String subscriptionKey;
public DistanceApiService(DistanceMatrixRepository distanceMatrixRepository,
RestTemplate restTemplate) {
RestTemplate restTemplate, ObjectMapper objectMapper, GeoApiService geoApiService) {
this.distanceMatrixRepository = distanceMatrixRepository;
this.restTemplate = restTemplate;
this.objectMapper = objectMapper;
this.geoApiService = geoApiService;
}
public Optional<Integer> getDistance(Location from, Location to) {
@ -64,28 +71,63 @@ public class DistanceApiService {
if (from.getGeoLat() == null || from.getGeoLng() == null ||
to.getGeoLat() == null || to.getGeoLng() == null) {
logger.warn("Missing geo coordinates for nodes: from={}, to={}", from.getId(), to.getId());
logger.warn("Missing geo coordinates for nodes: from={}, to={}", from.getExternalMappingId(), to.getExternalMappingId());
return Optional.empty();
}
Optional<Distance> cachedDistance = distanceMatrixRepository.getDistance(from, isUsrFrom, to, isUsrTo);
if (cachedDistance.isPresent()) {
logger.debug("Found cached distance from node {} to node {}", from.getId(), to.getId());
if (cachedDistance.isPresent() && cachedDistance.get().getState() == DistanceMatrixState.VALID) {
logger.info("Found cached distance from node {} (user: {}) to node {} (user {}) - {} meters", from.getExternalMappingId(), isUsrFrom, to.getExternalMappingId(), isUsrTo, cachedDistance.get().getDistance().doubleValue());
return cachedDistance;
}
logger.debug("Fetching distance from Azure Maps for nodes {} to {}", from.getId(), to.getId());
Optional<Distance> fetchedDistance = fetchDistanceFromAzureMaps(from, isUsrFrom, to, isUsrTo);
if (cachedDistance.isPresent() && cachedDistance.get().getState() == DistanceMatrixState.EXCEPTION) {
if (cachedDistance.get().getRetries() >= 3)
return Optional.empty();
if (fetchedDistance.isPresent()) {
distanceMatrixRepository.saveDistance(fetchedDistance.get());
return fetchedDistance;
distanceMatrixRepository.updateRetries(cachedDistance.get().getId());
}
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) {
distanceMatrixRepository.saveDistance(distanceResponse.distance);
return Optional.of(distanceResponse.distance);
}
if (distanceResponse.errorType != AzureMapsErrorType.NO_ERROR) {
distanceMatrixRepository.saveDistance(getErrorDistance(cachedDistance, from, isUsrFrom, to, isUsrTo));
}
return Optional.empty();
}
private Distance getErrorDistance(Optional<Distance> cachedDistance, Node from, boolean isUsrFrom, Node to, boolean isUsrTo) {
var distance = cachedDistance.orElse(new Distance());
distance.setState(DistanceMatrixState.EXCEPTION);
distance.setUpdatedAt(LocalDateTime.now());
distance.setRetries(distance.getRetries() == null ? 0 : distance.getRetries() + 1);
if (cachedDistance.isEmpty()) {
distance.setFromUserNodeId(isUsrFrom ? from.getId() : null);
distance.setFromNodeId(isUsrFrom ? null : from.getId());
distance.setToUserNodeId(isUsrTo ? to.getId() : null);
distance.setToNodeId(isUsrTo ? null : to.getId());
distance.setFromGeoLat(from.getGeoLat());
distance.setFromGeoLng(from.getGeoLng());
distance.setToGeoLat(to.getGeoLat());
distance.setToGeoLng(to.getGeoLng());
distance.setDistance(BigDecimal.ZERO);
}
return distance;
}
private RouteDirectionsResponse fetchDistanceFromAzureMaps(BigDecimal fromLat, BigDecimal fromLng, BigDecimal toLat, BigDecimal toLng) {
String url = UriComponentsBuilder.fromUriString(AZURE_MAPS_ROUTE_API)
.queryParam("api-version", "1.0")
@ -99,11 +141,21 @@ public class DistanceApiService {
return restTemplate.getForObject(url, RouteDirectionsResponse.class);
}
private Optional<Distance> fetchDistanceFromAzureMaps(Node from, boolean isUsrFrom, Node to, boolean isUsrTo) {
private AzureMapResponse fetchDistanceFromAzureMaps(Node from, boolean isUsrFrom, Node to, boolean isUsrTo, boolean allowFixing) {
try {
RouteDirectionsResponse response = fetchDistanceFromAzureMaps(from.getGeoLat(), from.getGeoLng(), to.getGeoLat(), to.getGeoLng());
return convertToDistance(response, from, isUsrFrom, to, isUsrTo);
} catch (Exception e) {
if (HttpClientErrorException.class.isAssignableFrom(e.getClass()))
return handleAzureMapsError((HttpClientErrorException) e, from, isUsrFrom, to, isUsrTo, allowFixing);
logger.error("Error fetching distance from Azure Maps", e);
return new AzureMapResponse(null, AzureMapsErrorType.OTHER_ERROR, null);
}
}
private AzureMapResponse convertToDistance(RouteDirectionsResponse response, Node from, boolean isUsrFrom, Node to, boolean isUsrTo) {
if (response != null && response.getRoutes() != null && !response.getRoutes().isEmpty()) {
Integer distanceInMeters = response.getRoutes().getFirst().getSummary().getLengthInMeters();
@ -133,16 +185,100 @@ public class DistanceApiService {
distance.setState(DistanceMatrixState.VALID);
distance.setUpdatedAt(LocalDateTime.now());
// reset to 0 if on success
distance.setRetries(0);
logger.info("Successfully fetched distance: {} meters", distanceInMeters);
return Optional.of(distance);
return new AzureMapResponse(distance, AzureMapsErrorType.NO_ERROR, null);
} else {
logger.warn("No routes found in Azure Maps response");
logger.error("No routes found in Azure Maps response");
return new AzureMapResponse(null, AzureMapsErrorType.ROUTE_ERROR, null);
}
} catch (Exception e) {
logger.error("Error fetching distance from Azure Maps", e);
}
return Optional.empty();
private AzureMapResponse handleAzureMapsError(HttpClientErrorException e,
Node from, boolean isUsrFrom,
Node to, boolean isUsrTo, boolean allowFixing) {
try {
String responseBody = e.getResponseBodyAsString();
JsonNode errorNode = objectMapper.readTree(responseBody);
String errorCode = errorNode.path("error").path("code").asText();
String errorMessage = errorNode.path("error").path("message").asText();
logger.warn("Azure Maps API Error for nodes {} ({}) to {} ({}): {} - {}",
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"))
return new AzureMapResponse(null, AzureMapsErrorType.ROUTE_ERROR, null);
if (errorMessage.contains("MAP_MATCHING_FAILURE")) {
if (errorMessage.contains("Destination")) {
if (allowFixing) {
var fixedNode = fixNode(to);
if (fixedNode != null)
return fetchDistanceFromAzureMaps(from, isUsrFrom, fixedNode, isUsrTo, false);
}
return new AzureMapResponse(null, AzureMapsErrorType.NODE_ERROR, AzureMapsDefectiveNode.FROM);
}
if (errorMessage.contains("Origin")) {
if (allowFixing) {
var fixedNode = fixNode(from);
if (fixedNode != null)
return fetchDistanceFromAzureMaps(fixedNode, isUsrFrom, to, isUsrTo, false);
}
return new AzureMapResponse(null, AzureMapsErrorType.NODE_ERROR, AzureMapsDefectiveNode.TO);
}
return new AzureMapResponse(null, AzureMapsErrorType.NODE_ERROR, AzureMapsDefectiveNode.UNKNOWN);
}
} catch (Exception parseException) {
logger.error("Failed to parse Azure Maps error response", parseException);
}
return new AzureMapResponse(null, AzureMapsErrorType.OTHER_ERROR, null);
}
private Node fixNode(Node node) {
logger.info("Try to fix node {} ({}) ", node.getExternalMappingId(), node.getId());
Location location = geoApiService.locate(node.getAddress() + ", " + node.getCountryId());
if (location != null && location.getLatitude() != null && location.getLongitude() != null) {
if (0 != BigDecimal.valueOf(location.getLatitude()).compareTo(node.getGeoLat()) || 0 != BigDecimal.valueOf(location.getLongitude()).compareTo(node.getGeoLng())) {
logger.info("Fixed node {} ({}) coordinates {}, {} -> {}, {}", node.getExternalMappingId(), node.getId(), node.getGeoLat(), node.getGeoLng(), location.getLatitude(), location.getLongitude());
node.setGeoLng(BigDecimal.valueOf(location.getLongitude()));
node.setGeoLat(BigDecimal.valueOf(location.getLatitude()));
return node;
}
}
return null;
}
private enum AzureMapsErrorType {
NO_ERROR, NODE_ERROR, ROUTE_ERROR, OTHER_ERROR
}
private enum AzureMapsDefectiveNode {
FROM, TO, UNKNOWN
}
private record AzureMapResponse(Distance distance, AzureMapsErrorType errorType,
AzureMapsDefectiveNode defectiveNode) {
}

View file

@ -53,12 +53,16 @@ public class EUTaxationApiService {
request.setReferenceDate(getCurrentDate());
request.setTradeMovement(TradeMovementCode.fromValue(tradeMovement));
logger.info("Lookup Measure for {} and {}", goodsCode, countryCode);
JAXBElement<GoodsMeasForWs> requestElement = objectFactory.createGoodsMeasForWs(request);
@SuppressWarnings("unchecked")
JAXBElement<GoodsMeasForWsResponse> responseElement =
(JAXBElement<GoodsMeasForWsResponse>) webServiceTemplate.marshalSendAndReceive(requestElement);
logger.info("Lookup Measure for {} and {} success: {} Measures received.", goodsCode, countryCode, responseElement.getValue().getReturn().getResult().getMeasures().getMeasure().size());
return CompletableFuture.completedFuture(responseElement.getValue());
}

View file

@ -43,6 +43,7 @@ public class GeoApiService {
}
public GeocodingResult geocode(String address) {
if (address == null || address.trim().isEmpty()) {
logger.warn("Address is null or empty");
return null;

View file

@ -17,6 +17,8 @@ import de.avatic.lcc.util.exception.base.InternalErrorException;
import eu.europa.ec.taxation.taric.client.GoodsMeasForWsResponse;
import eu.europa.ec.taxation.taric.client.GoodsMeasuresForWsResponse;
import jakarta.annotation.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.util.*;
@ -30,15 +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;
public TaxationResolverService(CountryRepository countryRepository, EUTaxationApiService eUTaxationApiService, PropertyRepository propertyRepository, ZolltarifnummernApiService zolltarifnummernApiService, NomenclatureService nomenclatureService, CountryPropertyRepository countryPropertyRepository) {
private final Logger logger = LoggerFactory.getLogger(TaxationResolverService.class);
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;
}
@ -92,20 +95,11 @@ public class TaxationResolverService {
}
private List<TaxationResolverSingleRequest> resolveIncompleteHsCodesIntern(List<TaxationResolverRequest> request) {
return request.stream().flatMap(r -> nomenclatureService.getNomenclature(r.material().getHsCode()).stream().map(hsCode -> new TaxationResolverSingleRequest(hsCode, r.countryId(), r))).toList();
}
var singleRequests = request.stream().flatMap(r -> nomenclatureService.getNomenclature(r.material().getHsCode()).stream().map(hsCode -> new TaxationResolverSingleRequest(hsCode, r.countryId(), r))).toList();
private List<TaxationResolverSingleRequest> resolveIncompleteHsCodes(List<TaxationResolverRequest> request) {
logger.info("Resolved {} incomplete hs codes to {} hs code leaves ", request.size(), singleRequests.size());
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();
return singleRequests;
}
@ -244,6 +238,9 @@ public class TaxationResolverService {
}
public List<TaxationResolverResponse> getTariffRates(List<TaxationResolverRequest> requests) {
logger.info("Do taxation resolution for {} requests", requests.size());
var goodMeasures = doRequests(requests);
return goodMeasures.keySet().stream().map(r -> mapToResponse(r, goodMeasures.get(r))).toList();
}
@ -257,6 +254,9 @@ public class TaxationResolverService {
List<GoodsMeasForWsResponse> measForWsResponse = apiResponse.apiResponse();
logger.info("============================");
logger.info("Resolved measures for: {}, {}", request.material(), request.countryId());
try {
String selectedHsCode = null;
Double selectedDuty = null;
@ -277,11 +277,20 @@ public class TaxationResolverService {
var measureType = MeasureType.fromMeasureCode(measure.getMeasureType().getMeasureType());
boolean maybeRelevant = measureType.map(MeasureType::containsRelevantDuty).orElse(false);
if (maybeRelevant) {
var duty = extractDuty(measure);
if (duty.isPresent()) {
logger.info("Measure ({}{}, {}): is_relevant: true, duty: {}, hs code: {}",
measureType.map(MeasureType::getSeries).orElse("UNKNOWN"),
measureType.map(MeasureType::getMeasureCode).orElse(""),
measureType.map(MeasureType::name).orElse("UNKNOWN"),
duty,
entry.getKey().getReturn().getResult().getRequest().getGoodsCode());
maxDuty = Math.max(maxDuty, duty.get());
minDuty = Math.min(minDuty, duty.get());
@ -291,10 +300,22 @@ public class TaxationResolverService {
selectedMeasure = measureType.map(MeasureType::getMeasureCode).orElse(null);
selectedHsCode = entry.getKey().getReturn().getResult().getRequest().getGoodsCode();
}
} else {
logger.info("Measure ({}{}, {}): is_relevant: true, no duty extracted",
measureType.map(MeasureType::getSeries).orElse("UNKNOWN"),
measureType.map(MeasureType::getMeasureCode).orElse(""),
measureType.map(MeasureType::name).orElse("UNKNOWN"));
}
} else {
logger.info("Measure ({}{}, {}): is_relevant: false", measureType.map(MeasureType::getSeries).orElse("UNKNOWN"), measureType.map(MeasureType::getMeasureCode).orElse(""), measureType.map(MeasureType::name).orElse("UNKNOWN"));
}
}
}
}
}
logger.info("============================");
logger.info("Selected: measure: {}, duty: {}, max_duty: {}, min_duty: {}", selectedMeasure, selectedDuty, maxDuty, minDuty);
logger.info("============================");
if (selectedDuty != null && (maxDuty - minDuty <= 0.02)) {
return new TaxationResolverResponse(selectedDuty, selectedMeasure, selectedHsCode, request.material(), request.countryId());

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() {
@ -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

@ -4,8 +4,7 @@ import de.avatic.lcc.dto.bulk.BulkFileType;
import de.avatic.lcc.model.bulk.BulkFileTypes;
import de.avatic.lcc.model.bulk.BulkOperation;
import de.avatic.lcc.model.bulk.HiddenTableType;
import de.avatic.lcc.repositories.rates.ValidityPeriodRepository;
import de.avatic.lcc.service.bulk.helper.HeaderCellStyleProvider;
import de.avatic.lcc.service.bulk.helper.CellStyleProvider;
import de.avatic.lcc.service.excelMapper.*;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.Sheet;
@ -21,10 +20,9 @@ import java.io.IOException;
@Service
public class BulkExportService {
private final HeaderCellStyleProvider headerCellStyleProvider;
private final CellStyleProvider cellStyleProvider;
private final ContainerRateExcelMapper containerRateExcelMapper;
private final MatrixRateExcelMapper matrixRateExcelMapper;
private final MaterialExcelMapper materialExcelMapper;
private final PackagingExcelMapper packagingExcelMapper;
private final NodeExcelMapper nodeExcelMapper;
private final HiddenNodeExcelMapper hiddenNodeExcelMapper;
@ -32,11 +30,10 @@ public class BulkExportService {
private final String sheetPassword;
private final MaterialFastExcelMapper materialFastExcelMapper;
public BulkExportService(@Value("${lcc.bulk.sheet_password}") String sheetPassword, HeaderCellStyleProvider headerCellStyleProvider, ContainerRateExcelMapper containerRateExcelMapper, MatrixRateExcelMapper matrixRateExcelMapper, MaterialExcelMapper materialExcelMapper, PackagingExcelMapper packagingExcelMapper, NodeExcelMapper nodeExcelMapper, HiddenNodeExcelMapper hiddenNodeExcelMapper, HiddenCountryExcelMapper hiddenCountryExcelMapper, MaterialFastExcelMapper materialFastExcelMapper) {
this.headerCellStyleProvider = headerCellStyleProvider;
public BulkExportService(@Value("${lcc.bulk.sheet_password}") String sheetPassword, CellStyleProvider cellStyleProvider, ContainerRateExcelMapper containerRateExcelMapper, MatrixRateExcelMapper matrixRateExcelMapper, PackagingExcelMapper packagingExcelMapper, NodeExcelMapper nodeExcelMapper, HiddenNodeExcelMapper hiddenNodeExcelMapper, HiddenCountryExcelMapper hiddenCountryExcelMapper, MaterialFastExcelMapper materialFastExcelMapper) {
this.cellStyleProvider = cellStyleProvider;
this.containerRateExcelMapper = containerRateExcelMapper;
this.matrixRateExcelMapper = matrixRateExcelMapper;
this.materialExcelMapper = materialExcelMapper;
this.packagingExcelMapper = packagingExcelMapper;
this.nodeExcelMapper = nodeExcelMapper;
this.hiddenNodeExcelMapper = hiddenNodeExcelMapper;
@ -68,7 +65,7 @@ public class BulkExportService {
Workbook workbook = new XSSFWorkbook();
Sheet worksheet = workbook.createSheet(BulkFileTypes.valueOf(bulkFileType.name()).getSheetName());
CellStyle style = headerCellStyleProvider.createHeaderCellStyle(workbook);
CellStyle style = cellStyleProvider.createHeaderCellStyle(workbook);
if (bulkFileType.equals(BulkFileType.COUNTRY_MATRIX) || bulkFileType.equals(BulkFileType.NODE)) {
@ -93,10 +90,6 @@ public class BulkExportService {
matrixRateExcelMapper.fillSheet(worksheet, style, periodId);
matrixRateExcelMapper.createConstraints(workbook, worksheet);
break;
// case MATERIAL:
// materialExcelMapper.fillSheet(worksheet, style);
// materialExcelMapper.createConstraints(worksheet);
// break;
case PACKAGING:
packagingExcelMapper.fillSheet(worksheet, style);
packagingExcelMapper.createConstraints(workbook, worksheet);

View file

@ -3,6 +3,7 @@ package de.avatic.lcc.service.bulk;
import de.avatic.lcc.dto.bulk.BulkFileType;
import de.avatic.lcc.model.bulk.BulkFileTypes;
import de.avatic.lcc.model.bulk.BulkOperation;
import de.avatic.lcc.model.db.materials.Material;
import de.avatic.lcc.service.api.BatchGeoApiService;
import de.avatic.lcc.service.bulk.bulkImport.*;
import de.avatic.lcc.service.excelMapper.*;
@ -11,6 +12,8 @@ import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.util.RecordFormatException;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.io.ByteArrayInputStream;
@ -34,6 +37,10 @@ public class BulkImportService {
private final BatchGeoApiService batchGeoApiService;
private final MaterialFastExcelMapper materialFastExcelMapper;
private static final Logger log = LoggerFactory.getLogger(BulkImportService.class);
public BulkImportService(MatrixRateExcelMapper matrixRateExcelMapper, ContainerRateExcelMapper containerRateExcelMapper, MaterialExcelMapper materialExcelMapper, PackagingExcelMapper packagingExcelMapper, NodeExcelMapper nodeExcelMapper, NodeBulkImportService nodeBulkImportService, PackagingBulkImportService packagingBulkImportService, MaterialBulkImportService materialBulkImportService, MatrixRateImportService matrixRateImportService, ContainerRateImportService containerRateImportService, BatchGeoApiService batchGeoApiService, MaterialFastExcelMapper materialFastExcelMapper) {
this.matrixRateExcelMapper = matrixRateExcelMapper;
this.containerRateExcelMapper = containerRateExcelMapper;
@ -63,7 +70,15 @@ public class BulkImportService {
private void processOperationWithFastExcel(BulkOperation op) throws IOException {
var materials = materialFastExcelMapper.importFromExcel(op.getFile());
materials.forEach(materialBulkImportService::processMaterialInstructions);
int processed = 0;
for(var material : materials) {
if(processed++ % 1000 == 0)
log.info("Processed {} of {} materials", processed, materials.size());
materialBulkImportService.processMaterialInstructions(material);
}
}
private void processOperationWithApachePOI(BulkOperation op) throws IOException {
@ -96,10 +111,6 @@ public class BulkImportService {
var matrixRates = matrixRateExcelMapper.extractSheet(sheet);
matrixRateImportService.processMatrixRates(matrixRates);
break;
// case MATERIAL:
// var materials = materialExcelMapper.extractSheet(sheet);
// materials.forEach(materialBulkImportService::processMaterialInstructions);
// break;
case PACKAGING:
var packaging = packagingExcelMapper.extractSheet(sheet);
packaging.forEach(packagingBulkImportService::processPackagingInstructions);

View file

@ -3,7 +3,7 @@ package de.avatic.lcc.service.bulk;
import de.avatic.lcc.dto.bulk.BulkFileType;
import de.avatic.lcc.model.bulk.*;
import de.avatic.lcc.model.bulk.header.*;
import de.avatic.lcc.service.bulk.helper.HeaderCellStyleProvider;
import de.avatic.lcc.service.bulk.helper.CellStyleProvider;
import de.avatic.lcc.service.bulk.helper.HeaderGenerator;
import de.avatic.lcc.service.excelMapper.*;
import org.apache.poi.ss.usermodel.CellStyle;
@ -24,7 +24,7 @@ public class TemplateExportService {
private final HeaderGenerator headerGenerator;
private final HeaderCellStyleProvider headerCellStyleProvider;
private final CellStyleProvider cellStyleProvider;
private final HiddenNodeExcelMapper hiddenNodeExcelMapper;
private final HiddenCountryExcelMapper hiddenCountryExcelMapper;
private final String sheetPassword;
@ -34,9 +34,9 @@ public class TemplateExportService {
private final PackagingExcelMapper packagingExcelMapper;
private final NodeExcelMapper nodeExcelMapper;
public TemplateExportService(@Value("${lcc.bulk.sheet_password}") String sheetPassword, HeaderGenerator headerGenerator, HeaderCellStyleProvider headerCellStyleProvider, HiddenNodeExcelMapper hiddenNodeExcelMapper, HiddenCountryExcelMapper hiddenCountryExcelMapper, ContainerRateExcelMapper containerRateExcelMapper, MatrixRateExcelMapper matrixRateExcelMapper, MaterialExcelMapper materialExcelMapper, PackagingExcelMapper packagingExcelMapper, NodeExcelMapper nodeExcelMapper) {
public TemplateExportService(@Value("${lcc.bulk.sheet_password}") String sheetPassword, HeaderGenerator headerGenerator, CellStyleProvider cellStyleProvider, HiddenNodeExcelMapper hiddenNodeExcelMapper, HiddenCountryExcelMapper hiddenCountryExcelMapper, ContainerRateExcelMapper containerRateExcelMapper, MatrixRateExcelMapper matrixRateExcelMapper, MaterialExcelMapper materialExcelMapper, PackagingExcelMapper packagingExcelMapper, NodeExcelMapper nodeExcelMapper) {
this.headerGenerator = headerGenerator;
this.headerCellStyleProvider = headerCellStyleProvider;
this.cellStyleProvider = cellStyleProvider;
this.hiddenNodeExcelMapper = hiddenNodeExcelMapper;
this.hiddenCountryExcelMapper = hiddenCountryExcelMapper;
this.sheetPassword = sheetPassword;
@ -53,7 +53,7 @@ public class TemplateExportService {
Sheet sheet = workbook.createSheet(BulkFileTypes.valueOf(bulkFileType.name()).getSheetName());
CellStyle style = headerCellStyleProvider.createHeaderCellStyle(workbook);
CellStyle style = cellStyleProvider.createHeaderCellStyle(workbook);
if (bulkFileType.equals(BulkFileType.COUNTRY_MATRIX) || bulkFileType.equals(BulkFileType.NODE)) {

View file

@ -29,6 +29,7 @@ public class MaterialBulkImportService {
}
}
private void updateMaterial(Material material) {
var foundMaterial = materialRepository.getByPartNumber(material.getNormalizedPartNumber());

Some files were not shown because too many files have changed in this diff Show more