FRONTEND:

- Introduced `StagedRatesStore` with support for staged changes and expiry handling.
- Updated `Rates.vue` to integrate staged rates check and display.
- Enhanced `TableView` with flag support for improved visuals.
- Adjusted bulk operation timer behavior to fix async issues.
- Incorporated Kosovo flag asset and updated styles for better layout.
BACKEND:
- Added Matrix/Container Rate import service.
- Added renewal function for expired rates.
This commit is contained in:
Jan 2025-09-25 23:31:12 +02:00
parent 2f7df47df4
commit 8698031689
28 changed files with 790 additions and 125 deletions

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.9 KiB

View file

@ -1,12 +1,20 @@
<template> <template>
<div class="flag-container"> <div class="flag-container">
<img :src="path" :alt="iso" :class="flagSizeClass"> <tooltip v-if="tooltipText != null" :text="tooltipText"><img :src="path" :alt="iso" :class="flagSizeClass"></tooltip>
<img v-else :src="path" :alt="iso" :class="flagSizeClass">
</div> </div>
</template> </template>
<script> <script>
import Tooltip from "@/components/UI/Tooltip.vue";
export default { export default {
components: {Tooltip},
props: { props: {
tooltipText: {
type: String,
default: null,
},
iso: { iso: {
type: String, type: String,
required: true required: true

View file

@ -67,7 +67,9 @@ export default {
}, },
methods: { methods: {
setActiveTab(index) { setActiveTab(index) {
if (index >= 0 && index < this.tabs.length) { if (index >= 0 && index < this.tabs.length) {
this.hasChanged = true; this.hasChanged = true;
this.activeTab = index; this.activeTab = index;
this.$emit('tab-changed', { this.$emit('tab-changed', {

View file

@ -32,15 +32,20 @@
</tr> </tr>
</tbody> </tbody>
<tbody v-else> <tbody v-else>
<tr v-for="(item, index) in data" :key="index" class="table-row" @click="$emit('row-click', item)" :class="{'table-row--hover': mouseOver}"> <tr v-for="(item, index) in data" :key="index" class="table-row" @click="$emit('row-click', item)"
:class="{'table-row--hover': mouseOver}">
<td v-for="column in columns" :key="column.key" class="table-cell" :class="getAlignment(column.align)"> <td v-for="column in columns" :key="column.key" class="table-cell" :class="getAlignment(column.align)">
<span v-if="column.iconResolver == null">{{ getCellValue(item, column) }}</span>
<flag v-if="column.showFlag" :tooltip-text="getCellValue(item, column)" :iso="getCellValue(item, column)"></flag>
<span v-else-if="column.iconResolver == null">{{ getCellValue(item, column) }}</span>
<component v-else <component v-else
:is="getCellValue(item, column)" :is="getCellValue(item, column)"
weight="regular" weight="regular"
size="24" size="24"
class="table-icon" class="table-icon"
/> />
</td> </td>
</tr> </tr>
</tbody> </tbody>
@ -62,10 +67,11 @@ import BasicButton from "@/components/UI/BasicButton.vue";
import SearchBar from "@/components/UI/SearchBar.vue"; import SearchBar from "@/components/UI/SearchBar.vue";
import Box from "@/components/UI/Box.vue"; import Box from "@/components/UI/Box.vue";
import Pagination from "@/components/UI/Pagination.vue"; import Pagination from "@/components/UI/Pagination.vue";
import Flag from "@/components/UI/Flag.vue";
export default { export default {
name: "TableView", name: "TableView",
components: {Pagination, Box, SearchBar, BasicButton, Checkbox, Spinner}, components: {Flag, Pagination, Box, SearchBar, BasicButton, Checkbox, Spinner},
emits: ['row-click'], emits: ['row-click'],
props: { props: {
dataSource: { dataSource: {
@ -200,7 +206,6 @@ export default {
gap: 1.6rem; gap: 1.6rem;
overflow: visible; overflow: visible;
border-radius: 0.8rem; border-radius: 0.8rem;
box-shadow: 0 0.4rem 0.6rem -0.1rem rgba(0, 0, 0, 0.1);
background-color: #FFFFFF; background-color: #FFFFFF;
} }
@ -234,7 +239,7 @@ export default {
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08rem; letter-spacing: 0.08rem;
text-align: left; text-align: left;
padding: 2.4rem; padding: 1.6rem 1.6rem 1.6rem 0;
} }
.table-row { .table-row {
@ -244,12 +249,18 @@ export default {
} }
.table-cell { .table-cell {
padding: 2.4rem; padding: 0.8rem;
background-color: transparent; background-color: transparent;
border-bottom: 0.1rem solid #E3EDFF; border-bottom: 0.1rem solid #E3EDFF;
text-align: right; text-align: right;
} }
.table-cell-content {
display: flex;
align-items: center;
gap: 0.8rem;
}
.table-cell--left { .table-cell--left {
text-align: left; text-align: left;
} }

View file

@ -25,6 +25,7 @@
<div class="bulk-operation-caption">validity period</div> <div class="bulk-operation-caption">validity period</div>
<div class="bulk-operation-data"> <div class="bulk-operation-data">
<div class="period-select-container">
<dropdown :options="periods" <dropdown :options="periods"
emptyText="No property set available" emptyText="No property set available"
class="period-select" class="period-select"
@ -32,6 +33,7 @@
v-model="selectedPeriod" v-model="selectedPeriod"
:disabled="!showValidityPeriod" :disabled="!showValidityPeriod"
></dropdown> ></dropdown>
</div>
</div> </div>
<div class="bulk-operation-action-footer"> <div class="bulk-operation-action-footer">
@ -114,6 +116,19 @@ export default {
processId: null, processId: null,
} }
}, },
props: {
isSelected: {
type: Boolean,
default: false
}
},
watch: {
async isSelected(newVal) {
if(newVal === true)
await this.validityPeriodStore.loadPeriods();
await this.bulkOperationStore.manageStatus();
}
},
computed: { computed: {
...mapStores(useValidityPeriodStore, useBulkOperationStore), ...mapStores(useValidityPeriodStore, useBulkOperationStore),
showValidityPeriod() { showValidityPeriod() {
@ -133,28 +148,23 @@ export default {
periods() { periods() {
const periods = []; const periods = [];
const ps = this.validityPeriodStore.getPeriods; const vp = this.validityPeriodStore.getPeriods;
const current = this.validityPeriodStore.getCurrentPeriodId;
if ((ps ?? null) === null) { if ((vp ?? null) === null) {
return null; return null;
} }
for (const p of ps) { for (const p of vp) {
const value = (p.state === "DRAFT" || p.state === "VALID") ? "CURRENT" : `${this.buildDate(p.start_date)} - ${this.buildDate(p.end_date)} ${p.state === "INVALID" ? "(INVALID)" : ""}`; const value = (p.state === "DRAFT") ? "DRAFT" : (p.state === "VALID") ? "CURRENT" : `${this.buildDate(p.start_date)} - ${this.buildDate(p.end_date)} ${p.state === "INVALID" ? "(INVALID)" : ""}`;
const period = {id: p.id, value: value}; const period = {id: p.id, value: value};
if (p.state !== "VALID" || p.id === current)
periods.push(period); periods.push(period);
} }
return periods; return periods;
} }
}, },
created() {
this.validityPeriodStore.loadPeriods();
this.bulkOperationStore.updateStatus();
},
methods: { methods: {
async fetchFile(id) { async fetchFile(id) {
logger.info(`Fetching file ${id}`); logger.info(`Fetching file ${id}`);
@ -316,4 +326,14 @@ export default {
color: #002F54; color: #002F54;
} }
.period-select-container {
display: flex;
justify-content: flex-end;
align-items: center;
margin-bottom: 1rem;
gap: 1.6rem;
font-size: 1.4rem;
min-width: 30rem;
}
</style> </style>

View file

@ -2,44 +2,52 @@
<div class="country-container"> <div class="country-container">
<div class="country-search-container"> <div class="country-search-container">
<div>Find country to edit:</div> <div>Find country to edit:</div>
<div class="country-search-box-container"><autosuggest-searchbar <div class="country-search-box-container">
:fetch-suggestions="query" <autosuggest-searchbar
flag-resolver="iso_code" :fetch-suggestions="query"
placeholder="Search country..." flag-resolver="iso_code"
no-results-text="no country found" placeholder="Search country..."
variant="flags" no-results-text="no country found"
title-resolver="name" variant="flags"
subtitle-resolver="iso_code" title-resolver="name"
:reset-on-select="true" subtitle-resolver="iso_code"
@selected="selectCountry" :reset-on-select="true"
></autosuggest-searchbar></div> @selected="selectCountry"
></autosuggest-searchbar>
</div>
</div> </div>
<div class="country-edit-container"> <div class="country-edit-container">
<div class="country-info-container"> <collapsible-box v-if="selectedCountry" :is-collapsable="false" variant="border" :title="selectedCountry?.name ?? 'no country'">
<box v-if="selectedCountry" class="country-info-box"> <div class="country-info-container">
<flag size="xl" :iso="selectedCountry.iso_code"></flag> <box v-if="selectedCountry" class="country-info-box">
<div class="country-info-text" > <flag size="xl" :iso="selectedCountry.iso_code"></flag>
<div class="country-info-text-caption">{{ selectedCountry.name }} ({{ selectedCountry.iso_code}})</div> <div class="country-info-text">
<div class="country-info-text-subline">{{ selectedCountry.region_code}}</div> <div class="country-info-text-caption">{{ selectedCountry.name }} ({{ selectedCountry.iso_code }})</div>
</div> <div class="country-info-text-subline">{{ selectedCountry.region_code }}</div>
</div>
</box> </box>
</div> </div>
<div v-if="selectedCountry" > <div v-if="selectedCountry">
<transition name="properties-fade" mode="out-in"> <transition name="properties-fade" mode="out-in">
<div v-if="!loading" class="properties-list"> <div v-if="!loading" class="properties-list">
<transition-group name="property-item" tag="div"> <transition-group name="property-item" tag="div">
<property v-for="property of properties" <property v-for="property of properties"
:key="`${selectedPeriodId}-${selectedCountry.id}-${property.external_mapping_id}`" :key="`${selectedPeriodId}-${selectedCountry.id}-${property.external_mapping_id}`"
:property="property" :property="property"
:disabled="!isValidPeriodActive" :disabled="!isValidPeriodActive"
@save="saveProperty"></property> @save="saveProperty"></property>
</transition-group> </transition-group>
</div> </div>
</transition> </transition>
</div>
</collapsible-box>
<div v-else>
No country selected.
</div> </div>
</div> </div>
</div> </div>
</template> </template>
@ -57,10 +65,11 @@ import Tooltip from "@/components/UI/Tooltip.vue";
import ModalDialog from "@/components/UI/ModalDialog.vue"; import ModalDialog from "@/components/UI/ModalDialog.vue";
import Dropdown from "@/components/UI/Dropdown.vue"; import Dropdown from "@/components/UI/Dropdown.vue";
import {usePropertiesStore} from "@/store/properties.js"; import {usePropertiesStore} from "@/store/properties.js";
import CollapsibleBox from "@/components/UI/CollapsibleBox.vue";
export default { export default {
name: "CountryProperties", name: "CountryProperties",
components: {Dropdown, ModalDialog, Tooltip, IconButton, Property, AutosuggestSearchbar, Box, Flag}, components: {CollapsibleBox, Dropdown, ModalDialog, Tooltip, IconButton, Property, AutosuggestSearchbar, Box, Flag},
computed: { computed: {
periods() { periods() {
const periods = []; const periods = [];
@ -167,7 +176,7 @@ export default {
display: flex; display: flex;
justify-content: flex-start; justify-content: flex-start;
gap: 1.6rem; gap: 1.6rem;
margin: 3.2rem 0 ; margin: 3.2rem 0;
} }
.country-info-box { .country-info-box {
@ -217,6 +226,7 @@ export default {
} }
.country-edit-container { .country-edit-container {
padding-top: 2.4rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.6rem; gap: 1.6rem;
@ -225,6 +235,4 @@ export default {
} }
</style> </style>

View file

@ -1,5 +1,9 @@
<template> <template>
<table-view ref="tableViewRef" :data-source="fetch" :columns="materialColumns" :page="pagination.page" :page-size="pageSize" :page-count="pagination.pageCount" :total-count="pagination.totalCount"></table-view> <div>
<table-view ref="tableViewRef" :data-source="fetch" :columns="materialColumns" :page="pagination.page"
:page-size="pageSize" :page-count="pagination.pageCount"
:total-count="pagination.totalCount"></table-view>
</div>
</template> </template>
@ -12,10 +16,20 @@ export default {
name: "materials", name: "materials",
components: {TableView}, components: {TableView},
computed: { computed: {
... mapStores(useMaterialStore), ...mapStores(useMaterialStore),
}, },
created() { props: {
this.materialStore.updateMaterials() isSelected: {
type: Boolean,
default: false
}
},
watch: {
async isSelected(newVal) {
if(newVal === true) {
await this.materialStore.updateMaterials();
}
}
}, },
methods: { methods: {
async fetch(query) { async fetch(query) {

View file

@ -1,5 +1,10 @@
<template> <template>
<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> <div>
<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>
</div>
</template> </template>
@ -10,9 +15,22 @@ import {useNodeStore} from "@/store/node.js";
export default { export default {
name: "Nodes", name: "Nodes",
props: {
isSelected: {
type: Boolean,
default: false
}
},
watch: {
async isSelected(newVal) {
if(newVal === true) {
await this.nodeStore.loadNodes();
}
}
},
components: {TableView}, components: {TableView},
computed: { computed: {
... mapStores(useNodeStore), ...mapStores(useNodeStore),
}, },
created() { created() {
this.nodeStore.loadNodes() this.nodeStore.loadNodes()
@ -41,13 +59,14 @@ export default {
}, },
{ {
key: 'country.iso_code', key: 'country.iso_code',
label: 'Country (ISO Code)', label: 'Country',
showFlag: true
}, },
{ {
key: 'types', key: 'types',
label: 'Type', label: 'Type',
formatter: (value) => { formatter: (value) => {
return value.map(v => v.toUpperCase()).join(', '); return value.map(v => String(v).charAt(0).toUpperCase() + String(v).slice(1).toLowerCase()).join(', ');
} }
}, },
{ {
@ -63,7 +82,7 @@ export default {
pageCount: 1, pageCount: 1,
totalCount: 0 totalCount: 0
}, },
pageSize: 10 pageSize: 20
} }
} }
} }

View file

@ -1,6 +1,6 @@
<template> <template>
<div class="properties-container"> <div class="properties-container">
<staged-changes></staged-changes>
<div v-if="!loading" class="period-select-container"> <div v-if="!loading" class="period-select-container">
<span class="period-select-caption">Property set:</span> <span class="period-select-caption">Property set:</span>
<dropdown :options="periods" <dropdown :options="periods"
@ -34,9 +34,7 @@
</div> </div>
</transition> </transition>
<box variant="border"> <country-properties v-if="!loading"></country-properties>
<country-properties></country-properties>
</box>
</div> </div>
@ -56,12 +54,15 @@ import {usePropertySetsStore} from "@/store/propertySets.js";
import {useCountryStore} from "@/store/country.js"; import {useCountryStore} from "@/store/country.js";
import CountryProperties from "@/components/layout/config/CountryProperties.vue"; import CountryProperties from "@/components/layout/config/CountryProperties.vue";
import Box from "@/components/UI/Box.vue"; import Box from "@/components/UI/Box.vue";
import StagedChanges from "@/components/layout/config/StagedChanges.vue";
export default { export default {
name: "Properties", name: "Properties",
components: { components: {
StagedChanges,
Box, Box,
CountryProperties, NotificationBar, ModalDialog, Tooltip, IconButton, BasicButton, Dropdown, Property}, CountryProperties, NotificationBar, ModalDialog, Tooltip, IconButton, BasicButton, Dropdown, Property
},
data() { data() {
return { return {
modalDialogDeleteState: false, modalDialogDeleteState: false,

View file

@ -1,6 +1,8 @@
<template> <template>
<div class="container-rate-container"> <div class="container-rate-container">
<staged-rates ref="stagedRatesRef"></staged-rates>
<div class="container-rate-header"> <div class="container-rate-header">
<div class="container-rate-search-container"> <div class="container-rate-search-container">
@ -32,7 +34,9 @@
</div> </div>
<table-view ref="tableViewRef" :data-source="fetch" :columns="selectedTypeColumns" :page="pagination.page" :page-size="pageSize" :page-count="pagination.pageCount" :total-count="pagination.totalCount"></table-view> <table-view ref="tableViewRef" :data-source="fetch" :columns="selectedTypeColumns" :page="pagination.page"
:page-size="pageSize" :page-count="pagination.pageCount"
:total-count="pagination.totalCount"></table-view>
</div> </div>
@ -52,10 +56,30 @@ import TableView from "@/components/UI/TableView.vue";
import RadioOption from "@/components/UI/RadioOption.vue"; import RadioOption from "@/components/UI/RadioOption.vue";
import {useMatrixRateStore} from "@/store/matrixRate.js"; import {useMatrixRateStore} from "@/store/matrixRate.js";
import {useContainerRateStore} from "@/store/containerRate.js"; import {useContainerRateStore} from "@/store/containerRate.js";
import StagedRates from "@/components/layout/config/StagedRates.vue";
export default { export default {
name: "Rates", name: "Rates",
components: {RadioOption, TableView, DataTable, AutosuggestSearchbar, ModalDialog, Tooltip, IconButton, Dropdown}, props: {
isSelected: {
type: Boolean,
default: false
}
},
watch: {
async isSelected(newVal) {
if (newVal === true) {
await this.validityPeriodStore.loadPeriods();
await this.matrixRateStore.setQuery();
await this.containerRateStore.setQuery();
this.$refs.stagedRatesRef.checkChanges();
this.pagination = this.rateType === 'container' ? this.containerRateStore.getPagination : this.matrixRateStore.getPagination;
}
}
},
components: {
StagedRates,
RadioOption, TableView, DataTable, AutosuggestSearchbar, ModalDialog, Tooltip, IconButton, Dropdown},
computed: { computed: {
...mapStores(useValidityPeriodStore, useMatrixRateStore, useContainerRateStore), ...mapStores(useValidityPeriodStore, useMatrixRateStore, useContainerRateStore),
loadingPeriods() { loadingPeriods() {
@ -75,6 +99,7 @@ export default {
return this.rateTypeValue; return this.rateTypeValue;
}, },
set(value) { set(value) {
if (value === "matrix") { if (value === "matrix") {
this.selectedTypeColumns = this.matrixColumns; this.selectedTypeColumns = this.matrixColumns;
} else { } else {
@ -91,27 +116,24 @@ export default {
}, },
async set(value) { async set(value) {
this.validityPeriodStore.setSelectedPeriod(value); this.validityPeriodStore.setSelectedPeriod(value);
// await this.propertiesStore.loadProperties(value); this.$refs.tableViewRef.reload('', 1);
// await this.countryStore.selectPeriod(value);
} }
}, },
periods() { periods() {
const periods = []; const periods = [];
const vp = this.validityPeriodStore.getPeriods; const vp = this.validityPeriodStore.getPeriods;
// const current = this.propertySetsStore.getCurrentPeriodId; //TODO
const current = 1;
if ((vp ?? null) === null) { if ((vp ?? null) === null) {
return null; return null;
} }
for (const p of vp) { for (const p of vp) {
const value = (p.state === "DRAFT") ? "DRAFT" : (p.state === "VALID") ? "CURRENT" : `${this.buildDate(p.start_date)} - ${this.buildDate(p.end_date)} ${p.state === "INVALID" ? "(INVALID)" : ""}`; const value = (p.state === "DRAFT") ? "DRAFT" : (p.state === "VALID") ? "CURRENT" : `${this.buildDate(p.start_date)} - ${this.buildDate(p.end_date)} ${p.state === "INVALID" ? "(INVALID)" : ""}`;
const period = {id: p.id, value: value}; const period = {id: p.id, value: value};
if (p.state !== "VALID" || p.id === current)
periods.push(period); periods.push(period);
} }
return periods; return periods;
@ -119,18 +141,18 @@ export default {
}, },
data() { data() {
return { return {
pageSize: 10, pageSize: 20,
pagination: { page: 1, pageCount: 10, totalCount: 1 }, pagination: {page: 1, pageCount: 1, totalCount: 1},
rateTypeValue: "container", rateTypeValue: "container",
selectedTypeColumns: [], selectedTypeColumns: [],
matrixColumns: [ matrixColumns: [
{key: 'source.iso_code', label: 'From Country (ISO code)'}, {key: 'source.iso_code', label: 'From Country (ISO code)' },
{key: 'destination.iso_code', label: 'To Country (ISO code)'}, {key: 'destination.iso_code', label: 'To Country (ISO code)'},
{key: 'rate', align: 'right', label: 'Rate [EUR/km]'}, {key: 'rate', align: 'right', label: 'Rate [EUR/km]'},
], ],
containerColumns: [ containerColumns: [
{ {
key: 'type', label: 'Type', align: 'center', iconResolver: (rawValue, item) => { key: 'type', label: 'Type', align: 'center', iconResolver: (rawValue, _) => {
if (rawValue === "SEA") { if (rawValue === "SEA") {
return "PhBoat"; return "PhBoat";
@ -143,10 +165,10 @@ export default {
}, },
{key: 'source.name', align: 'left', label: 'From node'}, {key: 'source.name', align: 'left', label: 'From node'},
{key: 'destination.name', align: 'left', label: 'To node'}, {key: 'destination.name', align: 'left', label: 'To node'},
{key: 'lead_time', align: 'right', label: 'Lead time [days]'}, {key: 'rates.TEU', align: 'right', label: '20 ft. GP rate [EUR]'},
{key: 'rates.TEU', align: 'right',label: '20 ft. GP rate [EUR]'}, {key: 'rates.FEU', align: 'right', label: '40 ft. GP rate [EUR]'},
{key: 'rates.FEU', align: 'right',label: '40 ft. GP rate [EUR]'},
{key: 'rates.HC', align: 'right', label: '40 ft. HC rate [EUR]'}, {key: 'rates.HC', align: 'right', label: '40 ft. HC rate [EUR]'},
{key: 'lead_time', align: 'right', label: 'Lead time [days]'},
], ],
modalDialogDeleteState: false, modalDialogDeleteState: false,
selectedPeriodId: null selectedPeriodId: null
@ -157,6 +179,7 @@ export default {
await this.validityPeriodStore.loadPeriods(); await this.validityPeriodStore.loadPeriods();
await this.matrixRateStore.setQuery(); await this.matrixRateStore.setQuery();
await this.containerRateStore.setQuery(); await this.containerRateStore.setQuery();
this.pagination = this.rateType === 'container' ? this.containerRateStore.getPagination : this.matrixRateStore.getPagination;
}, },
methods: { methods: {
async fetch(query) { async fetch(query) {
@ -192,7 +215,6 @@ export default {
<style scoped> <style scoped>
.table-view { .table-view {
margin-top: 1.6rem; margin-top: 1.6rem;
} }
@ -218,6 +240,7 @@ export default {
margin-bottom: 1rem; margin-bottom: 1rem;
gap: 1.6rem; gap: 1.6rem;
font-size: 1.4rem; font-size: 1.4rem;
} }
.container-rate-search-searchbar { .container-rate-search-searchbar {
@ -231,6 +254,7 @@ export default {
margin-bottom: 1rem; margin-bottom: 1rem;
gap: 1.6rem; gap: 1.6rem;
font-size: 1.4rem; font-size: 1.4rem;
min-width: 50rem;
} }
.period-select-caption { .period-select-caption {

View file

@ -0,0 +1,135 @@
<template>
<div>
<transition name="fade">
<div v-if="showSelf" class="staged-changes-container">
<div class="staged-changes-info">
<ph-warning size="18px"></ph-warning>
{{ message }}
</div>
<div class="staged-changes-save">
<icon-button icon="floppy-disk" @click="applyChanges" variant="blue"></icon-button>
</div>
</div>
</transition>
<modal-dialog :title="modalTitle" dismiss-text="No" accept-text="Yes"
:state="modalDialogStagedChangesState"
:message="modalText"
@click="applyChangesModalClick"
>
</modal-dialog>
</div>
</template>
<script>
import IconButton from "@/components/UI/IconButton.vue";
import ModalDialog from "@/components/UI/ModalDialog.vue";
import {mapStores} from "pinia";
import {usePropertiesStore} from "@/store/properties.js";
import {useStagedRatesStore} from "@/store/stagedRates.js";
export default {
name: "StagedRates",
components: {ModalDialog, IconButton},
data() {
return {
modalDialogStagedChangesState: false,
}
},
computed: {
...mapStores(useStagedRatesStore, usePropertiesStore),
showSelf() {
return this.stagedChanges || this.expiresSoon;
},
stagedChanges() {
return this.stagedRatesStore.hasStagedChanges;
},
expiresSoon() {
return this.stagedRatesStore.expiresSoon;
},
message() {
if(this.stagedChanges)
return "There are changes to kilometer rates and/or container rates. Please review them thoroughly and press save icon to apply them.";
else
return "The validity of the rates will expire soon. Please upload new rates or extend the validity of the current rates by clicking the save icon.";
},
modalTitle() {
if(this.stagedChanges)
return "Do you really want to apply the current changes?";
else
return "Do you really want to extend the validity of the current rates?";
},
modalText() {
if(this.stagedChanges)
return "As soon as you change the kilometer rates or container rates, new calculations are no longer comparable with previous calculations. " +
"Therefore, a new \"validity period\" is created. Only calculations that were created with the same \"validity period\" can be compared within reporting. Would you like to continue?";
else
return "The accuracy of logistics calculations can only be ensured if transportation costs are regularly updated to the correct values. You should only extend the validity period if transportation costs have not changed. " +
"Do you want to extend the validity of the current rates?";
}
},
methods: {
applyChanges() {
this.modalDialogStagedChangesState = true;
},
async applyChangesModalClick(action) {
this.modalDialogStagedChangesState = false;
if (action === 'accept') {
await this.stagedRatesStore.applyChanges();
this.$emit('rates-update');
}
},
checkChanges() {
this.stagedRatesStore.checkStagedChanges();
}
},
created() {
this.checkChanges();
}
}
</script>
<style scoped>
.staged-changes-container {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 1.4rem;
gap: 1.6rem;
background-color: #5AF0B4;
color: #002F54;
border-radius: 0.8rem;
padding: 1.6rem;
margin-bottom: 1.6rem;
}
.staged-changes-info {
display: flex;
align-items: center;
gap: 1.6rem;
}
.staged-changes-save {
display: flex;
align-items: center;
gap: 1.6rem;
}
/* Fade transition styles */
.fade-enter-active {
transition: opacity 0.3s ease-in;
}
.fade-leave-active {
transition: opacity 0.3s ease-out;
}
.fade-enter-from {
opacity: 0;
}
.fade-leave-to {
opacity: 0;
}
</style>

View file

@ -27,7 +27,8 @@ import {
PhArchive, PhArchive,
PhFloppyDisk, PhFloppyDisk,
PhArrowCounterClockwise, PhArrowCounterClockwise,
PhCheck, PhBug, PhShuffle, PhStack, PhFile, PhFilePlus, PhDownloadSimple PhCheck, PhBug, PhShuffle, PhStack, PhFile, PhFilePlus, PhDownloadSimple, PhMonitor, PhCpu, PhFileJs, PhFileCloud,
PhCloudX, PhDesktop, PhHardDrives
} from "@phosphor-icons/vue"; } from "@phosphor-icons/vue";
const app = createApp(App); const app = createApp(App);
@ -62,6 +63,8 @@ app.component('PhBug', PhBug);
app.component('PhShuffle', PhShuffle); app.component('PhShuffle', PhShuffle);
app.component('PhStack', PhStack ); app.component('PhStack', PhStack );
app.component('PhFile', PhFile); app.component('PhFile', PhFile);
app.component("PhDesktop", PhDesktop );
app.component("PhHardDrives", PhHardDrives );
app.use(router); app.use(router);

View file

@ -3,9 +3,8 @@
<div class="header-container"> <div class="header-container">
<h2 class="page-header">Configuration</h2> <h2 class="page-header">Configuration</h2>
<div> <div>
<staged-changes></staged-changes>
<box class="box-container"> <box class="box-container">
<tab-container :tabs="tabsConfig" class="tab-container"> <tab-container :tabs="tabsConfig" class="tab-container" @tab-changed="handleTabChange">
</tab-container> </tab-container>
</box> </box>
</div> </div>
@ -35,32 +34,50 @@ export default {
currentTab: null, currentTab: null,
tabsConfig: [ tabsConfig: [
{ {
title: 'System properties', title: 'Properties',
component: markRaw(Properties), component: markRaw(Properties),
props: { isSelected: false},
}, },
{ {
title: 'System error log', title: 'System log',
component: markRaw(ErrorLog), component: markRaw(ErrorLog),
props: { isSelected: false},
}, },
{ {
title: 'Materials', title: 'Materials',
component: markRaw(Materials), component: markRaw(Materials),
props: { isSelected: false},
}, },
{ {
title: 'Nodes', title: 'Nodes',
component: markRaw(Nodes), component: markRaw(Nodes),
props: { isSelected: false},
}, },
{ {
title: 'Rates', title: 'Rates',
component: markRaw(Rates), component: markRaw(Rates),
props: { isSelected: false},
}, },
{ {
title: 'Bulk operations', title: 'Bulk operations',
component: markRaw(BulkOperations), component: markRaw(BulkOperations),
props: { isSelected: false},
} }
] ]
} }
}, },
methods: {
handleTabChange(eventData) {
console.log("handleTabChange")
const { index, tab } = eventData;
console.log(`Tab ${index} activated:`, tab.title);
this.tabsConfig.forEach(t => t.props.isSelected = t.title === tab.title);
}
}
} }
</script> </script>

View file

@ -25,14 +25,25 @@ export default {
return { return {
showModal: false, showModal: false,
error: null, error: null,
pageSize: 10, pageSize: 20,
pagination: { page: 1, pageCount: 10, totalCount: 1 }, pagination: { page: 1, pageCount: 10, totalCount: 1 },
columns: [ columns: [
{key: 'type', label: 'Type', align: "center", iconResolver: (rawValue, item) => {
if (rawValue === "FRONTEND") {
return "PhDesktop";
} else if (rawValue === "BACKEND") {
return "PhHardDrives"
} else if (rawValue === "BULK") {
return "PhStack"
} else if(rawValue === "CALCULATION") {
return "PhCalculator"
}
}},
{key: 'timestamp', label: 'Timestamp', formatter: (value) => this.buildDate(value) }, {key: 'timestamp', label: 'Timestamp', formatter: (value) => this.buildDate(value) },
{key: 'user_id', label: 'User'}, {key: 'user_id', label: 'User'},
{key: 'type', label: 'Type'},
{key: 'title', label: 'Title'}, {key: 'title', label: 'Title'},
{key: 'message', label: 'Message'}, {key: 'message', label: 'Message', formatter: (value) => (value?.length > 100 ? `${value?.substring(0,100)} ...` : value)},
{key: 'code', label: 'Exception'}, {key: 'code', label: 'Exception'},
], ],
@ -41,8 +52,28 @@ export default {
computed: { computed: {
...mapStores(useErrorLogStore) ...mapStores(useErrorLogStore)
}, },
props: {
isSelected: {
type: Boolean,
default: false
}
},
watch: {
async isSelected(newVal) {
if(newVal === true) {
const query = {
searchTerm: '',
page: 1,
pageSize: 10,
}
await this.fetchData(query);
}
}
},
methods: { methods: {
async fetchData(query) { async fetchData(query) {
console.log("fetchData")
await this.errorLogStore.setQuery(query); await this.errorLogStore.setQuery(query);
this.pagination = this.errorLogStore.getPagination; this.pagination = this.errorLogStore.getPagination;
return this.errorLogStore.getErrors; return this.errorLogStore.getErrors;

View file

@ -34,11 +34,9 @@ export const useBulkOperationStore = defineStore('bulkOperation', {
this.startTimer(); this.startTimer();
}, },
async timerMethod() { async timerMethod() {
this.updateStatus(); await this.updateStatus();
const restart = this.restartNeeded(); const restart = this.restartNeeded();
console.log("state " + this.bulkOperations.map(b => b.state).join(", ") + "restarting " + restart);
this.stopTimer(); this.stopTimer();
if(restart) { if(restart) {
@ -46,6 +44,11 @@ export const useBulkOperationStore = defineStore('bulkOperation', {
} }
}, },
async manageStatus() {
await this.updateStatus();
if(this.restartNeeded())
this.startTimer();
},
async updateStatus() { async updateStatus() {
this.loading = true; this.loading = true;
@ -79,15 +82,12 @@ export const useBulkOperationStore = defineStore('bulkOperation', {
startTimer() { startTimer() {
if (this.updateTimer) return if (this.updateTimer) return
console.log("start timer") this.updateTimer = setTimeout(async () => {
this.updateTimer = setTimeout(() => { await this.timerMethod()
this.timerMethod()
}, this.updateInterval) }, this.updateInterval)
}, },
stopTimer() { stopTimer() {
if (this.updateTimer) { if (this.updateTimer) {
console.log("stop timer")
clearTimeout(this.updateTimer) clearTimeout(this.updateTimer)
this.updateTimer = null this.updateTimer = null
} }

View file

@ -0,0 +1,33 @@
import {defineStore} from 'pinia'
import {config} from '@/config'
import {useErrorStore} from "@/store/error.js";
import performRequest from "@/backend.js";
export const useStagedRatesStore = defineStore('stagedRates', {
state() {
return {
stagedChanges: {staged_changes: false, expires: null}
}
},
getters: {
hasStagedChanges(state) {
return state.stagedChanges.staged_changes;
},
expiresSoon(state) {
return !state.stagedChanges.staged_changes && state.stagedChanges.expires !== null && (state.stagedChanges.expires < 10);
}
},
actions: {
async checkStagedChanges() {
const url = `${config.backendUrl}/rates/staged_changes`;
const resp = await performRequest(this, 'GET', url, null,);
this.stagedChanges = resp.data;
},
async applyChanges() {
const url = `${config.backendUrl}/rates/staged_changes`;
await performRequest(this, 'PUT', url, null, false);
this.stagedChanges = {staged_changes: false, expires: null};
},
}
});

View file

@ -2,6 +2,7 @@ package de.avatic.lcc.controller.configuration;
import de.avatic.lcc.dto.configuration.matrixrates.MatrixRateDTO; import de.avatic.lcc.dto.configuration.matrixrates.MatrixRateDTO;
import de.avatic.lcc.dto.configuration.rates.ContainerRateDTO; import de.avatic.lcc.dto.configuration.rates.ContainerRateDTO;
import de.avatic.lcc.dto.configuration.rates.StagedRatesDTO;
import de.avatic.lcc.dto.generic.ValidityPeriodDTO; import de.avatic.lcc.dto.generic.ValidityPeriodDTO;
import de.avatic.lcc.repositories.pagination.SearchQueryResult; import de.avatic.lcc.repositories.pagination.SearchQueryResult;
import de.avatic.lcc.service.access.ContainerRateService; import de.avatic.lcc.service.access.ContainerRateService;
@ -166,8 +167,8 @@ public class RateController {
* whether rate drafts exist (true) or not (false). * whether rate drafts exist (true) or not (false).
*/ */
@GetMapping( {"/staged_changes", "/staged_changes/"}) @GetMapping( {"/staged_changes", "/staged_changes/"})
public ResponseEntity<Boolean> checkRateDrafts() { public ResponseEntity<StagedRatesDTO> checkRateDrafts() {
return ResponseEntity.ok(rateApprovalService.hasRateDrafts()); return ResponseEntity.ok(rateApprovalService.getStagedRateDTO());
} }
/** /**

View file

@ -0,0 +1,27 @@
package de.avatic.lcc.dto.configuration.rates;
import com.fasterxml.jackson.annotation.JsonProperty;
public class StagedRatesDTO {
@JsonProperty("staged_changes")
private boolean stagedChanges;
private Integer expires;
public boolean isStagedChanges() {
return stagedChanges;
}
public void setStagedChanges(boolean stagedChanges) {
this.stagedChanges = stagedChanges;
}
public Integer getExpires() {
return expires;
}
public void setExpires(Integer expires) {
this.expires = expires;
}
}

View file

@ -18,6 +18,8 @@ public class MatrixRate {
@NotNull @NotNull
private Integer toCountry; private Integer toCountry;
private Integer validityPeriodId;
public Integer getId() { public Integer getId() {
return id; return id;
} }
@ -49,4 +51,12 @@ public class MatrixRate {
public void setToCountry(Integer toCountry) { public void setToCountry(Integer toCountry) {
this.toCountry = toCountry; this.toCountry = toCountry;
} }
public void setValidityPeriodId(Integer validityPeriodId) {
this.validityPeriodId = validityPeriodId;
}
public Integer getValidityPeriodId() {
return validityPeriodId;
}
} }

View file

@ -14,6 +14,7 @@ public class ValidityPeriod {
private ValidityPeriodState state; private ValidityPeriodState state;
private int renewals;
public Integer getId() { public Integer getId() {
return id; return id;
@ -46,4 +47,12 @@ public class ValidityPeriod {
public void setState(ValidityPeriodState state) { public void setState(ValidityPeriodState state) {
this.state = state; this.state = state;
} }
public void setRenewals(int renewals) {
this.renewals = renewals;
}
public int getRenewals() {
return renewals;
}
} }

View file

@ -180,6 +180,32 @@ public class ContainerRateRepository {
return Optional.of(route.getFirst()); return Optional.of(route.getFirst());
} }
@Transactional
public void insert(ContainerRate containerRate) {
String sql = """
INSERT INTO container_rate
(from_node_id, to_node_id, container_rate_type, rate_teu, rate_feu, rate_hc, lead_time, validity_period_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
container_rate_type = VALUES(container_rate_type),
rate_teu = VALUES(rate_teu),
rate_feu = VALUES(rate_feu),
rate_hc = VALUES(rate_hc),
lead_time = VALUES(lead_time)
""";
jdbcTemplate.update(sql,
containerRate.getFromNodeId(),
containerRate.getToNodeId(),
containerRate.getType().name(),
containerRate.getRateTeu(),
containerRate.getRateFeu(),
containerRate.getRateHc(),
containerRate.getLeadTime(),
containerRate.getValidityPeriodId()
);
}
private static class ContainerRateMapper implements RowMapper<ContainerRate> { private static class ContainerRateMapper implements RowMapper<ContainerRate> {

View file

@ -134,6 +134,22 @@ public class MatrixRateRepository {
return Optional.of(rates.getFirst()); return Optional.of(rates.getFirst());
} }
@Transactional
public void insert(MatrixRate rate) {
String sql = """
INSERT INTO country_matrix_rate (from_country_id, to_country_id, rate, validity_period_id)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
rate = VALUES(rate)
""";
jdbcTemplate.update(sql,
rate.getFromCountry(),
rate.getToCountry(),
rate.getRate(),
rate.getValidityPeriodId());
}
/** /**
* Maps rows of a {@link ResultSet} to {@link MatrixRate} objects as required by * Maps rows of a {@link ResultSet} to {@link MatrixRate} objects as required by
* the {@link JdbcTemplate}. * the {@link JdbcTemplate}.
@ -155,6 +171,7 @@ public class MatrixRateRepository {
entity.setRate(rs.getBigDecimal("rate")); entity.setRate(rs.getBigDecimal("rate"));
entity.setFromCountry(rs.getInt("from_country_id")); entity.setFromCountry(rs.getInt("from_country_id"));
entity.setToCountry(rs.getInt("to_country_id")); entity.setToCountry(rs.getInt("to_country_id"));
entity.setValidityPeriodId(rs.getInt("validity_period_id"));
return entity; return entity;
} }

View file

@ -3,6 +3,8 @@ package de.avatic.lcc.repositories.rates;
import de.avatic.lcc.model.ValidityTuple; import de.avatic.lcc.model.ValidityTuple;
import de.avatic.lcc.model.rates.ValidityPeriod; import de.avatic.lcc.model.rates.ValidityPeriod;
import de.avatic.lcc.model.rates.ValidityPeriodState; import de.avatic.lcc.model.rates.ValidityPeriodState;
import de.avatic.lcc.util.exception.internalerror.DatabaseException;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
@ -80,6 +82,7 @@ public class ValidityPeriodRepository {
* @param id the unique identifier of the validity period. * @param id the unique identifier of the validity period.
* @return the {@link ValidityPeriod} corresponding to the ID. * @return the {@link ValidityPeriod} corresponding to the ID.
*/ */
@Transactional
public ValidityPeriod getById(Integer id) { public ValidityPeriod getById(Integer id) {
String query = "SELECT * FROM validity_period WHERE id = ?"; String query = "SELECT * FROM validity_period WHERE id = ?";
return jdbcTemplate.queryForObject(query, new ValidityPeriodMapper(), id); return jdbcTemplate.queryForObject(query, new ValidityPeriodMapper(), id);
@ -89,7 +92,8 @@ public class ValidityPeriodRepository {
* Creates a draft validity period if none exists in the database. * Creates a draft validity period if none exists in the database.
*/ */
private void createSet() { private void createSet() {
jdbcTemplate.update("INSERT INTO validity_period (state) SELECT ? WHERE NOT EXISTS (SELECT 1 FROM validity_period WHERE state = ?)", ValidityPeriodState.DRAFT.name(), ValidityPeriodState.DRAFT.name()); final Timestamp currentTimestamp = new Timestamp(System.currentTimeMillis());
jdbcTemplate.update("INSERT INTO validity_period (state, start_date) SELECT ?, ? WHERE NOT EXISTS (SELECT 1 FROM validity_period WHERE state = ?)", ValidityPeriodState.DRAFT.name(), currentTimestamp, ValidityPeriodState.DRAFT.name());
} }
/** /**
@ -97,6 +101,7 @@ public class ValidityPeriodRepository {
* *
* @return the ID of the valid {@link ValidityPeriod}. * @return the ID of the valid {@link ValidityPeriod}.
*/ */
@Transactional
public Optional<Integer> getValidPeriodId() { public Optional<Integer> getValidPeriodId() {
return getValidPeriod().map(ValidityPeriod::getId); return getValidPeriod().map(ValidityPeriod::getId);
} }
@ -106,6 +111,7 @@ public class ValidityPeriodRepository {
* *
* @return the {@link ValidityPeriod} in the {@code VALID} state. * @return the {@link ValidityPeriod} in the {@code VALID} state.
*/ */
@Transactional
public Optional<ValidityPeriod> getValidPeriod() { public Optional<ValidityPeriod> getValidPeriod() {
String query = "SELECT * FROM validity_period WHERE state = ?"; String query = "SELECT * FROM validity_period WHERE state = ?";
var period = jdbcTemplate.query(query, new ValidityPeriodMapper(), ValidityPeriodState.VALID.name()); var period = jdbcTemplate.query(query, new ValidityPeriodMapper(), ValidityPeriodState.VALID.name());
@ -121,9 +127,17 @@ public class ValidityPeriodRepository {
* *
* @return the {@link ValidityPeriod} in the {@code DRAFT} state. * @return the {@link ValidityPeriod} in the {@code DRAFT} state.
*/ */
@Transactional
public ValidityPeriod getDraftPeriod() { public ValidityPeriod getDraftPeriod() {
createSet();
String query = "SELECT * FROM validity_period WHERE state = ?"; String query = "SELECT * FROM validity_period WHERE state = ?";
return jdbcTemplate.queryForObject(query, new ValidityPeriodMapper(), ValidityPeriodState.DRAFT.name()); var period = jdbcTemplate.query(query, new ValidityPeriodMapper(), ValidityPeriodState.DRAFT.name());
if(period.isEmpty())
throw new DatabaseException("No draft validity period exists");
return period.getFirst();
} }
/** /**
@ -131,6 +145,7 @@ public class ValidityPeriodRepository {
* *
* @return the ID of the draft {@link ValidityPeriod}. * @return the ID of the draft {@link ValidityPeriod}.
*/ */
@Transactional
public Integer getDraftPeriodId() { public Integer getDraftPeriodId() {
return getDraftPeriod().getId(); return getDraftPeriod().getId();
} }
@ -148,8 +163,14 @@ public class ValidityPeriodRepository {
if (id == null) return false; if (id == null) return false;
String query = "SELECT COUNT(*) FROM country_matrix_rate WHERE validity_period_id = ?"; String query = "SELECT COUNT(*) FROM country_matrix_rate WHERE validity_period_id = ?";
var totalCount = jdbcTemplate.queryForObject(query, Integer.class, id); var matrixCount = jdbcTemplate.queryForObject(query, Integer.class, id);
return totalCount != null && totalCount > 0;
query = "SELECT COUNT(*) FROM container_rate WHERE validity_period_id = ?";
var containerCount = jdbcTemplate.queryForObject(query, Integer.class, id);
int totalCount = (matrixCount != null ? matrixCount : 0) + (containerCount != null ? containerCount : 0);
return totalCount > 0;
} }
/** /**
@ -219,6 +240,7 @@ public class ValidityPeriodRepository {
return Optional.of(periods.getFirst()); return Optional.of(periods.getFirst());
} }
@Transactional
public List<ValidityTuple> findValidityPeriodsWithReportByMaterialId(Integer materialId) { public List<ValidityTuple> findValidityPeriodsWithReportByMaterialId(Integer materialId) {
String validityPeriodSql = """ String validityPeriodSql = """
@ -231,7 +253,12 @@ public class ValidityPeriodRepository {
return jdbcTemplate.query(validityPeriodSql, (rs, cnt) -> new ValidityTuple(rs.getInt("validity_period_id"), rs.getInt("property_set_id")), materialId); return jdbcTemplate.query(validityPeriodSql, (rs, cnt) -> new ValidityTuple(rs.getInt("validity_period_id"), rs.getInt("property_set_id")), materialId);
} }
; @Transactional
public void increaseRenewal(Integer increase) {
String sql = "UPDATE validity_period SET renewals = renewals + ? WHERE state = 'VALID'";
jdbcTemplate.update(sql, increase);
}
/** /**
* Maps rows of a {@link ResultSet} to {@link ValidityPeriod} objects. * Maps rows of a {@link ResultSet} to {@link ValidityPeriod} objects.
@ -254,6 +281,8 @@ public class ValidityPeriodRepository {
String stateStr = rs.getString("state"); String stateStr = rs.getString("state");
period.setState(stateStr != null ? ValidityPeriodState.valueOf(stateStr) : null); period.setState(stateStr != null ? ValidityPeriodState.valueOf(stateStr) : null);
period.setRenewals(rs.getInt("renewals"));
return period; return period;

View file

@ -3,9 +3,7 @@ package de.avatic.lcc.service.bulk;
import de.avatic.lcc.model.bulk.BulkFileTypes; import de.avatic.lcc.model.bulk.BulkFileTypes;
import de.avatic.lcc.model.bulk.BulkOperation; import de.avatic.lcc.model.bulk.BulkOperation;
import de.avatic.lcc.repositories.NodeRepository; import de.avatic.lcc.repositories.NodeRepository;
import de.avatic.lcc.service.bulk.bulkImport.MaterialBulkImportService; import de.avatic.lcc.service.bulk.bulkImport.*;
import de.avatic.lcc.service.bulk.bulkImport.NodeBulkImportService;
import de.avatic.lcc.service.bulk.bulkImport.PackagingBulkImportService;
import de.avatic.lcc.service.excelMapper.*; import de.avatic.lcc.service.excelMapper.*;
import de.avatic.lcc.service.transformer.generic.NodeTransformer; import de.avatic.lcc.service.transformer.generic.NodeTransformer;
import de.avatic.lcc.util.exception.internalerror.ExcelValidationError; import de.avatic.lcc.util.exception.internalerror.ExcelValidationError;
@ -32,8 +30,10 @@ public class BulkImportService {
private final NodeBulkImportService nodeBulkImportService; private final NodeBulkImportService nodeBulkImportService;
private final PackagingBulkImportService packagingBulkImportService; private final PackagingBulkImportService packagingBulkImportService;
private final MaterialBulkImportService materialBulkImportService; private final MaterialBulkImportService materialBulkImportService;
private final MatrixRateImportService matrixRateImportService;
private final ContainerRateImportService containerRateImportService;
public BulkImportService(MatrixRateExcelMapper matrixRateExcelMapper, ContainerRateExcelMapper containerRateExcelMapper, MaterialExcelMapper materialExcelMapper, PackagingExcelMapper packagingExcelMapper, NodeExcelMapper nodeExcelMapper, NodeRepository nodeRepository, NodeTransformer nodeTransformer, NodeBulkImportService nodeBulkImportService, PackagingBulkImportService packagingBulkImportService, MaterialBulkImportService materialBulkImportService) { public BulkImportService(MatrixRateExcelMapper matrixRateExcelMapper, ContainerRateExcelMapper containerRateExcelMapper, MaterialExcelMapper materialExcelMapper, PackagingExcelMapper packagingExcelMapper, NodeExcelMapper nodeExcelMapper, NodeRepository nodeRepository, NodeTransformer nodeTransformer, NodeBulkImportService nodeBulkImportService, PackagingBulkImportService packagingBulkImportService, MaterialBulkImportService materialBulkImportService, MatrixRateImportService matrixRateImportService, ContainerRateImportService containerRateImportService) {
this.matrixRateExcelMapper = matrixRateExcelMapper; this.matrixRateExcelMapper = matrixRateExcelMapper;
this.containerRateExcelMapper = containerRateExcelMapper; this.containerRateExcelMapper = containerRateExcelMapper;
this.materialExcelMapper = materialExcelMapper; this.materialExcelMapper = materialExcelMapper;
@ -44,6 +44,8 @@ public class BulkImportService {
this.nodeBulkImportService = nodeBulkImportService; this.nodeBulkImportService = nodeBulkImportService;
this.packagingBulkImportService = packagingBulkImportService; this.packagingBulkImportService = packagingBulkImportService;
this.materialBulkImportService = materialBulkImportService; this.materialBulkImportService = materialBulkImportService;
this.matrixRateImportService = matrixRateImportService;
this.containerRateImportService = containerRateImportService;
} }
public void processOperation(BulkOperation op) throws IOException { public void processOperation(BulkOperation op) throws IOException {
@ -56,14 +58,18 @@ public class BulkImportService {
try (Workbook workbook = new XSSFWorkbook(in)) { try (Workbook workbook = new XSSFWorkbook(in)) {
Sheet sheet = workbook.getSheet(BulkFileTypes.valueOf(type.name()).getSheetName()); Sheet sheet = workbook.getSheet(BulkFileTypes.valueOf(type.name()).getSheetName());
if(sheet == null)
throw new ExcelValidationError("Provided file does not contain a sheet named " + BulkFileTypes.valueOf(type.name()).getSheetName());
switch (type) { switch (type) {
case CONTAINER_RATE: case CONTAINER_RATE:
var containerRates = containerRateExcelMapper.extractSheet(sheet); var containerRates = containerRateExcelMapper.extractSheet(sheet);
containerRateImportService.processContainerRates(containerRates);
break; break;
case COUNTRY_MATRIX: case COUNTRY_MATRIX:
var matrixRates = matrixRateExcelMapper.extractSheet(sheet); var matrixRates = matrixRateExcelMapper.extractSheet(sheet);
matrixRates.forEach(System.out::println); matrixRateImportService.processMatrixRates(matrixRates);
break; break;
case MATERIAL: case MATERIAL:
var materials = materialExcelMapper.extractSheet(sheet); var materials = materialExcelMapper.extractSheet(sheet);

View file

@ -0,0 +1,30 @@
package de.avatic.lcc.service.bulk.bulkImport;
import de.avatic.lcc.model.rates.ContainerRate;
import de.avatic.lcc.repositories.rates.ContainerRateRepository;
import de.avatic.lcc.repositories.rates.ValidityPeriodRepository;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class ContainerRateImportService {
private final ValidityPeriodRepository validityPeriodRepository;
private final ContainerRateRepository containerRateRepository;
public ContainerRateImportService(ValidityPeriodRepository validityPeriodRepository, ContainerRateRepository containerRateRepository) {
this.validityPeriodRepository = validityPeriodRepository;
this.containerRateRepository = containerRateRepository;
}
public void processContainerRates(List<ContainerRate> containerRates) {
Integer periodId = validityPeriodRepository.getDraftPeriodId();
containerRates.forEach(rate -> processContainerRate(rate,periodId));
}
public void processContainerRate(ContainerRate containerRate, Integer periodId) {
containerRate.setValidityPeriodId(periodId);
containerRateRepository.insert(containerRate);
}
}

View file

@ -0,0 +1,30 @@
package de.avatic.lcc.service.bulk.bulkImport;
import de.avatic.lcc.model.rates.MatrixRate;
import de.avatic.lcc.repositories.rates.MatrixRateRepository;
import de.avatic.lcc.repositories.rates.ValidityPeriodRepository;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class MatrixRateImportService {
private final MatrixRateRepository matrixRateRepository;
private final ValidityPeriodRepository validityPeriodRepository;
public MatrixRateImportService(MatrixRateRepository matrixRateRepository, ValidityPeriodRepository validityPeriodRepository) {
this.matrixRateRepository = matrixRateRepository;
this.validityPeriodRepository = validityPeriodRepository;
}
public void processMatrixRate(MatrixRate rate, Integer periodId) {
rate.setValidityPeriodId(periodId);
matrixRateRepository.insert(rate);
}
public void processMatrixRates(List<MatrixRate> matrixRates) {
Integer periodId = validityPeriodRepository.getDraftPeriodId();
matrixRates.forEach(rate -> processMatrixRate(rate,periodId));
}
}

View file

@ -1,9 +1,15 @@
package de.avatic.lcc.service.configuration; package de.avatic.lcc.service.configuration;
import de.avatic.lcc.repositories.rates.MatrixRateRepository; import de.avatic.lcc.dto.configuration.rates.StagedRatesDTO;
import de.avatic.lcc.model.properties.SystemPropertyMappingId;
import de.avatic.lcc.model.rates.ValidityPeriod;
import de.avatic.lcc.repositories.rates.ValidityPeriodRepository; import de.avatic.lcc.repositories.rates.ValidityPeriodRepository;
import de.avatic.lcc.service.access.PropertyService;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Optional;
/** /**
* A service class responsible for approving or verifying rate drafts. * A service class responsible for approving or verifying rate drafts.
@ -15,9 +21,11 @@ public class RateApprovalService {
private final ValidityPeriodRepository validityPeriodRepository; private final ValidityPeriodRepository validityPeriodRepository;
private final PropertyService propertyService;
public RateApprovalService(ValidityPeriodRepository validityPeriodRepository) { public RateApprovalService(ValidityPeriodRepository validityPeriodRepository, PropertyService propertyService) {
this.validityPeriodRepository = validityPeriodRepository; this.validityPeriodRepository = validityPeriodRepository;
this.propertyService = propertyService;
} }
/** /**
@ -25,14 +33,65 @@ public class RateApprovalService {
* *
* @return {@code true} if rate drafts exist, {@code false} otherwise. * @return {@code true} if rate drafts exist, {@code false} otherwise.
*/ */
public boolean hasRateDrafts() { public StagedRatesDTO getStagedRateDTO() {
return validityPeriodRepository.hasRateDrafts();
var optValidPeriod = validityPeriodRepository.getValidPeriod();
StagedRatesDTO stagedRatesDTO = new StagedRatesDTO();
stagedRatesDTO.setExpires(optValidPeriod.map(this::expires).orElse(null));
stagedRatesDTO.setStagedChanges(validityPeriodRepository.hasRateDrafts());
return stagedRatesDTO;
}
private Integer expires(ValidityPeriod validPeriod) {
Optional<Integer> validityProperty = propertyService.getProperty(SystemPropertyMappingId.VALID_DAYS);
if (validityProperty.isPresent()) {
var validDays = validityProperty.get() * (validPeriod.getRenewals() + 1);
return Math.toIntExact(Duration.between(LocalDateTime.now(), validPeriod.getStartDate().plusDays(validDays)).toDays());
}
return null;
}
private Integer getRenewalIncrease() {
Optional<Integer> validityProperty = propertyService.getProperty(SystemPropertyMappingId.VALID_DAYS);
var optValidPeriod = validityPeriodRepository.getValidPeriod();
if (validityProperty.isPresent() && optValidPeriod.isPresent()) {
var validPeriod = optValidPeriod.get();
var validDays = validityProperty.get() * (validPeriod.getRenewals() + 1);
var expiresIn = Math.toIntExact(Duration.between(LocalDateTime.now(), validPeriod.getStartDate().plusDays(validDays)).toDays()) - 10;
if (expiresIn < 0) {
return ((-1 * expiresIn) / validDays) + 1;
}
return 1;
}
return null;
} }
/** /**
* Approves and applies all staged (draft) rates. * Approves and applies all staged (draft) rates.
*/ */
public void approveRateDrafts() { public void approveRateDrafts() {
validityPeriodRepository.applyDraft();
if (validityPeriodRepository.hasRateDrafts()) {
validityPeriodRepository.applyDraft();
} else {
Integer increase = getRenewalIncrease();
if (increase != null) {
validityPeriodRepository.increaseRenewal(increase);
}
}
} }
} }

View file

@ -66,7 +66,7 @@ CREATE TABLE IF NOT EXISTS `country_property_type`
`external_mapping_id` VARCHAR(16), `external_mapping_id` VARCHAR(16),
`data_type` VARCHAR(16) NOT NULL, `data_type` VARCHAR(16) NOT NULL,
`validation_rule` VARCHAR(64), `validation_rule` VARCHAR(64),
`description` VARCHAR(255) NOT NULL, `description` VARCHAR(255) NOT NULL,
`property_group` VARCHAR(32) NOT NULL, `property_group` VARCHAR(32) NOT NULL,
`sequence_number` INT NOT NULL, `sequence_number` INT NOT NULL,
`is_required` BOOLEAN NOT NULL DEFAULT FALSE, `is_required` BOOLEAN NOT NULL DEFAULT FALSE,
@ -218,6 +218,7 @@ CREATE TABLE IF NOT EXISTS validity_period
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
start_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, start_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
end_date TIMESTAMP DEFAULT NULL, end_date TIMESTAMP DEFAULT NULL,
renewals INT UNSIGNED DEFAULT 0,
state CHAR(8) NOT NULL CHECK (state IN ('DRAFT', 'VALID', 'INVALID', 'EXPIRED')), state CHAR(8) NOT NULL CHECK (state IN ('DRAFT', 'VALID', 'INVALID', 'EXPIRED')),
CONSTRAINT `chk_validity_date_range` CHECK (`end_date` IS NULL OR `end_date` > `start_date`) CONSTRAINT `chk_validity_date_range` CHECK (`end_date` IS NULL OR `end_date` > `start_date`)
); );
@ -237,7 +238,8 @@ CREATE TABLE IF NOT EXISTS container_rate
FOREIGN KEY (to_node_id) REFERENCES node (id), FOREIGN KEY (to_node_id) REFERENCES node (id),
FOREIGN KEY (validity_period_id) REFERENCES validity_period (id), FOREIGN KEY (validity_period_id) REFERENCES validity_period (id),
INDEX idx_from_to_nodes (from_node_id, to_node_id), INDEX idx_from_to_nodes (from_node_id, to_node_id),
INDEX idx_validity_period_id (validity_period_id) INDEX idx_validity_period_id (validity_period_id),
CONSTRAINT uk_container_rate_unique UNIQUE (from_node_id, to_node_id, validity_period_id)
); );
CREATE TABLE IF NOT EXISTS country_matrix_rate CREATE TABLE IF NOT EXISTS country_matrix_rate
@ -251,7 +253,8 @@ CREATE TABLE IF NOT EXISTS country_matrix_rate
FOREIGN KEY (to_country_id) REFERENCES country (id), FOREIGN KEY (to_country_id) REFERENCES country (id),
FOREIGN KEY (validity_period_id) REFERENCES validity_period (id), FOREIGN KEY (validity_period_id) REFERENCES validity_period (id),
INDEX idx_from_to_country (from_country_id, to_country_id), INDEX idx_from_to_country (from_country_id, to_country_id),
INDEX idx_validity_period_id (validity_period_id) INDEX idx_validity_period_id (validity_period_id),
CONSTRAINT uk_country_matrix_rate_unique UNIQUE (from_country_id, to_country_id, validity_period_id)
); );
@ -311,9 +314,9 @@ CREATE TABLE IF NOT EXISTS packaging_property_type
`id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY, `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
`name` VARCHAR(255) NOT NULL, `name` VARCHAR(255) NOT NULL,
external_mapping_id VARCHAR(16) NOT NULL, external_mapping_id VARCHAR(16) NOT NULL,
`description` VARCHAR(255) NOT NULL, `description` VARCHAR(255) NOT NULL,
`property_group` VARCHAR(32) NOT NULL, `property_group` VARCHAR(32) NOT NULL,
`sequence_number` INT NOT NULL, `sequence_number` INT NOT NULL,
`data_type` VARCHAR(16), `data_type` VARCHAR(16),
`validation_rule` VARCHAR(64), `validation_rule` VARCHAR(64),
`is_required` BOOLEAN NOT NULL DEFAULT FALSE, `is_required` BOOLEAN NOT NULL DEFAULT FALSE,