- Moved calculation to worker threads.
- Added pre-calculation checks so that a calculation isn't started with insufficient data.
- Missing lead time for D2D added.
- Grouping for reporting works now as expected.
FRONTEND:
- Reporting implemented. Material and Valid period added to report.
- Fixed Workflow for the calculation user. Archive/Delete functionality is added.
- Missing lead time for D2D added.
This commit is contained in:
Jan 2025-09-11 20:29:45 +02:00
parent 849d31bc8e
commit f885704dc9
52 changed files with 1073 additions and 329 deletions

View file

@ -14,6 +14,7 @@
"loglevel": "^1.9.2",
"pinia": "^3.0.3",
"vue": "^3.5.18",
"vue-chartjs": "^5.3.2",
"vue-router": "^4.5.1"
},
"devDependencies": {
@ -2939,6 +2940,16 @@
}
}
},
"node_modules/vue-chartjs": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.2.tgz",
"integrity": "sha512-NrkbRRoYshbXbWqJkTN6InoDVwVb90C0R7eAVgMWcB9dPikbruaOoTFjFYHE/+tNPdIe6qdLCDjfjPHQ0fw4jw==",
"license": "MIT",
"peerDependencies": {
"chart.js": "^4.1.1",
"vue": "^3.0.0-0 || ^2.7.0"
}
},
"node_modules/vue-router": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz",

View file

@ -18,6 +18,7 @@
"loglevel": "^1.9.2",
"pinia": "^3.0.3",
"vue": "^3.5.18",
"vue-chartjs": "^5.3.2",
"vue-router": "^4.5.1"
},
"devDependencies": {

View file

@ -1,7 +1,7 @@
import logger from "@/logger.js";
import {useErrorStore} from "@/store/error.js";
const performRequest = async (requestingStore, method, url, body, expectResponse = true) => {
const performRequest = async (requestingStore, method, url, body, expectResponse = true, expectedException = null) => {
const params = {
method: method,
@ -48,17 +48,23 @@ const performRequest = async (requestingStore, method, url, body, expectResponse
});
if (!response.ok) {
const error = {
const errorObj = {
code: data.error.code,
title: data.error.title,
message: data.error.message,
trace: data.error.trace
}
logger.error(error);
const errorStore = useErrorStore();
void errorStore.addError(error, {store: requestingStore, request: request});
throw new Error('Internal backend error');
const error = new Error('Internal backend error');
error.errorObj = errorObj;
if (expectedException === null || data.error.title !== expectedException) {
logger.error(errorObj);
const errorStore = useErrorStore();
void errorStore.addError(errorObj, {store: requestingStore, request: request});
}
throw error;
}
} else {
if (!response.ok) {
@ -76,17 +82,23 @@ const performRequest = async (requestingStore, method, url, body, expectResponse
});
const error = {
const errorObj = {
code: data.error.code,
title: data.error.title,
message: data.error.message,
trace: data.error.trace
}
logger.error(error);
const errorStore = useErrorStore();
void errorStore.addError(error, {store: requestingStore, request: request});
throw new Error('Internal backend error');
const error = new Error('Internal backend error');
error.errorObj = errorObj;
if (expectedException === null || data.error.title !== expectedException) {
logger.error(errorObj);
const errorStore = useErrorStore();
void errorStore.addError(errorObj, {store: requestingStore, request: request});
}
throw error;
}
}

View file

@ -10,9 +10,11 @@
ref="modalOverlay"
:style="modalAddStyle"
>
<box @click.stop>
<div class="modal-container">
<box @click.stop class="modal-box">
<slot></slot>
</box>
</box>
</div>
</div>
</transition>
</teleport>
@ -42,12 +44,17 @@ export default {
validators: (value) => value >= 0,
default: 5000,
},
resizeTransitionDuration: {
type: String,
default: '0.3s'
}
},
emits: ['close'],
computed: {
modalAddStyle() {
return {
zIndex: this.zIndex,
'--resize-duration': this.resizeTransitionDuration
}
},
isVisible() {
@ -105,7 +112,28 @@ export default {
padding: 1rem;
}
/* Transition animations */
.modal-container {
max-width: 100%;
max-height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.modal-box {
/* Smooth transitions for size changes */
transition:
width var(--resize-duration, 0.3s) ease,
height var(--resize-duration, 0.3s) ease,
max-width var(--resize-duration, 0.3s) ease,
max-height var(--resize-duration, 0.3s) ease,
transform var(--resize-duration, 0.3s) ease;
/* Ensure the box can resize smoothly */
overflow: hidden;
}
/* Transition animations for modal open/close */
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.3s ease;
@ -116,13 +144,18 @@ export default {
opacity: 0;
}
.modal-enter-active .box,
.modal-leave-active .box {
.modal-enter-active .modal-container,
.modal-leave-active .modal-container {
transition: transform 0.3s ease;
}
.modal-enter-from .box,
.modal-leave-to .box {
.modal-enter-from .modal-container,
.modal-leave-to .modal-container {
transform: scale(0.9) translateY(-10px);
}
/* Optional: Add transition to content inside the modal for smoother changes */
.modal-box :deep(*) {
transition: opacity 0.2s ease;
}
</style>

View file

@ -34,55 +34,81 @@ ChartJS.register(
export default {
name: "ReportChart",
computed: {
props: {
title: {
type: String,
required: true
},
mek_a : {
type: Number,
required: true
},
logistics_costs : {
type: Number,
required: true
},
risk_cost : {
type: Number,
required: true
},
chance_cost : {
type: Number,
required: true
},
scale : {
type: Number,
required: true
}
},
data() {
return {
data: {
labels: ['Sample A'],
datasets: [
{
label: 'MEK A',
data: [35],
backgroundColor: '#6B869C', // Blue-gray like in your image
borderColor: '#6B869C',
borderWidth: 1,
stack: 'stack1'
},
{
label: 'Logistics costs',
data: [15],
backgroundColor: '#5AF0B4', // Mint green like in your image
borderColor: '#5AF0B4',
borderWidth: 1,
stack: 'stack1'
},
{
label: 'Error Bars',
// data: [50, 65, 58, 75], // Total height (used for error bar positioning)
type: 'scatter',
backgroundColor: 'transparent',
borderColor: 'transparent',
pointRadius: 0,
showLine: false,
stack: 'errorBars',
errorBars: {
plus: [5], // Upper error
minus: [4] // Lower error
}
}
]
}
computed: {
totalCosts() {
return this.mek_a + this.logistics_costs;
},
upperError() {
return (this.risk_cost - this.totalCosts);
},
lowerError() {
return (this.totalCosts - this.chance_cost);
}
},
mounted() {
const ctx = this.$refs.stackedBarChart.getContext('2d');
// Sample data
const data = this.data;
const data = {
labels: [this.title],
datasets: [
{
label: 'MEK A',
data: [this.mek_a],
backgroundColor: '#6B869C', // Blue-gray like in your image
borderColor: '#6B869C',
borderWidth: 1,
stack: 'stack1'
},
{
label: 'Logistics costs',
data: [this.logistics_costs],
backgroundColor: '#5AF0B4', // Mint green like in your image
borderColor: '#5AF0B4',
borderWidth: 1,
stack: 'stack1'
},
{
label: 'Error Bars',
type: 'scatter',
backgroundColor: 'transparent',
borderColor: 'transparent',
pointRadius: 0,
showLine: false,
stack: 'errorBars',
errorBars: {
plus: [this.upperError], // Upper error
minus: [this.lowerError] // Lower error
}
}
]
};
const config = {
type: 'bar',
@ -106,7 +132,7 @@ export default {
y: {
stacked: true, // Don't stack for error bars
beginAtZero: true,
max: 90,
max: this.scale,
grid: {
color: '#E3EDFF'
},

View file

@ -21,8 +21,9 @@
</div>
</div>
<div class="route-section-info-text">
<div class="route-section-info-text-header-cell">Calc. model</div><div class="route-section-info-text-data-cell">{{ section.rate_type === 'CONTAINER' ? 'Container rate' : section.rate_type === 'D2D' ? 'Door-2-door' : 'Kilometer rate' }}</div>
<div class="route-section-info-text-header-cell">Transit time</div><div class="route-section-info-text-data-cell">{{ section.duration.total }} days</div>
<div class="route-section-info-text-header-cell">Distance</div><div class="route-section-info-text-data-cell">{{ section.distance.total.toFixed(2) }} km</div>
<div class="route-section-info-text-header-cell" v-if="section.rate_type === 'MATRIX'">Distance</div><div v-if="section.rate_type === 'MATRIX'" class="route-section-info-text-data-cell">{{ section.distance.total.toFixed(2) }} km</div>
<div class="route-section-info-text-header-cell">Transport rate</div><div class="route-section-info-text-data-cell">{{ section.cost.total.toFixed(2) }} &euro;</div>
</div>

View file

@ -25,7 +25,7 @@
help-text-position="left"></icon-button>
<icon-button :disabled="!isDraft" icon="trash" @click="deleteClick" help-text="Delete this calculation"
help-text-position="left"></icon-button>
<icon-button :disabled="isDraft" icon="archive" @click="archiveClick" help-text="Archive/hide this calculation"
<icon-button :disabled="isDraft || isArchived" icon="archive" @click="archiveClick" help-text="Archive/hide this calculation"
help-text-position="left"></icon-button>
</div>
</div>
@ -88,6 +88,9 @@ export default {
},
isDraft() {
return this.premise.state === 'DRAFT';
},
isArchived() {
return this.premise.state === 'ARCHIVED';
}
},
methods: {
@ -95,14 +98,20 @@ export default {
this.premise.checked = checked;
},
editClick() {
const urlStr = new UrlSafeBase64().encodeIds([this.id]);
this.$router.push({name: 'edit', params: {id: urlStr}});
if(this.premise.state === 'DRAFT') {
const urlStr = new UrlSafeBase64().encodeIds([this.id]);
this.$router.push({name: 'edit', params: {id: urlStr}});
}
},
deleteClick() {
if(this.premise.state === 'DRAFT') {
this.premiseStore.deletePremisses([this.id])
}
},
archiveClick() {
if(this.premise.state !== 'DRAFT' && this.premise.state !== 'ARCHIVED') {
this.premiseStore.archivePremisses([this.id])
}
}
}
}

View file

@ -25,17 +25,19 @@
<radio-option name="model" value="d2d" v-model="calculationModel">individual rate</radio-option>
</div>
<!-- Single grid cell for caption that transitions content -->
<div v-if="!showMassEditWarning" class="destination-edit-column-caption destination-edit-caption-top">
<transition name="fade" mode="out-in">
<div v-if="showRoutes || showRouteWarning" key="routes" class="destination-edit-caption-top-elem">Routes</div>
<div v-else key="rate" class="destination-edit-caption-top-elem">D2D Rate [EUR]</div>
</transition>
</div>
<!-- Single grid cell for data that transitions content -->
<div v-if="!showMassEditWarning" class="destination-edit-column-data destination-edit-routes-cell">
<transition name="fade" mode="out-in">
<div v-if="showRoutes" key="routes" class="destination-edit-cell-routes">
<destination-route v-for="route in destination.routes" :key="route.id" :route="route"
:selected="route.is_selected" @click="selectRoute(route.id)"></destination-route>
@ -50,8 +52,24 @@
<input :value="rateD2d" @blur="validateRateD2d" class="input-field"
autocomplete="off"/>
</div>
</transition>
</div>
<div class="destination-edit-column-caption">
<div v-if="!showMassEditWarning && !showRoutes && !showRouteWarning"> Lead time [days]</div>
</div>
<div class="destination-edit-column-data">
<div v-if="!showMassEditWarning && !showRoutes && !showRouteWarning" key="rate" class="text-container">
<input :value="leadtimeD2d" @blur="validateLeadTimeD2d" class="input-field"
autocomplete="off"/>
</div>
</div>
</div>
</div>
</template>
@ -87,6 +105,15 @@ export default {
this.rateD2d = validatedValue;
event.target.value = stringified;
},
validateLeadTimeD2d(event) {
const value = parseNumberFromString(event.target.value, 0);
const validatedValue = Math.max(0, value);
const stringified = validatedValue.toFixed();
this.leadtimeD2d = validatedValue;
event.target.value = stringified;
}
},
created() {
@ -141,6 +168,14 @@ export default {
set(value) {
this.destination && (this.destination.rate_d2d = value);
}
},
leadtimeD2d: {
get() {
return this.destination.lead_time_d2d?.toFixed() ?? '0';
},
set(value) {
this.destination && (this.destination.lead_time_d2d = value);
}
}
}
}
@ -184,7 +219,7 @@ export default {
.destination-edit-routes {
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: auto auto auto 1fr; /* Last row takes remaining space */
grid-template-rows: repeat(5, auto) 1fr; /* Last row takes remaining space */
gap: 1.6rem;
height: 100%;
min-height: 0; /* Important for grid to shrink */

View file

@ -3,7 +3,7 @@
<div :class="containerClass">
<ph-boat :size="18" v-if="isSea" class="destination-route-icon"></ph-boat>
<ph-train :size="18" v-else-if="isRail" class="destination-route-icon"></ph-train>
<ph-truck-trailer :size="18" v-else-if="isRoad" class="destination-route-icon"></ph-truck-trailer>
<ph-truck :size="18" v-else-if="isRoad" class="destination-route-icon"></ph-truck>
<ph-navigation-arrow :size="18" v-else class="destination-route-icon"></ph-navigation-arrow>
<div><span v-for="element in routeElements" class="destination-route-element"> {{ element }} </span></div>
<basic-badge v-if="cheapest" variant="secondary">CHEAPEST</basic-badge>
@ -16,12 +16,20 @@
<script>
import {PhBoat, PhHandCoins, PhLightning, PhNavigationArrow, PhTrain, PhTruckTrailer} from "@phosphor-icons/vue";
import {
PhBoat,
PhHandCoins,
PhLightning,
PhNavigationArrow,
PhTrain,
PhTruck,
PhTruckTrailer
} from "@phosphor-icons/vue";
import BasicBadge from "@/components/UI/BasicBadge.vue";
export default {
name: "DestinationRoute",
components: {BasicBadge, PhLightning, PhHandCoins, PhNavigationArrow, PhTrain, PhTruckTrailer, PhBoat},
components: {PhTruck, BasicBadge, PhLightning, PhHandCoins, PhNavigationArrow, PhTrain, PhTruckTrailer, PhBoat},
props: {
route: {
type: Object,

View file

@ -1,11 +1,20 @@
<template>
<div class="report-container">
<div class="report-header">{{ report.supplier.name }}</div>
<div class="box-gap">
<div class="report-header">{{ report.supplier.name }}</div></div>
<div class="report-chart">
<report-chart></report-chart>
<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"
:scale="chartScale"
></report-chart>
</div>
<collapsible-box :is-collapsable="false" variant="border" title="Overview" size="m" :stretch-content="true">
<div class="box-gap">
<collapsible-box :is-collapsable="false" variant="border" title="Overview" size="m" :stretch-content="true" >
<div class="report-content-container--2-col">
<div class="report-content-row">
@ -26,7 +35,8 @@
</div>
</collapsible-box>
</div>
<div class="box-gap">
<collapsible-box :is-collapsable="false" variant="border" title="Weighted cost breakdown" size="m"
:stretch-content="true">
<div class="report-content-container--3-col">
@ -61,6 +71,12 @@
<div class="report-content-data-cell">{{ (report.costs.main_run.percentage * 100).toFixed(2) }}</div>
</div>
<div class="report-content-row" v-if="((report.costs.air_freight_cost ?? null) !== null)">
<div>Airfreight cost</div>
<div class="report-content-data-cell">{{ report.costs.air_freight_cost.total.toFixed(2) }}</div>
<div class="report-content-data-cell">{{ (report.costs.air_freight_cost.percentage * 100).toFixed(2) }}</div>
</div>
<div class="report-content-row">
<div>Post carriage</div>
<div class="report-content-data-cell">{{ report.costs.post_run.total.toFixed(2) }}</div>
@ -105,10 +121,13 @@
</div>
</collapsible-box>
</div>
<div class="box-gap" :key="premise.id" v-for="premise in report.premises">
<collapsible-box class="report-content-container" variant="border" :title="premise.destination.name"
:key="premise.id" :stretch-content="true" v-for="premise in report.premises">
:stretch-content="true" :initially-collapsed="true">
<div>
<report-route :sections="premise.sections" :destination="premise.destination" ></report-route>
@ -136,16 +155,21 @@
<div class="report-content-data-cell">{{ (premise.oversea_share * 100).toFixed(2) }}%</div>
</div>
<div class="report-content-row" v-if="premise.air_freight_share">
<div class="report-content-row" v-if="(premise.air_freight_share ?? null) !== null">
<div>Airfreight share</div>
<div class="report-content-data-cell">{{ (premise.air_freight_share * 100).toFixed(2) }}%</div>
</div>
<div class="report-content-row">
<div>Transit time</div>
<div>Transit time [days]</div>
<div class="report-content-data-cell">{{ premise.transport_time }}</div>
</div>
<div class="report-content-row">
<div>Safety stock [days]</div>
<div class="report-content-data-cell">{{ premise.safety_stock }}</div>
</div>
</div>
<div class="report-sub-header">Packaging</div>
@ -192,14 +216,12 @@
<div class="report-content-data-cell">{{ premise.weight_exceeded ? 'weight' : 'volume' }} </div>
</div>
</div>
</div>
</collapsible-box>
</div>
</div>
</template>
@ -218,6 +240,10 @@ export default {
report: {
type: Object,
required: true
},
chartScale: {
type: Number,
required: true
}
},
methods: {
@ -245,6 +271,11 @@ export default {
<style scoped>
.box-gap {
padding-left: 0.8rem;
padding-right: 0.8rem;
}
.report-container {
display: flex;
flex-direction: column;
@ -286,6 +317,8 @@ export default {
font-size: 1.6rem;
font-weight: 500;
color: #001D33;
text-wrap: nowrap;
}
.report-sub-header {

View file

@ -12,9 +12,9 @@
></autosuggest-searchbar>
</div>
</div>
<div class="caption">Select suppliers to compare:</div>
<div class="content-container" v-if="suppliers.length !== 0">
<div class="caption">Select suppliers to compare:</div>
<ul class="item-list">
<transition-group name="fade" tag="ul" class="item-list">
<li class="item-list-element" v-for="supplier in suppliers" :key="supplier.id"
@click="selectSupplier(supplier.id)">
<supplier-item :id="String(supplier.id)"
@ -26,7 +26,7 @@
:selected="isSelected(supplier.id)"
></supplier-item>
</li>
</ul>
</transition-group>
</div>
<div class="content-container-empty" v-else>no suppliers found.</div>
<div class="footer">
@ -56,7 +56,7 @@ export default {
},
created() {
//todo reset the store instead.
this.selectedMaterialId = this.reportSearchStore.getMaterial?.id;
this.reportSearchStore.reset();
},
computed: {
...mapStores(useMaterialStore, useReportSearchStore),
@ -101,7 +101,7 @@ export default {
display: flex;
flex-direction: column;
gap: 2.4rem;
min-width: 90vw;
width: 80vw;
min-height: min(80vh, 100rem);
}
@ -122,15 +122,15 @@ export default {
.content-container {
flex: 1;
overflow-x: auto; /* Enable horizontal scrolling */
overflow-y: hidden; /* Prevent vertical overflow */
overflow-y: auto;
overflow-x: hidden;
min-height: 0; /* Allow flex item to shrink below content size */
}
.content-container-empty {
flex: 1;
overflow-x: auto; /* Enable horizontal scrolling */
overflow-y: hidden; /* Prevent vertical overflow */
overflow-y: auto;
overflow-x: hidden;
min-height: 0; /* Allow flex item to shrink below content size */
display: flex;
align-items: center;
@ -150,11 +150,11 @@ export default {
.item-list {
display: flex;
flex-direction: row;
list-style: none;
gap: 2.4rem 2.4rem;
margin: 4.8rem;
flex-wrap: nowrap;
min-width: max-content;
flex-wrap: wrap;
}
.item-list-element {
@ -163,5 +163,16 @@ export default {
cursor: pointer;
}
/* Fade animation styles */
.fade-enter-active, .fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
.fade-enter-to, .fade-leave-from {
opacity: 1;
}
</style>

View file

@ -26,7 +26,7 @@ import {
PhArchive,
PhFloppyDisk,
PhArrowCounterClockwise,
PhCheck, PhBug, PhShuffle, PhStack, PhFile
PhCheck, PhBug, PhShuffle, PhStack, PhFile, PhFilePlus, PhDownloadSimple
} from "@phosphor-icons/vue";
const app = createApp(App);

View file

@ -18,6 +18,7 @@
</div>
</div>
<Toast ref="toast" />
<transition name="list-edit-container" tag="div">
<transition-group name="list-edit" mode="out-in" class="edit-calculation-list-container" tag="div">
@ -120,6 +121,7 @@ import MaterialEdit from "@/components/layout/edit/MaterialEdit.vue";
import PackagingEdit from "@/components/layout/edit/PackagingEdit.vue";
import DestinationListView from "@/components/layout/edit/DestinationListView.vue";
import SelectNode from "@/components/layout/node/SelectNode.vue";
import Toast from "@/components/UI/Toast.vue";
const COMPONENT_TYPES = {
@ -132,7 +134,7 @@ const COMPONENT_TYPES = {
export default {
name: "MassEdit",
components: {Modal, MassEditDialog, ListEdit, Spinner, CalculationListItem, Checkbox, BulkEditRow, BasicButton},
components: {Toast, Modal, MassEditDialog, ListEdit, Spinner, CalculationListItem, Checkbox, BulkEditRow, BasicButton},
computed: {
...mapStores(usePremiseEditStore),
hasSelection() {
@ -184,7 +186,7 @@ export default {
created() {
this.bulkQuery = this.$route.params.ids;
this.ids = new UrlSafeBase64().decodeIds(this.$route.params.ids);
this.premiseEditStore.loadPremissesIfNeeded(this.ids);
this.premiseEditStore.loadPremissesIfNeeded(this.ids, true);
},
data() {
return {
@ -221,8 +223,22 @@ export default {
}
},
methods: {
startCalculation() {
this.premiseEditStore.startCalculation();
async startCalculation() {
const error = await this.premiseEditStore.startCalculation();
if(error !== null) {
this.$refs.toast.addToast({
icon: 'warning',
message: error.message,
title: "Cannot start calculation",
variant: 'exception',
duration: 8000
})
} else
{
this.closeMassEdit()
}
},
closeMassEdit() {
this.$router.push({name: "calculation-list"});

View file

@ -128,8 +128,24 @@ export default {
}
},
methods: {
startCalculation() {
this.premiseEditStore.startCalculation();
async startCalculation() {
const error = await this.premiseEditStore.startCalculation();
if(error !== null) {
this.$refs.toast.addToast({
icon: 'warning',
message: error.message,
title: "Cannot start calculation",
variant: 'exception',
duration: 8000
})
} else
{
this.close();
}
},
close() {
if(this.bulkEditQuery) {

View file

@ -81,19 +81,34 @@ export default {
},
methods: {
handleMultiselectAction(action) {
const selectedPremisses = this.premiseStore.premises.filter(p => p.checked === true);
if (action === "delete") {
const ids = selectedPremisses.filter(p => p.state === "DRAFT").map(p => p.id)
if (ids.length !== 0) {
this.premiseStore.deletePremisses(ids);
}
} else if (action === "edit") {
const ids = this.premiseStore.premises.filter(p => p.checked === true).map(p => p.id);
const ids = selectedPremisses.map(p => p.id);
if (ids.length === 1) {
this.$router.push({name: "edit", params: {id: ids[0]}});
this.$router.push({name: "edit", params: {id: new UrlSafeBase64().encodeIds([ids[0]])}});
} else {
this.$router.push({name: "bulk", params: {ids: new UrlSafeBase64().encodeIds(ids)}});
}
} else if (action === "archive") {
const ids = selectedPremisses.filter(p => p.state !== "DRAFT" && p.state !== "ARCHIVED").map(p => p.id)
if (ids.length !== 0) {
this.premiseStore.archivePremisses(ids);
}
}
},
updateCheckBoxes(checked) {

View file

@ -1,9 +1,10 @@
<template>
<div>
<div class="header-container">
<h2 class="page-header">Reporting</h2>
<h2 class="page-header page-header-align">Reporting <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 class="header-controls">
<basic-button @click="showModal = true" icon="file">Create report</basic-button>
<basic-button @click="createReport" icon="file">Create report</basic-button>
<basic-button :disabled="!hasReport" variant="secondary" @click="downloadReport" icon="Download">Export</basic-button>
</div>
</div>
@ -14,9 +15,7 @@
</div>
<div v-else-if="hasReport">
<box>
<report v-for="report in reports" :key="report.id" :report="report"></report>
<report v-for="report in reports" :key="report.id" :report="report" :chart-scale="chartScale"></report>
</box>
</div>
<div v-else class="empty-container">
@ -43,10 +42,11 @@ import Box from "@/components/UI/Box.vue";
import Spinner from "@/components/UI/Spinner.vue";
import ReportChart from "@/components/UI/ReportChart.vue";
import Report from "@/components/layout/report/Report.vue";
import BasicBadge from "@/components/UI/BasicBadge.vue";
export default {
name: "Reporting",
components: {Report, ReportChart, Spinner, Box, SelectForReport, BasicButton, Modal},
components: {BasicBadge, Report, ReportChart, Spinner, Box, SelectForReport, BasicButton, Modal},
data() {
return {
showModal: false,
@ -62,9 +62,33 @@ export default {
},
loading() {
return this.reportsStore.loading;
},
chartScale() {
return this.reportsStore.getChartScale;
},
partNumber() {
if(!this.hasReport) return null;
return this.reportsStore.reports[0].material.part_number;
},
period() {
if(!this.hasReport) return null;
const start = this.reportsStore.reports[0].start_date;
const end = this.reportsStore.reports[0].end_date;
if(end === null) {
return `Validity period: since ${this.buildDate(start)}` ;
}
return `Validity period: ${this.buildDate(start)} ${this.buildDate(end)}` ;
}
},
methods: {
createReport() {
this.showModal = true;
},
buildDate(date) {
return `${date[0]}-${date[1].toString().padStart(2, '0')}-${date[2].toString().padStart(2, '0')}`
},
closeModal(data) {
console.log("closeModal: ", data.action)
if (data.action === 'accept') {
@ -84,6 +108,18 @@ export default {
<style scoped>
.page-header-align {
display: flex;
align-items: center;
gap: 1.6rem;
}
.page-header-badges {
display: flex;
align-items: center;
gap: 0.8rem;
}
.space-around {
margin: 3rem;
}

View file

@ -1,6 +1,7 @@
import {defineStore} from 'pinia'
import {config} from '@/config'
import {useErrorStore} from "@/store/error.js";
import performRequest from "@/backend.js";
export const usePremiseStore = defineStore('premise', {
state: () => ({
@ -12,7 +13,9 @@ export const usePremiseStore = defineStore('premise', {
pagination: {}
}),
getters: {
getById: (state) => { return (id) => state.premises.find(p => p.id === id)},
getById: (state) => {
return (id) => state.premises.find(p => p.id === id)
},
showData: (state) => !state.loading && state.empty === false,
showLoading: (state) => state.loading,
showEmpty: (state) => !state.loading && state.empty === true,
@ -23,6 +26,18 @@ export const usePremiseStore = defineStore('premise', {
this.query = query;
this.updatePremises();
},
async deletePremisses(ids) {
const params = new URLSearchParams();
params.append('premissIds', ids.join(', '));
await performRequest(this, 'POST', `${config.backendUrl}/calculation/delete/${params.size === 0 ? '' : '?'}${params.toString()}`, null, false);
await this.updatePremises();
},
async archivePremisses(ids) {
const params = new URLSearchParams();
params.append('premissIds', ids.join(', '));
await performRequest(this, 'POST', `${config.backendUrl}/calculation/archive/${params.size === 0 ? '' : '?'}${params.toString()}`, null, false);
await this.updatePremises();
},
async updatePremises() {
this.premises = [];
@ -45,37 +60,46 @@ export const usePremiseStore = defineStore('premise', {
const url = `${config.backendUrl}/calculation/view/${params.size === 0 ? '' : '?'}${params.toString()}`;
const request = { url: url, params: {method: 'GET'}};
const request = {url: url, params: {method: 'GET'}};
const response = await fetch(url).catch(e => {
this.error = { title: 'Network error.', message: "Please check your internet connection.", trace: null }
this.error = {title: 'Network error.', message: "Please check your internet connection.", trace: null}
this.loading = false;
console.error(this.error);
const errorStore = useErrorStore();
void errorStore.addError(this.error, { store: this, request: request});
void errorStore.addError(this.error, {store: this, request: request});
throw e;
});
const data = await response.json().catch(e => {
this.error = { title: 'Malformed response', message: "Malformed server response. Please contact support.", trace: null }
this.error = {
title: 'Malformed response',
message: "Malformed server response. Please contact support.",
trace: null
}
this.loading = false;
console.error(this.error);
const errorStore = useErrorStore();
void errorStore.addError(this.error, { store: this, request: request});
void errorStore.addError(this.error, {store: this, request: request});
throw e;
});
if (!response.ok) {
this.error = { code: data.error.code, title: data.error.title, message: data.error.message, trace: data.error.trace }
this.error = {
code: data.error.code,
title: data.error.title,
message: data.error.message,
trace: data.error.trace
}
this.loading = false;
console.error(this.error);
const errorStore = useErrorStore();
void errorStore.addError(this.error, { store: this, request: request});
void errorStore.addError(this.error, {store: this, request: request});
console.log(data);
return;

View file

@ -3,6 +3,7 @@ import {config} from '@/config'
import {toRaw} from "vue";
import {useErrorStore} from "@/store/error.js";
import logger from "@/logger.js"
import performRequest from '@/backend.js'
export const usePremiseEditStore = defineStore('premiseEdit', {
state() {
@ -313,17 +314,14 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
const body = this.premisses.map(p => p.id);
const url = `${config.backendUrl}/calculation/start/`;
let error = null;
const data = await this.performRequest('PUT', url, body).catch(e => {
// do something
await performRequest(this,'PUT', url, body, false, 'Premiss validation error').catch(e => {
console.log("startCalculation exception", e.errorObj);
error = e.errorObj;
})
if (data) {
// do something
}
alert("Finished.");
return error;
},
/**
@ -368,11 +366,12 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
disposal_costs: toDest.disposal_costs,
is_d2d: toDest.is_d2d,
rate_d2d: toDest.rate_d2d,
lead_time_d2d: toDest.lead_time_d2d,
route_selected_id: toDest.routes.find(r => r.is_selected)?.id ?? null,
};
const url = `${config.backendUrl}/calculation/destination/${toDest.id}`;
this.performRequest('PUT', url, body, false);
performRequest(this,'PUT', url, body, false);
});
});
@ -397,7 +396,7 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
const body = {destinations: destinations, premise_id: this.destinations.premise_ids};
const url = `${config.backendUrl}/calculation/destination/`;
const data = await this.performRequest('PUT', url, body).catch(e => {
const data = await performRequest(this,'PUT', url, body).catch(e => {
this.destinations = null;
this.processDestinationMassEdit = false;
})
@ -427,6 +426,7 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
d.annual_amount = from.annual_amount;
d.is_d2d = from.is_d2d;
d.rate_d2d = from.is_d2d ? from.rate_d2d : null;
d.lead_time_d2d = from.is_d2d ? from.lead_time_d2d : null;
d.handling_costs = from.handling_costs;
d.disposal_costs = from.disposal_costs;
d.repackaging_costs = from.repackaging_costs;
@ -441,6 +441,7 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
d.annual_amount = from.annual_amount;
d.is_d2d = from.is_d2d;
d.rate_d2d = from.is_d2d ? from.rate_d2d : null;
d.lead_time_d2d = from.is_d2d ? from.lead_time_d2d : null;
if (from.userDefinedHandlingCosts) {
d.disposal_costs = from.disposal_costs;
@ -504,13 +505,14 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
disposal_costs: toDest.disposal_costs,
is_d2d: toDest.is_d2d,
rate_d2d: toDest.rate_d2d,
lead_time_d2d: toDest.lead_time_d2d,
route_selected_id: toDest.routes.find(r => r.is_selected)?.id ?? null,
};
logger.info(body)
const url = `${config.backendUrl}/calculation/destination/${toDest.id}`;
await this.performRequest('PUT', url, body, false);
await performRequest(this,'PUT', url, body, false);
}
@ -543,7 +545,7 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
const origId = id.substring(1);
const url = `${config.backendUrl}/calculation/destination/${origId}`;
await this.performRequest('DELETE', url, null, false).catch(async e => {
await performRequest(this,'DELETE', url, null, false).catch(async e => {
logger.error("Unable to delete destination: " + origId + "");
logger.error(e);
await this.loadPremissesIfNeeded(this.premisses.map(p => p.id));
@ -580,6 +582,7 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
annual_amount: 0,
is_d2d: false,
rate_d2d: null,
lead_time_d2d: null,
disposal_costs: null,
repackaging_costs: null,
handling_costs: null,
@ -602,7 +605,7 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
const url = `${config.backendUrl}/calculation/destination/`;
const destinations = await this.performRequest('POST', url, body).catch(e => {
const destinations = await performRequest(this,'POST', url, body).catch(e => {
this.loading = false;
this.selectedLoading = false;
throw e;
@ -663,7 +666,7 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
body.premise_id = toBeUpdated;
logger.info(url, body)
const data = await this.performRequest('PUT', url, body).catch(e => {
const data = await performRequest(this,'PUT', url, body).catch(e => {
this.loading = false;
});
@ -708,7 +711,7 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
is_fca_enabled: toBeUpdated[0].is_fca_enabled
};
await this.performRequest('POST', `${config.backendUrl}/calculation/price/`, body, false).catch(e => {
await performRequest(this,'POST', `${config.backendUrl}/calculation/price/`, body, false).catch(e => {
success = false;
})
@ -743,7 +746,7 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
};
await this.performRequest('POST', `${config.backendUrl}/calculation/packaging/`, body, false).catch(() => {
await performRequest(this,'POST', `${config.backendUrl}/calculation/packaging/`, body, false).catch(() => {
success = false;
})
@ -755,7 +758,6 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
const toBeUpdated = this.premisses ? (ids ? (ids.map(id => this.premisses.find(p => String(p.id) === String(id)))) : (this.premisses.filter(p => p.selected))) : null;
if (!toBeUpdated?.length) return;
const body = {
@ -765,7 +767,7 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
tariff_rate: toBeUpdated[0].tariff_rate,
};
await this.performRequest('POST', `${config.backendUrl}/calculation/material/`, body, false).catch(() => {
await performRequest(this,'POST', `${config.backendUrl}/calculation/material/`, body, false).catch(() => {
success = false;
})
@ -833,7 +835,7 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
params.append('premissIds', `${[id]}`);
const url = `${config.backendUrl}/calculation/edit/${params.size === 0 ? '' : '?'}${params.toString()}`;
this.premisses = await this.performRequest('GET', url, null).catch(e => {
this.premisses = await performRequest(this,'GET', url, null).catch(e => {
this.selectedLoading = false;
this.loading = false;
});
@ -843,8 +845,8 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
this.selectedLoading = false;
this.loading = false;
},
async loadPremissesIfNeeded(ids) {
const reload = this.premisses ? !ids.every((id) => this.premisses.find(d => d.id === id)) : true;
async loadPremissesIfNeeded(ids, exact = false) {
const reload = this.premisses ? !ids.every((id) => this.premisses.find(d => d.id === id) && (!exact || ids.length === this.premisses.length)) : true;
if (reload) {
await this.loadPremissesForced(ids);
@ -859,7 +861,7 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
params.append('premissIds', ids.join(', '));
const url = `${config.backendUrl}/calculation/edit/${params.size === 0 ? '' : '?'}${params.toString()}`;
this.premisses = await this.performRequest('GET', url, null).catch(e => {
this.premisses = await performRequest(this,'GET', url, null).catch(e => {
this.loading = false;
});
@ -871,100 +873,6 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
removePremise(id) {
const idx = this.premisses.findIndex(p => p.id === id);
this.premisses.splice(idx, 1);
},
async performRequest(method, url, body, expectResponse = true) {
const params = {
method: method,
headers: {
'Content-Type': 'application/json'
}
};
if (body) {
params.body = JSON.stringify(body);
}
const request = {url: url, params: params};
logger.info("Request:", request);
const response = await fetch(url, params
).catch(e => {
const error = {
code: 'Network error.',
message: "Please check your internet connection.",
trace: null
}
logger.error(error);
const errorStore = useErrorStore();
void errorStore.addError(error, {store: this, request: request});
throw e;
});
let data = null;
if (expectResponse) {
data = await response.json().catch(e => {
const error = {
code: 'Malformed response',
message: "Malformed server response. Please contact support.",
trace: null
}
logger.error(error);
const errorStore = useErrorStore();
void errorStore.addError(error, {store: this, request: request});
throw e;
});
if (!response.ok) {
const error = {
code: data.error.code,
title: data.error.title,
message: data.error.message,
trace: data.error.trace
}
logger.error(error);
const errorStore = useErrorStore();
void errorStore.addError(error, {store: this, request: request});
throw new Error('Internal backend error');
}
} else {
if (!response.ok) {
const data = await response.json().catch(e => {
const error = {
code: "Return code error " + response.status,
message: "Server returned wrong response code",
trace: null
}
logger.error(error);
const errorStore = useErrorStore();
void errorStore.addError(error, {store: this, request: request});
throw new Error('Internal backend error');
});
const error = {
code: data.error.code,
title: data.error.title,
message: data.error.message,
trace: data.error.trace
}
logger.error(error);
const errorStore = useErrorStore();
void errorStore.addError(error, {store: this, request: request});
throw new Error('Internal backend error');
}
}
logger.info("Response:", data);
return data;
}
}
})

View file

@ -35,6 +35,12 @@ export const useReportSearchStore = defineStore('reportSearch', {
},
},
actions: {
async reset() {
this.suppliers = [];
this.material = null;
this.selectedIds = [];
this.remainingSuppliers = [];
},
async selectSupplier(id) {
if (!this.selectedIds.includes(id))

View file

@ -10,7 +10,19 @@ export const useReportsStore = defineStore('reports', {
loading: false,
}
},
getters: {},
getters: {
getChartScale(state) {
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);
})
const magnitude = Math.pow(10, Math.floor(Math.log10(max)));
return Math.ceil(max / magnitude) * magnitude;
}
},
actions: {
async fetchReports(materialId, supplierIds) {
if (supplierIds == null || materialId == null) return;

View file

@ -0,0 +1,24 @@
package de.avatic.lcc.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean(name = "taskExecutor")
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("calc-");
executor.initialize();
return executor;
}
}

View file

@ -4,6 +4,7 @@ import de.avatic.lcc.dto.error.ErrorDTO;
import de.avatic.lcc.dto.error.ErrorResponseDTO;
import de.avatic.lcc.util.exception.base.BadRequestException;
import de.avatic.lcc.util.exception.base.ForbiddenException;
import de.avatic.lcc.util.exception.internalerror.PremiseValidationError;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import org.springframework.http.HttpStatus;
@ -130,6 +131,18 @@ public class GlobalExceptionHandler {
return new ResponseEntity<>(new ErrorResponseDTO(error), HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(PremiseValidationError.class)
public ResponseEntity<ErrorResponseDTO> handleGenericException(PremiseValidationError exception) {
ErrorDTO error = new ErrorDTO(
exception.getClass().getName(),
"Premiss validation error",
exception.getMessage(),
Arrays.asList(exception.getStackTrace())
);
return new ResponseEntity<>(new ErrorResponseDTO(error), HttpStatus.INTERNAL_SERVER_ERROR);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponseDTO> handleGenericException(Exception exception) {
ErrorDTO error = new ErrorDTO(

View file

@ -108,14 +108,27 @@ public class PremiseController {
}
@PostMapping({"/delete", "/delete/"})
public ResponseEntity<Void> deletePremises(@RequestParam List<Integer> premissIds) {
premisesServices.delete(premissIds);
return ResponseEntity.ok().build();
}
@PostMapping({"/archive", "/archive/"})
public ResponseEntity<Void> archivePremises(@RequestParam List<Integer> premissIds) {
premisesServices.archive(premissIds);
return ResponseEntity.ok().build();
}
@GetMapping({"/edit", "/edit/"})
public ResponseEntity<List<PremiseDetailDTO>> getPremises(@RequestParam List<Integer> premissIds) {
return ResponseEntity.ok(premisesServices.getPremises(premissIds));
}
@PutMapping({"/start", "/start/"})
public ResponseEntity<Integer> startCalculation(@RequestBody List<Integer> premiseIds) {
return ResponseEntity.ok(premisesServices.startCalculation(premiseIds));
public ResponseEntity<Void> startCalculation(@RequestBody List<Integer> premiseIds) {
premisesServices.startCalculation(premiseIds);
return ResponseEntity.ok().build();
}
/**

View file

@ -31,6 +31,9 @@ public class DestinationDTO {
@JsonProperty("rate_d2d")
private BigDecimal rateD2d;
@JsonProperty("lead_time_d2d")
private Number leadTimeD2d;
private List<RouteDTO> routes;
public Boolean getD2d() {
@ -105,4 +108,12 @@ public class DestinationDTO {
public void setRateD2d(BigDecimal rateD2d) {
this.rateD2d = rateD2d;
}
public Number getLeadTimeD2d() {
return leadTimeD2d;
}
public void setLeadTimeD2d(Number leadTimeD2d) {
this.leadTimeD2d = leadTimeD2d;
}
}

View file

@ -13,18 +13,18 @@ public class DestinationUpdateDTO {
private Integer annualAmount;
@JsonProperty("repackaging_costs")
@DecimalMin(value = "0.00", message = "Amount must be greater than or equal 0")
@Digits(integer = 13, fraction = 2, message = "Amount must have at most 2 decimal places")
@DecimalMin(value = "0.00", message = "Repackaging cost must be greater than or equal 0")
@Digits(integer = 13, fraction = 2, message = "Repackaging cost must have at most 2 decimal places")
private Number repackingCost;
@JsonProperty("handling_costs")
@DecimalMin(value = "0.00", message = "Amount must be greater than or equal 0")
@Digits(integer = 13, fraction = 2, message = "Amount must have at most 2 decimal places")
@DecimalMin(value = "0.00", message = "Handling cost must be greater than or equal 0")
@Digits(integer = 13, fraction = 2, message = "Handling cost must have at most 2 decimal places")
private Number handlingCost;
@JsonProperty("disposal_costs")
@DecimalMin(value = "0.00", message = "Amount must be greater than or equal 0")
@Digits(integer = 13, fraction = 2, message = "Amount must have at most 2 decimal places")
@DecimalMin(value = "0.00", message = "Disposal cost must be greater than or equal 0")
@Digits(integer = 13, fraction = 2, message = "Disposal cost must have at most 2 decimal places")
private Number disposalCost;
@JsonProperty("route_selected_id")
@ -34,11 +34,16 @@ public class DestinationUpdateDTO {
@JsonProperty("is_d2d")
private Boolean d2d;
@DecimalMin(value = "0.00", message = "Amount must be greater than or equal 0")
@Digits(integer = 13, fraction = 2, message = "Amount must have at most 2 decimal places")
@DecimalMin(value = "0.00", message = "Rate must be greater than or equal 0")
@Digits(integer = 13, fraction = 2, message = "Rate must have at most 2 decimal places")
@JsonProperty("rate_d2d")
private Number rateD2d;
@Min(value = 0, message = "Lead time must be greater than or equal 0")
@Digits(integer = 13, fraction = 2, message = "Lead time must have at most 2 decimal places")
@JsonProperty("lead_time_d2d")
private Integer leadtimeD2d;
public Number getRepackingCost() {
return repackingCost;
}
@ -108,4 +113,12 @@ public class DestinationUpdateDTO {
", rateD2d=" + rateD2d +
'}';
}
public Integer getLeadtimeD2d() {
return leadtimeD2d;
}
public void setLeadtimeD2d(Integer leadtimeD2d) {
this.leadtimeD2d = leadtimeD2d;
}
}

View file

@ -3,7 +3,7 @@ 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),
HQ(12030, 2350, 2690, 76.4, 24,21),
HC(12030, 2350, 2690, 76.4, 24,21),
TRUCK(13600,2450, 2650, 88.3, 34, 33);
private final int length;

View file

@ -1,13 +1,23 @@
package de.avatic.lcc.dto.report;
import com.fasterxml.jackson.annotation.JsonProperty;
import de.avatic.lcc.dto.generic.MaterialDTO;
import de.avatic.lcc.dto.generic.NodeDTO;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
public class ReportDTO {
MaterialDTO material;
@JsonProperty("start_date")
private LocalDateTime startDate;
@JsonProperty("end_date")
private LocalDateTime endDate;
NodeDTO supplier;
@JsonProperty("costs")
@ -54,4 +64,28 @@ public class ReportDTO {
public void setDestination(List<ReportDestinationDTO> destinations) {
this.destinations = destinations;
}
public MaterialDTO getMaterial() {
return material;
}
public void setMaterial(MaterialDTO material) {
this.material = material;
}
public LocalDateTime getStartDate() {
return startDate;
}
public void setStartDate(LocalDateTime startDate) {
this.startDate = startDate;
}
public LocalDateTime getEndDate() {
return endDate;
}
public void setEndDate(LocalDateTime endDate) {
this.endDate = endDate;
}
}

View file

@ -36,7 +36,7 @@ public class ReportDestinationDTO {
private Double transportTime;
@JsonProperty("safety_stock")
private Double safetyStock;
private Integer safetyStock;
/* packaging */
@ -150,11 +150,11 @@ public class ReportDestinationDTO {
this.transportTime = transportTime;
}
public Double getSafetyStock() {
public Integer getSafetyStock() {
return safetyStock;
}
public void setSafetyStock(Double safetyStock) {
public void setSafetyStock(Integer safetyStock) {
this.safetyStock = safetyStock;
}

View file

@ -0,0 +1,4 @@
package de.avatic.lcc.model;
public record ValidityTuple(Integer periodId, Integer propertySetId) {
}

View file

@ -0,0 +1,47 @@
package de.avatic.lcc.model.calculations;
public class CalculationResult {
Integer jobId;
CalculationJobState state;
Throwable exception;
public CalculationResult(Integer jobId) {
this.jobId = jobId;
this.exception = null;
this.state = CalculationJobState.VALID;
}
public CalculationResult(Integer jobId, Throwable e) {
this.jobId = jobId;
this.exception = e;
this.state = CalculationJobState.EXCEPTION;
}
public CalculationJobState getState() {
return state;
}
public void setState(CalculationJobState state) {
this.state = state;
}
public Throwable getException() {
return exception;
}
public void setException(Throwable exception) {
this.exception = exception;
}
public Integer getJobId() {
return jobId;
}
public void setJobId(Integer jobId) {
this.jobId = jobId;
}
}

View file

@ -1,6 +1,7 @@
package de.avatic.lcc.repositories;
import de.avatic.lcc.dto.generic.NodeType;
import de.avatic.lcc.model.ValidityTuple;
import de.avatic.lcc.model.nodes.Node;
import de.avatic.lcc.repositories.pagination.SearchQueryPagination;
import de.avatic.lcc.repositories.pagination.SearchQueryResult;
@ -380,7 +381,7 @@ public class NodeRepository {
return jdbcTemplate.query(query, new NodeMapper(), countryId, countryId);
}
public List<Node> findNodeListsForReportingByPeriodId(Integer materialId, Integer periodId) {
public List<Node> findNodeListsForReportingByPeriodId(Integer materialId, ValidityTuple tuple) {
String suppliersSql = """
@ -390,11 +391,12 @@ public class NodeRepository {
INNER JOIN node n ON p.supplier_node_id = n.id
WHERE p.material_id = ?
AND cj.validity_period_id = ?
AND cj.property_set_id = ?
AND p.supplier_node_id IS NOT NULL
""";
return jdbcTemplate.query(suppliersSql, new NodeMapper(), materialId, periodId);
return jdbcTemplate.query(suppliersSql, new NodeMapper(), materialId, tuple.periodId(), tuple.propertySetId());
}
public Optional<Node> getByDestinationId(Integer id) {

View file

@ -75,6 +75,7 @@ public class CalculationJobRepository {
return Optional.of(job.getFirst());
}
@Transactional
public void setStateTo(Integer id, CalculationJobState calculationJobState) {
String sql = "UPDATE calculation_job SET job_state = ? WHERE id = ?";

View file

@ -51,9 +51,14 @@ public class CalculationJobRouteSectionRepository {
jdbcTemplate.update(connection -> {
var ps = connection.prepareStatement(query, Statement.RETURN_GENERATED_KEYS);
ps.setInt(1, section.getPremiseRouteSectionId());
if (section.getPremiseRouteSectionId() == null)
ps.setNull(1, java.sql.Types.INTEGER);
else
ps.setInt(1, section.getPremiseRouteSectionId());
ps.setInt(2, section.getCalculationJobDestinationId());
ps.setString(3, section.getTransportType().name());
ps.setString(3, convertRateTypeToString(section.getRateType(), section.getTransportType()));
ps.setBoolean(4, section.getUnmixedPrice());
ps.setBoolean(5, section.isCbmPrice());
ps.setBoolean(6, section.isWeightPrice());
@ -73,6 +78,17 @@ public class CalculationJobRouteSectionRepository {
return keyHolder.getKey() != null ? keyHolder.getKey().intValue() : null;
}
private String convertRateTypeToString(RateType rateType, TransportType transportType) {
if (rateType == null) {
return null;
}
return switch (rateType) {
case MATRIX, D2D -> rateType.name();
case CONTAINER -> transportType == null ? null : transportType.name();
};
}
@Transactional
public List<CalculationJobRouteSection> getRouteSectionsByDestinationId(Integer destinationId) {

View file

@ -51,7 +51,7 @@ public class DestinationRepository {
}
@Transactional
public void update(Integer id, Integer annualAmount, BigDecimal repackingCost, BigDecimal disposalCost, BigDecimal handlingCost, Boolean isD2d, BigDecimal d2dRate) {
public void update(Integer id, Integer annualAmount, BigDecimal repackingCost, BigDecimal disposalCost, BigDecimal handlingCost, Boolean isD2d, BigDecimal d2dRate, BigDecimal d2dLeadTime) {
if (id == null) {
throw new InvalidArgumentException("ID cannot be null");
}
@ -80,6 +80,9 @@ public class DestinationRepository {
setClauses.add("rate_d2d = :d2dRate");
parameters.put("d2dRate", setD2d ? d2dRate : null);
setClauses.add("lead_time_d2d = :d2dLeadTime");
parameters.put("d2dLeadTime", setD2d ? d2dLeadTime : null);
if (annualAmount != null) {
setClauses.add("annual_amount = :annualAmount");

View file

@ -637,6 +637,25 @@ public class PremiseRepository {
}
}
@Transactional
public void setStatus(List<Integer> premisesIds, PremiseState premiseState) {
if (premisesIds == null || premisesIds.isEmpty() || premiseState == null) {
return;
}
String placeholders = String.join(",", Collections.nCopies(premisesIds.size(), "?"));
String query = "UPDATE premise SET state = ? WHERE id IN (" + placeholders + ")";
List<Object> params = new ArrayList<>();
params.add(premiseState.name());
params.addAll(premisesIds);
var affectedRows = jdbcTemplate.update(query, params.toArray());
if (affectedRows != premisesIds.size())
throw new DatabaseException("Premise update failed for " + premisesIds.size() + " premises. Affected rows: " + affectedRows);
}
/**
* Encapsulates SQL query building logic

View file

@ -138,6 +138,17 @@ public class PropertySetRepository {
return stateString != null ? ValidityPeriodState.valueOf(stateString) : null;
}
public PropertySet getById(Integer propertySetId) {
String query = "SELECT id, start_date, end_date, state FROM property_set WHERE id = ?";
var set = jdbcTemplate.query(query, new PropertySetMapper(), propertySetId);
if(set.isEmpty())
throw new IllegalArgumentException("Property set with id " + propertySetId + " does not exist");
return set.getFirst();
}
/**
* Mapper class for converting SQL query results into {@link PropertySet} objects.
*/

View file

@ -1,5 +1,6 @@
package de.avatic.lcc.repositories.rates;
import de.avatic.lcc.model.ValidityTuple;
import de.avatic.lcc.model.rates.ValidityPeriod;
import de.avatic.lcc.model.rates.ValidityPeriodState;
import org.springframework.jdbc.core.JdbcTemplate;
@ -69,8 +70,8 @@ public class ValidityPeriodRepository {
*/
@Transactional
public boolean invalidateById(Integer id) {
var affectedRows = jdbcTemplate.update("UPDATE validity_period SET state = ? WHERE id = ? AND state = ? ", ValidityPeriodState.INVALID.name(), id, ValidityPeriodState.EXPIRED.name());
return affectedRows > 0;
var affectedRows = jdbcTemplate.update("UPDATE validity_period SET state = ? WHERE id = ? AND state = ? ", ValidityPeriodState.INVALID.name(), id, ValidityPeriodState.EXPIRED.name());
return affectedRows > 0;
}
/**
@ -109,7 +110,7 @@ public class ValidityPeriodRepository {
String query = "SELECT * FROM validity_period WHERE state = ?";
var period = jdbcTemplate.query(query, new ValidityPeriodMapper(), ValidityPeriodState.VALID.name());
if(period.isEmpty())
if (period.isEmpty())
return Optional.empty();
return Optional.of(period.getFirst());
@ -134,17 +135,17 @@ public class ValidityPeriodRepository {
return getDraftPeriod().getId();
}
/**
* Checks if there are any draft matrix rates associated with the current draft validity period.
*
* @return {@code true} if draft matrix rates exist for the current draft validity period;
* {@code false} otherwise.
* {@code false} otherwise.
*/
@Transactional
public boolean hasRateDrafts() {
Integer id = getDraftPeriodId();
if(id == null) return false;
if (id == null) return false;
String query = "SELECT COUNT(*) FROM country_matrix_rate WHERE validity_period_id = ?";
var totalCount = jdbcTemplate.queryForObject(query, Integer.class, id);
@ -176,30 +177,30 @@ public class ValidityPeriodRepository {
String placeholders = String.join(",", Collections.nCopies(nodeIds.size(), "?"));
String validityPeriodSql = """
SELECT vp.*
FROM validity_period vp
INNER JOIN (
SELECT
cj.validity_period_id,
COUNT(DISTINCT p.supplier_node_id) as node_count
FROM
premise p
INNER JOIN
calculation_job cj ON p.id = cj.premise_id
WHERE
p.material_id = ?
AND p.supplier_node_id IN ("""
SELECT vp.*
FROM validity_period vp
INNER JOIN (
SELECT
cj.validity_period_id,
COUNT(DISTINCT p.supplier_node_id) as node_count
FROM
premise p
INNER JOIN
calculation_job cj ON p.id = cj.premise_id
WHERE
p.material_id = ?
AND p.supplier_node_id IN ("""
+ placeholders + """
)
GROUP BY
cj.validity_period_id
HAVING
COUNT(DISTINCT p.supplier_node_id) = ?
) matching_periods ON vp.id = matching_periods.validity_period_id
ORDER BY
vp.start_date DESC
LIMIT 1
""";
)
GROUP BY
cj.validity_period_id
HAVING
COUNT(DISTINCT p.supplier_node_id) = ?
) matching_periods ON vp.id = matching_periods.validity_period_id
ORDER BY
vp.start_date DESC
LIMIT 1
""";
Object[] params = new Object[1 + nodeIds.size() + 1];
@ -218,18 +219,20 @@ public class ValidityPeriodRepository {
return Optional.of(periods.getFirst());
}
public List<Integer> findValidityPeriodsWithReportByMaterialId(Integer materialId) {
public List<ValidityTuple> findValidityPeriodsWithReportByMaterialId(Integer materialId) {
String validityPeriodSql = """
SELECT DISTINCT cj.validity_period_id
SELECT DISTINCT cj.validity_period_id, cj.property_set_id
FROM premise p
INNER JOIN calculation_job cj ON p.id = cj.premise_id
WHERE p.material_id = ?
""";
return jdbcTemplate.queryForList(validityPeriodSql, Integer.class, materialId);
return jdbcTemplate.query(validityPeriodSql, (rs, cnt) -> new ValidityTuple(rs.getInt("validity_period_id"), rs.getInt("property_set_id")), materialId);
}
;
/**
* Maps rows of a {@link ResultSet} to {@link ValidityPeriod} objects.
*/

View file

@ -1,5 +1,6 @@
package de.avatic.lcc.repositories.users;
import de.avatic.lcc.model.ValidityTuple;
import de.avatic.lcc.model.nodes.Node;
import de.avatic.lcc.repositories.NodeRepository;
import de.avatic.lcc.util.exception.base.ForbiddenException;
@ -81,7 +82,7 @@ public class UserNodeRepository {
return Optional.of(nodes.getFirst());
}
public List<Node> findNodeListsForReportingByPeriodId(Integer materialId, Integer periodId) {
public List<Node> findNodeListsForReportingByPeriodId(Integer materialId, ValidityTuple tuple) {
String userSuppliersSql = """
SELECT DISTINCT un.*
FROM premise p
@ -89,10 +90,11 @@ public class UserNodeRepository {
INNER JOIN sys_user_node un ON p.user_supplier_node_id = un.id
WHERE p.material_id = ?
AND cj.validity_period_id = ?
AND cj.property_set_id = ?
AND p.user_supplier_node_id IS NOT NULL
""";
return jdbcTemplate.query(userSuppliersSql, new NodeMapper(), materialId, periodId);
return jdbcTemplate.query(userSuppliersSql, new NodeMapper(), materialId, tuple.periodId(), tuple.propertySetId());
}

View file

@ -7,8 +7,12 @@ import java.math.BigDecimal;
@Service
public class CustomApiService {
public BigDecimal getTariffRate(String hsCode, Integer countryId) {
return BigDecimal.valueOf(3);
return BigDecimal.valueOf(0.3);
//TODO implement me
}
public boolean validate(String hsCode) {
return true;
}
}

View file

@ -115,7 +115,7 @@ public class ContainerRateService {
dto.setDestination(nodeTransformer.toNodeDTO(nodeRepository.getById(entity.getToNodeId()).orElseThrow()));
var rates = new HashMap<String, Number>();
rates.put(ContainerType.HQ.name(), entity.getRateHc());
rates.put(ContainerType.HC.name(), entity.getRateHc());
rates.put(ContainerType.FEU.name(), entity.getRateFeu());
rates.put(ContainerType.TEU.name(), entity.getRateTeu());

View file

@ -72,6 +72,7 @@ public class DestinationService {
}
private List<Destination> createDestination(List<Integer> premiseIds, Integer destinationNodeId, Integer annualAmount, Number repackingCost, Number disposalCost, Number handlingCost) {
var premisesToProcess = premiseRepository.getPremisesById(premiseIds);
@ -96,6 +97,8 @@ public class DestinationService {
destination.setId(destinationRepository.insert(destination));
Node source = premise.getSupplierNodeId() == null ? userNodeRepository.getById(premise.getUserSupplierNodeId()).orElseThrow() : nodeRepository.getById(premise.getSupplierNodeId()).orElseThrow();
//noinspection SpringTransactionalMethodCallsInspection
findRouteAndSave(destination.getId(), destinationNode, source, premise.getSupplierNodeId() == null);
destinations.add(destination);
}
@ -152,7 +155,10 @@ public class DestinationService {
destinationUpdateDTO.getRepackingCost() == null ? null : BigDecimal.valueOf(destinationUpdateDTO.getRepackingCost().doubleValue()),
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.getD2d(), destinationUpdateDTO.getRateD2d() == null ? null : BigDecimal.valueOf(destinationUpdateDTO.getRateD2d().doubleValue()),
destinationUpdateDTO.getLeadtimeD2d() == null ? null : BigDecimal.valueOf(destinationUpdateDTO.getLeadtimeD2d())
);
}

View file

@ -8,6 +8,8 @@ import de.avatic.lcc.dto.calculation.edit.masterData.PackagingUpdateDTO;
import de.avatic.lcc.dto.calculation.edit.masterData.PriceUpdateDTO;
import de.avatic.lcc.model.calculations.CalculationJob;
import de.avatic.lcc.model.calculations.CalculationJobState;
import de.avatic.lcc.model.premises.Premise;
import de.avatic.lcc.model.premises.PremiseState;
import de.avatic.lcc.repositories.calculation.CalculationJobRepository;
import de.avatic.lcc.repositories.pagination.SearchQueryPagination;
import de.avatic.lcc.repositories.pagination.SearchQueryResult;
@ -17,6 +19,7 @@ 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.PreCalculationCheckService;
import de.avatic.lcc.service.transformer.generic.DimensionTransformer;
import de.avatic.lcc.service.transformer.premise.PremiseTransformer;
import de.avatic.lcc.util.exception.base.InternalErrorException;
@ -25,8 +28,10 @@ import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutionException;
@Service
public class PremisesService {
@ -41,8 +46,9 @@ public class PremisesService {
private final ValidityPeriodRepository validityPeriodRepository;
private final CalculationStatusService calculationStatusService;
private final CalculationExecutionService calculationExecutionService;
private final PreCalculationCheckService preCalculationCheckService;
public PremisesService(PremiseRepository premiseRepository, PremiseTransformer premiseTransformer, DimensionTransformer dimensionTransformer, DestinationService destinationService, CalculationJobRepository calculationJobRepository, PropertyRepository propertyRepository, PropertySetRepository propertySetRepository, ValidityPeriodRepository validityPeriodRepository, CalculationStatusService calculationStatusService, CalculationExecutionService calculationExecutionService) {
public PremisesService(PremiseRepository premiseRepository, PremiseTransformer premiseTransformer, DimensionTransformer dimensionTransformer, DestinationService destinationService, CalculationJobRepository calculationJobRepository, PropertyRepository propertyRepository, PropertySetRepository propertySetRepository, ValidityPeriodRepository validityPeriodRepository, CalculationStatusService calculationStatusService, CalculationExecutionService calculationExecutionService, PreCalculationCheckService preCalculationCheckService) {
this.premiseRepository = premiseRepository;
this.premiseTransformer = premiseTransformer;
this.dimensionTransformer = dimensionTransformer;
@ -53,6 +59,7 @@ public class PremisesService {
this.validityPeriodRepository = validityPeriodRepository;
this.calculationStatusService = calculationStatusService;
this.calculationExecutionService = calculationExecutionService;
this.preCalculationCheckService = preCalculationCheckService;
}
@Transactional(readOnly = true)
@ -74,16 +81,18 @@ public class PremisesService {
var userId = 1;
premiseRepository.checkOwner(premiseIds, userId);
return premiseRepository.getPremisesById(premiseIds).stream().map(premiseTransformer::toPremiseDetailDTO).toList();
return premiseRepository.getPremisesById(premiseIds).stream().filter(p -> p.getState().equals(PremiseState.DRAFT)).map(premiseTransformer::toPremiseDetailDTO).toList();
}
@Transactional
public Integer startCalculation(List<Integer> premises) {
public void startCalculation(List<Integer> premises) {
var userId = 1; // TODO get current user id
// todo check if user is allowed to schedule this
premises.forEach(preCalculationCheckService::doPrecheck);
var validSetId = propertySetRepository.getValidSetId();
var validPeriodId = validityPeriodRepository.getValidPeriodId().orElseThrow(() -> new InternalErrorException("no valid period found that is VALID"));
@ -102,17 +111,28 @@ public class PremisesService {
calculationIds.add(calculationJobRepository.insert(job));
});
//TODO set premise to completed.
premiseRepository.setStatus(premises, PremiseState.COMPLETED);
calculationIds.forEach(this::scheduleCalculation);
return calculationStatusService.schedule(calculationIds);
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)) {
throw new InternalErrorException("Calculation failed: " + jobResult.getException().getMessage(), new Exception(jobResult.getException()));
}
}
;
} catch (CompletionException | InterruptedException | ExecutionException e) {
throw new InternalErrorException("Calculation failed", e);
}
}
//TODO: scheduled should be set by worker thread that processes the job.
public void scheduleCalculation(Integer id) {
calculationJobRepository.setStateTo(id, CalculationJobState.SCHEDULED);
calculationExecutionService.calculateJob(id);
}
public CalculationStatus getCalculationStatus(Integer processId) {
@ -150,7 +170,7 @@ public class PremisesService {
var price = priceUpdateDTO.getPrice() == null ? null : BigDecimal.valueOf(priceUpdateDTO.getPrice().doubleValue());
var overseaShare = priceUpdateDTO.getOverseaShare() == null ? null : BigDecimal.valueOf(priceUpdateDTO.getOverseaShare().doubleValue());
premiseRepository.updatePrice(priceUpdateDTO.getPremiseIds(), price, priceUpdateDTO.getIncludeFcaFee(),overseaShare);
premiseRepository.updatePrice(priceUpdateDTO.getPremiseIds(), price, priceUpdateDTO.getIncludeFcaFee(), overseaShare);
}
@Transactional
@ -159,8 +179,19 @@ public class PremisesService {
var userId = 1;
premiseRepository.checkOwner(premiseIds, userId);
destinationService.deleteAllDestinationsByPremiseId(premiseIds, false);
premiseRepository.deletePremisesById(premiseIds);
// only delete drafts.
var toBeDeleted = premiseRepository.getPremisesById(premiseIds).stream().filter(p -> p.getState().equals(PremiseState.DRAFT)).map(Premise::getId).toList();
destinationService.deleteAllDestinationsByPremiseId(toBeDeleted, false);
premiseRepository.deletePremisesById(toBeDeleted);
}
public void archive(List<Integer> premiseIds) {
//TODO check authorization
var userId = 1;
premiseRepository.checkOwner(premiseIds, userId);
// only archive completed.
var premisses = premiseRepository.getPremisesById(premiseIds);
premiseRepository.setStatus(premisses.stream().filter(p -> p.getState().equals(PremiseState.COMPLETED)).map(Premise::getId).toList(), PremiseState.ARCHIVED);
}
}

View file

@ -1,12 +1,10 @@
package de.avatic.lcc.service.calculation.execution;
import de.avatic.lcc.calculationmodel.*;
import de.avatic.lcc.dto.calculation.CalculationStatus;
import de.avatic.lcc.dto.generic.ContainerType;
import de.avatic.lcc.dto.generic.RateType;
import de.avatic.lcc.model.calculations.CalculationJob;
import de.avatic.lcc.model.calculations.CalculationJobDestination;
import de.avatic.lcc.model.calculations.CalculationJobRouteSection;
import de.avatic.lcc.model.calculations.CalculationJobState;
import de.avatic.lcc.model.calculations.*;
import de.avatic.lcc.model.packaging.LoadCarrierType;
import de.avatic.lcc.model.packaging.PackagingDimension;
import de.avatic.lcc.model.premises.Premise;
@ -24,11 +22,14 @@ import de.avatic.lcc.repositories.properties.PropertyRepository;
import de.avatic.lcc.service.calculation.execution.steps.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.*;
import java.util.concurrent.CompletableFuture;
@Service
public class CalculationExecutionService {
@ -83,6 +84,19 @@ public class CalculationExecutionService {
.orElse(null);
}
@Transactional
@Async("taskExecutor")
public CompletableFuture<CalculationResult> launchJobCalculation(Integer calculationId) {
try {
calculateJob(calculationId);
} catch (Throwable e) {
//TODO put error in database.
calculationJobRepository.setStateTo(calculationId, CalculationJobState.EXCEPTION);
return CompletableFuture.completedFuture(new CalculationResult(calculationId, e));
}
return CompletableFuture.completedFuture(new CalculationResult(calculationId));
}
public void calculateJob(Integer calculationId) {
CalculationJob calculation = calculationJobRepository.getCalculationJob(calculationId).orElseThrow();
@ -116,7 +130,7 @@ public class CalculationExecutionService {
}
}
calculationJobRepository.setStateTo(calculationId, CalculationJobState.VALID);
log.info("Calculation job {} finished", calculationId);
}

View file

@ -13,6 +13,7 @@ import de.avatic.lcc.repositories.properties.PropertyRepository;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
@Service
public class AirfreightCalculationService {
@ -46,7 +47,7 @@ public class AirfreightCalculationService {
var result = new AirfreightResult();
var huCost = BigDecimal.valueOf(1.08).multiply(((getShippingWeight(hu).multiply(preCarriage.add(mainCarriage).add(postCarriage).add(terminalFee))).add(preCarriageFee).add(customsClearanceFee).add(handOverFee)));
var totalCost = huCost.multiply(BigDecimal.valueOf(destination.getAnnualAmount()).divide(BigDecimal.valueOf(premise.getHuUnitCount())));
var totalCost = huCost.multiply(BigDecimal.valueOf(destination.getAnnualAmount()).divide(BigDecimal.valueOf(premise.getHuUnitCount()), 2, RoundingMode.HALF_UP));
var airfreightShareCost = totalCost.multiply(BigDecimal.valueOf(airfreightShare));
result.setVolumetricWeight(BigDecimal.valueOf(getVolumeWeight(hu)));

View file

@ -147,7 +147,7 @@ public class ContainerCalculationService {
ContainerType.FEU, SystemPropertyMappingId.FEU_LOAD,
ContainerType.TRUCK, SystemPropertyMappingId.TRUCK_LOAD,
ContainerType.TEU, SystemPropertyMappingId.TEU_LOAD,
ContainerType.HQ, SystemPropertyMappingId.FEU_LOAD
ContainerType.HC, SystemPropertyMappingId.FEU_LOAD
);
SystemPropertyMappingId mappingId = mappings.get(containerType);

View file

@ -3,6 +3,7 @@ package de.avatic.lcc.service.calculation.execution.steps;
import de.avatic.lcc.calculationmodel.ContainerCalculationResult;
import de.avatic.lcc.calculationmodel.CustomResult;
import de.avatic.lcc.calculationmodel.SectionInfo;
import de.avatic.lcc.dto.generic.RateType;
import de.avatic.lcc.model.premises.Premise;
import de.avatic.lcc.model.premises.route.Destination;
import de.avatic.lcc.model.properties.CountryPropertyMappingId;
@ -117,6 +118,11 @@ public class CustomCostCalculationService {
List<SectionInfo> customSections = new ArrayList<>();
if (sections.size() == 1 && sections.getFirst().result().getRateType().equals(RateType.D2D)) {
customSections.add(sections.getFirst());
return customSections;
}
for (SectionInfo section : sections) {
if (!(CustomUnionType.EU == getCustomUnionByRouteNodeId(section.section().getFromRouteNodeId()) &&
CustomUnionType.EU == getCustomUnionByRouteNodeId(section.section().getToRouteNodeId()))) {

View file

@ -246,7 +246,7 @@ public class RouteSectionCostCalculationService {
private BigDecimal getContainerRate(ContainerRate rate, ContainerType containerType) {
switch (containerType) {
case HQ -> {
case HC -> {
return rate.getRateHc();
}
case FEU -> {

View file

@ -0,0 +1,178 @@
package de.avatic.lcc.service.precalculation;
import de.avatic.lcc.model.nodes.Node;
import de.avatic.lcc.model.premises.Premise;
import de.avatic.lcc.model.premises.route.Destination;
import de.avatic.lcc.model.premises.route.Route;
import de.avatic.lcc.repositories.NodeRepository;
import de.avatic.lcc.repositories.premise.DestinationRepository;
import de.avatic.lcc.repositories.premise.PremiseRepository;
import de.avatic.lcc.repositories.premise.RouteRepository;
import de.avatic.lcc.service.CustomApiService;
import de.avatic.lcc.util.exception.internalerror.PremiseValidationError;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
@Service
public class PreCalculationCheckService {
private final PremiseRepository premiseRepository;
private final CustomApiService customApiService;
private final DestinationRepository destinationRepository;
private final RouteRepository routeRepository;
private final NodeRepository nodeRepository;
public PreCalculationCheckService(PremiseRepository premiseRepository, CustomApiService customApiService, DestinationRepository destinationRepository, RouteRepository routeRepository, NodeRepository nodeRepository) {
this.premiseRepository = premiseRepository;
this.customApiService = customApiService;
this.destinationRepository = destinationRepository;
this.routeRepository = routeRepository;
this.nodeRepository = nodeRepository;
}
public void doPrecheck(Integer premiseId) {
var premise = premiseRepository.getPremiseById(premiseId).orElseThrow();
supplierCheck(premise);
materialCheck(premise);
packagingCheck(premise);
priceCheck(premise);
for (Destination destination : destinationRepository.getByPremiseId(premiseId)) {
var node = nodeRepository.getByDestinationId(destination.getId()).orElseThrow();
destinationCheck(destination, node);
var routes = routeRepository.getByDestinationId(destination.getId());
if(routes.isEmpty())
throw new PremiseValidationError("No route found for destination " + node.getName() + ". Cannot use standard routing.");
if(routes.stream().noneMatch(Route::getSelected))
throw new PremiseValidationError("No route selected for destination " + node.getName());
}
}
private void destinationCheck(Destination destination, Node node) {
if (destination.getAnnualAmount() == null || destination.getAnnualAmount() == 0)
throw new PremiseValidationError("In destination " + node.getName() + ": annual quantity must be greater than zero.");
if (destination.getD2d() == null)
throw new PremiseValidationError("In destination " + node.getName() +": D2D not set.");
if (destination.getD2d() == true) {
if (destination.getRateD2d() == null || destination.getRateD2d().equals(BigDecimal.ZERO)) {
throw new PremiseValidationError("In destination " + node.getName() + ": D2D Rate not set or set to zero.");
}
if (destination.getLeadTimeD2d() == null || destination.getLeadTimeD2d() == 0) {
throw new PremiseValidationError("In destination " + node.getName() + ": D2D lead time not set or set to zero.");
}
}
if (destination.getCountryId() == null || destination.getCountryId() == 0) {
throw new PremiseValidationError("In destination " + node.getName() +": destination country ID not set. Please contact administrator");
}
if (destination.getGeoLat() == null || destination.getGeoLng() == null) {
throw new PremiseValidationError("In destination " + node.getName() + ": destination geo location not set. Please contact administrator");
}
if (destination.getDisposalCost() != null && destination.getDisposalCost().equals(BigDecimal.ZERO)) {
throw new PremiseValidationError("In destination " + node.getName() + ": disposal cost set to zero.");
}
if (destination.getHandlingCost() != null && destination.getHandlingCost().equals(BigDecimal.ZERO)) {
throw new PremiseValidationError("In destination " + node.getName() + ": handling cost set to zero.");
}
if (destination.getRepackingCost() != null && destination.getRepackingCost().equals(BigDecimal.ZERO)) {
throw new PremiseValidationError("In destination " + node.getName() + ": repackaging cost set to zero.");
}
}
private void supplierCheck(Premise premise) {
if (premise.getSupplierNodeId() == null && premise.getUserSupplierNodeId() == null)
throw new PremiseValidationError("Supplier node is not set");
}
private void priceCheck(Premise premise) {
if (premise.getMaterialCost() == null || premise.getMaterialCost().equals(BigDecimal.ZERO)) {
throw new PremiseValidationError("MEK_A is not set or set to zero");
}
if (premise.getOverseaShare() == null || premise.getOverseaShare().equals(BigDecimal.ZERO)) {
throw new PremiseValidationError("Oversea share is not set or set to zero");
}
if (premise.getFcaEnabled() == null) {
throw new PremiseValidationError("FCA fee is not set");
}
}
private void packagingCheck(Premise premise) {
if (premise.getHuMixable() == null) {
throw new PremiseValidationError("Mixable not set.");
}
if (premise.getHuStackable() == null) {
throw new PremiseValidationError("Stackable not set.");
}
if (premise.getHuStackable() == false && premise.getHuMixable() == true) {
throw new PremiseValidationError("Stackable cannot be false, when mixable is true.");
}
if (premise.getIndividualHuLength() == null || premise.getIndividualHuLength() == 0) {
throw new PremiseValidationError("Packaging length is not set or set to zero");
}
if (premise.getIndividualHuWidth() == null || premise.getIndividualHuWidth() == 0) {
throw new PremiseValidationError("Packaging width is not set or set to zero");
}
if (premise.getIndividualHuHeight() == null || premise.getIndividualHuHeight() == 0) {
throw new PremiseValidationError("Packaging height is not set or set to zero");
}
if (premise.getIndividualHuWeight() == null || premise.getIndividualHuWeight() == 0) {
throw new PremiseValidationError("Packaging weight is not set or set to zero");
}
if (premise.getHuDisplayedWeightUnit() == null) {
throw new PremiseValidationError("Packaging weight unit is not set");
}
if (premise.getHuDisplayedDimensionUnit() == null) {
throw new PremiseValidationError("Packaging dimension unit is not set");
}
}
private void materialCheck(Premise premise) {
if (!customApiService.validate(premise.getHsCode()))
throw new PremiseValidationError("Invalid HS code.");
if (premise.getTariffRate() == null) {
throw new PremiseValidationError("Tariff Rate not set.");
}
}
}

View file

@ -35,6 +35,7 @@ public class DestinationTransformer {
dto.setD2d(destination.getD2d());
dto.setRateD2d(destination.getRateD2d());
dto.setLeadTimeD2d(destination.getLeadTimeD2d());
return dto;
}

View file

@ -1,6 +1,7 @@
package de.avatic.lcc.service.transformer.report;
import de.avatic.lcc.dto.generic.NodeType;
import de.avatic.lcc.dto.generic.RateType;
import de.avatic.lcc.dto.report.ReportDTO;
import de.avatic.lcc.dto.report.ReportDestinationDTO;
import de.avatic.lcc.dto.report.ReportEntryDTO;
@ -8,19 +9,26 @@ import de.avatic.lcc.dto.report.ReportSectionDTO;
import de.avatic.lcc.model.calculations.CalculationJob;
import de.avatic.lcc.model.calculations.CalculationJobDestination;
import de.avatic.lcc.model.calculations.CalculationJobRouteSection;
import de.avatic.lcc.model.nodes.Node;
import de.avatic.lcc.model.premises.Premise;
import de.avatic.lcc.model.properties.SystemPropertyMappingId;
import de.avatic.lcc.repositories.MaterialRepository;
import de.avatic.lcc.repositories.NodeRepository;
import de.avatic.lcc.repositories.calculation.CalculationJobDestinationRepository;
import de.avatic.lcc.repositories.calculation.CalculationJobRouteSectionRepository;
import de.avatic.lcc.repositories.premise.PremiseRepository;
import de.avatic.lcc.repositories.premise.RouteNodeRepository;
import de.avatic.lcc.repositories.properties.PropertyRepository;
import de.avatic.lcc.repositories.properties.PropertySetRepository;
import de.avatic.lcc.repositories.rates.ValidityPeriodRepository;
import de.avatic.lcc.repositories.users.UserNodeRepository;
import de.avatic.lcc.service.transformer.generic.MaterialTransformer;
import de.avatic.lcc.service.transformer.generic.NodeTransformer;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -35,8 +43,13 @@ public class ReportTransformer {
private final NodeTransformer nodeTransformer;
private final RouteNodeRepository routeNodeRepository;
private final PropertyRepository propertyRepository;
private final UserNodeRepository userNodeRepository;
private final MaterialRepository materialRepository;
private final MaterialTransformer materialTransformer;
private final PropertySetRepository propertySetRepository;
private final ValidityPeriodRepository validityPeriodRepository;
public ReportTransformer(CalculationJobDestinationRepository calculationJobDestinationRepository, CalculationJobRouteSectionRepository calculationJobRouteSectionRepository, PremiseRepository premiseRepository, NodeRepository nodeRepository, NodeTransformer nodeTransformer, RouteNodeRepository routeNodeRepository, PropertyRepository propertyRepository) {
public ReportTransformer(CalculationJobDestinationRepository calculationJobDestinationRepository, CalculationJobRouteSectionRepository calculationJobRouteSectionRepository, PremiseRepository premiseRepository, NodeRepository nodeRepository, NodeTransformer nodeTransformer, RouteNodeRepository routeNodeRepository, PropertyRepository propertyRepository, UserNodeRepository userNodeRepository, MaterialRepository materialRepository, MaterialTransformer materialTransformer, PropertySetRepository propertySetRepository, ValidityPeriodRepository validityPeriodRepository) {
this.calculationJobDestinationRepository = calculationJobDestinationRepository;
this.calculationJobRouteSectionRepository = calculationJobRouteSectionRepository;
this.premiseRepository = premiseRepository;
@ -44,6 +57,11 @@ public class ReportTransformer {
this.nodeTransformer = nodeTransformer;
this.routeNodeRepository = routeNodeRepository;
this.propertyRepository = propertyRepository;
this.userNodeRepository = userNodeRepository;
this.materialRepository = materialRepository;
this.materialTransformer = materialTransformer;
this.propertySetRepository = propertySetRepository;
this.validityPeriodRepository = validityPeriodRepository;
}
public ReportDTO toReportDTO(CalculationJob job) {
@ -59,10 +77,25 @@ public class ReportTransformer {
var weightedTotalCost = getWeightedTotalCosts(sections);
Premise premise = premiseRepository.getPremiseById(job.getPremiseId()).orElseThrow();
reportDTO.setMaterial(materialTransformer.toMaterialDTO(materialRepository.getById(premise.getMaterialId()).orElseThrow()));
var period = getPeriod(job);
reportDTO.setStartDate(period.startDate);
reportDTO.setEndDate(period.endDate);
Node sourceNode = null;
if (premise.getSupplierNodeId() != null)
sourceNode = nodeRepository.getById(premise.getSupplierNodeId()).orElseThrow();
if (premise.getUserSupplierNodeId() != null)
sourceNode = userNodeRepository.getById(premise.getUserSupplierNodeId()).orElseThrow();
reportDTO.setCost(getCostMap(job, destinations, weightedTotalCost, includeAirfreight));
reportDTO.setRisk(getRisk(job, destinations, weightedTotalCost, includeAirfreight));
reportDTO.setDestination(destinations.stream().map(d -> getDestinationDTO(d, sections.get(d.getId()), premise)).toList());
Node finalSourceNode = sourceNode;
reportDTO.setDestination(destinations.stream().map(d -> getDestinationDTO(d, sections.get(d.getId()), premise, finalSourceNode, includeAirfreight)).toList());
if (!reportDTO.getDestinations().isEmpty()) {
var source = reportDTO.getDestinations().getFirst().getSections().stream().map(ReportSectionDTO::getFromNode).filter(n -> n.getTypes().contains(NodeType.SOURCE)).findFirst().orElseThrow();
@ -73,6 +106,25 @@ public class ReportTransformer {
}
private TimePeriod getPeriod(CalculationJob job) {
var propertySet = propertySetRepository.getById(job.getPropertySetId());
var validityPeriod = validityPeriodRepository.getById(job.getValidityPeriodId());
var startDate = propertySet.getStartDate().isBefore(validityPeriod.getStartDate()) ? validityPeriod.getStartDate() : propertySet.getStartDate();
if(propertySet.getEndDate() == null)
return new TimePeriod(startDate, validityPeriod.getEndDate());
if(validityPeriod.getEndDate() == null)
return new TimePeriod(startDate, propertySet.getEndDate());
var endDate = propertySet.getEndDate().isAfter(validityPeriod.getEndDate()) ? validityPeriod.getEndDate() : propertySet.getEndDate();
return new TimePeriod(startDate, endDate);
}
;
private WeightedTotalCosts getWeightedTotalCosts(Map<Integer, List<CalculationJobRouteSection>> sectionsMap) {
BigDecimal totalPreRunCost = BigDecimal.ZERO;
@ -98,15 +150,14 @@ public class ReportTransformer {
return new WeightedTotalCosts(totalPreRunCost, totalMainRunCost, totalPostRunCost, totalCost);
}
private ReportDestinationDTO getDestinationDTO(CalculationJobDestination destination, List<CalculationJobRouteSection> sections, Premise premise) {
private ReportDestinationDTO getDestinationDTO(CalculationJobDestination destination, List<CalculationJobRouteSection> sections, Premise premise, Node sourceNode, boolean includeAirfreight) {
var destinationNode = nodeRepository.getByDestinationId(destination.getPremiseDestinationId()).orElseThrow();
var dimensionUnit = premise.getHuDisplayedDimensionUnit();
var weightUnit = premise.getHuDisplayedWeightUnit();
ReportDestinationDTO destinationDTO = new ReportDestinationDTO();
destinationDTO.setSections(sections.stream().map(this::getSection).toList());
destinationDTO.setSections(sections.stream().map(s -> getSection(s, sourceNode, destinationNode, premise)).toList());
var totalAnnualCost = sections.stream().map(CalculationJobRouteSection::getAnnualCost).reduce(BigDecimal.ZERO, BigDecimal::add);
destinationDTO.getSections().forEach(s -> {
@ -132,9 +183,11 @@ public class ReportTransformer {
destinationDTO.setLayer(destination.getLayerCount());
destinationDTO.setOverseaShare(premise.getOverseaShare().doubleValue());
destinationDTO.setSafetyStock(destination.getSafetyStock().doubleValue());
destinationDTO.setSafetyStock(destination.getSafetyStockInDays().intValue());
destinationDTO.setTransportTime(destination.getTotalTransitTime().doubleValue());
destinationDTO.setAirFreightShare(destination.getAirFreightShare().doubleValue());
if (includeAirfreight)
destinationDTO.setAirFreightShare(destination.getAirFreightShare().doubleValue());
CalculationJobRouteSection mainRun = sections.stream().filter(CalculationJobRouteSection::getMainRun).findFirst().orElse(null);
@ -151,13 +204,20 @@ public class ReportTransformer {
return destinationDTO;
}
private ReportSectionDTO getSection(CalculationJobRouteSection section) {
private ReportSectionDTO getSection(CalculationJobRouteSection section, Node sourceNode, Node destinationNode, Premise premise) {
ReportSectionDTO sectionDTO = new ReportSectionDTO();
sectionDTO.setId(section.getId());
sectionDTO.setTransportType(section.getTransportType());
sectionDTO.setFromNode(nodeTransformer.toNodeDTO(routeNodeRepository.getFromNodeBySectionId(section.getPremiseRouteSectionId()).orElseThrow()));
sectionDTO.setToNode(nodeTransformer.toNodeDTO(routeNodeRepository.getToNodeBySectionId(section.getPremiseRouteSectionId()).orElseThrow()));
if (!section.getRateType().equals(RateType.D2D)) {
sectionDTO.setFromNode(nodeTransformer.toNodeDTO(routeNodeRepository.getFromNodeBySectionId(section.getPremiseRouteSectionId()).orElseThrow()));
sectionDTO.setToNode(nodeTransformer.toNodeDTO(routeNodeRepository.getToNodeBySectionId(section.getPremiseRouteSectionId()).orElseThrow()));
} else {
sectionDTO.setFromNode(nodeTransformer.toNodeDTO(sourceNode));
sectionDTO.setToNode(nodeTransformer.toNodeDTO(destinationNode));
}
sectionDTO.setRateType(section.getRateType());
@ -170,13 +230,12 @@ public class ReportTransformer {
sectionDTO.setDuration(duration);
var cost = new ReportEntryDTO();
cost.setTotal(section.getAnnualCost());
cost.setTotal(section.getRate());
sectionDTO.setCost(cost);
return sectionDTO;
}
private Map<String, ReportEntryDTO> getRisk(CalculationJob job, List<CalculationJobDestination> destination, WeightedTotalCosts weightedTotalCost, boolean includeAirfreight) {
Map<String, ReportEntryDTO> risk = new HashMap<>();
@ -232,8 +291,8 @@ public class ReportTransformer {
var totalValue = annualAmount.equals(BigDecimal.ZERO) ? BigDecimal.ZERO : destination.stream().map(CalculationJobDestination::getTotalCost).reduce(BigDecimal.ZERO, BigDecimal::add).divide(annualAmount, 4, RoundingMode.HALF_UP);
if(includeAirfreight) {
totalValue =totalValue.add(airfreightValue);
if (includeAirfreight) {
totalValue = totalValue.add(airfreightValue);
}
ReportEntryDTO total = new ReportEntryDTO();
@ -306,6 +365,8 @@ public class ReportTransformer {
return cost;
}
private record TimePeriod(LocalDateTime startDate, LocalDateTime endDate) {
}
private record WeightedTotalCosts(BigDecimal totalPreRunCost, BigDecimal totalMainRunCost,
BigDecimal totalPostRunCost, BigDecimal totalCost) {

View file

@ -0,0 +1,12 @@
package de.avatic.lcc.util.exception.internalerror;
import de.avatic.lcc.util.exception.base.InternalErrorException;
public class PremiseValidationError extends InternalErrorException {
public PremiseValidationError(String message) {
super(message);
}
}

View file

@ -466,6 +466,7 @@ CREATE TABLE IF NOT EXISTS calculation_job
property_set_id INT NOT NULL,
job_state CHAR(10) NOT NULL CHECK (job_state IN
('CREATED', 'SCHEDULED', 'VALID', 'INVALID', 'EXCEPTION')),
error_id INT DEFAULT NULL,
user_id INT NOT NULL,
FOREIGN KEY (premise_id) REFERENCES premise (id),
FOREIGN KEY (validity_period_id) REFERENCES validity_period (id),
@ -539,7 +540,7 @@ CREATE TABLE IF NOT EXISTS calculation_job_destination
CREATE TABLE IF NOT EXISTS calculation_job_route_section
(
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
premise_route_section_id INT NOT NULL,
premise_route_section_id INT,
calculation_job_destination_id INT NOT NULL,
transport_type CHAR(16) CHECK (transport_type IN
('RAIL', 'SEA', 'ROAD', 'POST_RUN', 'MATRIX', 'D2D')),