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>
<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>
</template>
<script>
import Tooltip from "@/components/UI/Tooltip.vue";
export default {
components: {Tooltip},
props: {
tooltipText: {
type: String,
default: null,
},
iso: {
type: String,
required: true

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,9 @@
<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>
@ -12,10 +16,20 @@ export default {
name: "materials",
components: {TableView},
computed: {
... mapStores(useMaterialStore),
...mapStores(useMaterialStore),
},
created() {
this.materialStore.updateMaterials()
props: {
isSelected: {
type: Boolean,
default: false
}
},
watch: {
async isSelected(newVal) {
if(newVal === true) {
await this.materialStore.updateMaterials();
}
}
},
methods: {
async fetch(query) {

View file

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

View file

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

View file

@ -1,6 +1,8 @@
<template>
<div class="container-rate-container">
<staged-rates ref="stagedRatesRef"></staged-rates>
<div class="container-rate-header">
<div class="container-rate-search-container">
@ -32,7 +34,9 @@
</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>
@ -52,10 +56,30 @@ import TableView from "@/components/UI/TableView.vue";
import RadioOption from "@/components/UI/RadioOption.vue";
import {useMatrixRateStore} from "@/store/matrixRate.js";
import {useContainerRateStore} from "@/store/containerRate.js";
import StagedRates from "@/components/layout/config/StagedRates.vue";
export default {
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: {
...mapStores(useValidityPeriodStore, useMatrixRateStore, useContainerRateStore),
loadingPeriods() {
@ -75,6 +99,7 @@ export default {
return this.rateTypeValue;
},
set(value) {
if (value === "matrix") {
this.selectedTypeColumns = this.matrixColumns;
} else {
@ -91,27 +116,24 @@ export default {
},
async set(value) {
this.validityPeriodStore.setSelectedPeriod(value);
// await this.propertiesStore.loadProperties(value);
// await this.countryStore.selectPeriod(value);
this.$refs.tableViewRef.reload('', 1);
}
},
periods() {
const periods = [];
const vp = this.validityPeriodStore.getPeriods;
// const current = this.propertySetsStore.getCurrentPeriodId; //TODO
const current = 1;
if ((vp ?? null) === null) {
return null;
}
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};
if (p.state !== "VALID" || p.id === current)
periods.push(period);
periods.push(period);
}
return periods;
@ -119,18 +141,18 @@ export default {
},
data() {
return {
pageSize: 10,
pagination: { page: 1, pageCount: 10, totalCount: 1 },
pageSize: 20,
pagination: {page: 1, pageCount: 1, totalCount: 1},
rateTypeValue: "container",
selectedTypeColumns: [],
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: 'rate', align: 'right', label: 'Rate [EUR/km]'},
],
containerColumns: [
{
key: 'type', label: 'Type', align: 'center', iconResolver: (rawValue, item) => {
key: 'type', label: 'Type', align: 'center', iconResolver: (rawValue, _) => {
if (rawValue === "SEA") {
return "PhBoat";
@ -143,10 +165,10 @@ export default {
},
{key: 'source.name', align: 'left', label: 'From 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.FEU', align: 'right',label: '40 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.HC', align: 'right', label: '40 ft. HC rate [EUR]'},
{key: 'lead_time', align: 'right', label: 'Lead time [days]'},
],
modalDialogDeleteState: false,
selectedPeriodId: null
@ -157,6 +179,7 @@ export default {
await this.validityPeriodStore.loadPeriods();
await this.matrixRateStore.setQuery();
await this.containerRateStore.setQuery();
this.pagination = this.rateType === 'container' ? this.containerRateStore.getPagination : this.matrixRateStore.getPagination;
},
methods: {
async fetch(query) {
@ -192,7 +215,6 @@ export default {
<style scoped>
.table-view {
margin-top: 1.6rem;
}
@ -218,6 +240,7 @@ export default {
margin-bottom: 1rem;
gap: 1.6rem;
font-size: 1.4rem;
}
.container-rate-search-searchbar {
@ -231,6 +254,7 @@ export default {
margin-bottom: 1rem;
gap: 1.6rem;
font-size: 1.4rem;
min-width: 50rem;
}
.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,
PhFloppyDisk,
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";
const app = createApp(App);
@ -62,6 +63,8 @@ app.component('PhBug', PhBug);
app.component('PhShuffle', PhShuffle);
app.component('PhStack', PhStack );
app.component('PhFile', PhFile);
app.component("PhDesktop", PhDesktop );
app.component("PhHardDrives", PhHardDrives );
app.use(router);

View file

@ -3,9 +3,8 @@
<div class="header-container">
<h2 class="page-header">Configuration</h2>
<div>
<staged-changes></staged-changes>
<box class="box-container">
<tab-container :tabs="tabsConfig" class="tab-container">
<tab-container :tabs="tabsConfig" class="tab-container" @tab-changed="handleTabChange">
</tab-container>
</box>
</div>
@ -35,32 +34,50 @@ export default {
currentTab: null,
tabsConfig: [
{
title: 'System properties',
title: 'Properties',
component: markRaw(Properties),
props: { isSelected: false},
},
{
title: 'System error log',
title: 'System log',
component: markRaw(ErrorLog),
props: { isSelected: false},
},
{
title: 'Materials',
component: markRaw(Materials),
props: { isSelected: false},
},
{
title: 'Nodes',
component: markRaw(Nodes),
props: { isSelected: false},
},
{
title: 'Rates',
component: markRaw(Rates),
props: { isSelected: false},
},
{
title: 'Bulk operations',
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>

View file

@ -25,14 +25,25 @@ export default {
return {
showModal: false,
error: null,
pageSize: 10,
pageSize: 20,
pagination: { page: 1, pageCount: 10, totalCount: 1 },
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: 'user_id', label: 'User'},
{key: 'type', label: 'Type'},
{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'},
],
@ -41,8 +52,28 @@ export default {
computed: {
...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: {
async fetchData(query) {
console.log("fetchData")
await this.errorLogStore.setQuery(query);
this.pagination = this.errorLogStore.getPagination;
return this.errorLogStore.getErrors;

View file

@ -34,11 +34,9 @@ export const useBulkOperationStore = defineStore('bulkOperation', {
this.startTimer();
},
async timerMethod() {
this.updateStatus();
await this.updateStatus();
const restart = this.restartNeeded();
console.log("state " + this.bulkOperations.map(b => b.state).join(", ") + "restarting " + restart);
this.stopTimer();
if(restart) {
@ -46,6 +44,11 @@ export const useBulkOperationStore = defineStore('bulkOperation', {
}
},
async manageStatus() {
await this.updateStatus();
if(this.restartNeeded())
this.startTimer();
},
async updateStatus() {
this.loading = true;
@ -79,15 +82,12 @@ export const useBulkOperationStore = defineStore('bulkOperation', {
startTimer() {
if (this.updateTimer) return
console.log("start timer")
this.updateTimer = setTimeout(() => {
this.timerMethod()
this.updateTimer = setTimeout(async () => {
await this.timerMethod()
}, this.updateInterval)
},
stopTimer() {
if (this.updateTimer) {
console.log("stop timer")
clearTimeout(this.updateTimer)
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.rates.ContainerRateDTO;
import de.avatic.lcc.dto.configuration.rates.StagedRatesDTO;
import de.avatic.lcc.dto.generic.ValidityPeriodDTO;
import de.avatic.lcc.repositories.pagination.SearchQueryResult;
import de.avatic.lcc.service.access.ContainerRateService;
@ -166,8 +167,8 @@ public class RateController {
* whether rate drafts exist (true) or not (false).
*/
@GetMapping( {"/staged_changes", "/staged_changes/"})
public ResponseEntity<Boolean> checkRateDrafts() {
return ResponseEntity.ok(rateApprovalService.hasRateDrafts());
public ResponseEntity<StagedRatesDTO> checkRateDrafts() {
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
private Integer toCountry;
private Integer validityPeriodId;
public Integer getId() {
return id;
}
@ -49,4 +51,12 @@ public class MatrixRate {
public void setToCountry(Integer 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 int renewals;
public Integer getId() {
return id;
@ -46,4 +47,12 @@ public class ValidityPeriod {
public void setState(ValidityPeriodState 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());
}
@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> {

View file

@ -134,6 +134,22 @@ public class MatrixRateRepository {
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
* the {@link JdbcTemplate}.
@ -155,6 +171,7 @@ public class MatrixRateRepository {
entity.setRate(rs.getBigDecimal("rate"));
entity.setFromCountry(rs.getInt("from_country_id"));
entity.setToCountry(rs.getInt("to_country_id"));
entity.setValidityPeriodId(rs.getInt("validity_period_id"));
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.rates.ValidityPeriod;
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.RowMapper;
import org.springframework.stereotype.Repository;
@ -80,6 +82,7 @@ public class ValidityPeriodRepository {
* @param id the unique identifier of the validity period.
* @return the {@link ValidityPeriod} corresponding to the ID.
*/
@Transactional
public ValidityPeriod getById(Integer id) {
String query = "SELECT * FROM validity_period WHERE 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.
*/
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}.
*/
@Transactional
public Optional<Integer> getValidPeriodId() {
return getValidPeriod().map(ValidityPeriod::getId);
}
@ -106,6 +111,7 @@ public class ValidityPeriodRepository {
*
* @return the {@link ValidityPeriod} in the {@code VALID} state.
*/
@Transactional
public Optional<ValidityPeriod> getValidPeriod() {
String query = "SELECT * FROM validity_period WHERE state = ?";
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.
*/
@Transactional
public ValidityPeriod getDraftPeriod() {
createSet();
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}.
*/
@Transactional
public Integer getDraftPeriodId() {
return getDraftPeriod().getId();
}
@ -148,8 +163,14 @@ public class ValidityPeriodRepository {
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);
return totalCount != null && totalCount > 0;
var matrixCount = jdbcTemplate.queryForObject(query, Integer.class, id);
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());
}
@Transactional
public List<ValidityTuple> findValidityPeriodsWithReportByMaterialId(Integer materialId) {
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);
}
;
@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.
@ -254,6 +281,8 @@ public class ValidityPeriodRepository {
String stateStr = rs.getString("state");
period.setState(stateStr != null ? ValidityPeriodState.valueOf(stateStr) : null);
period.setRenewals(rs.getInt("renewals"));
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.BulkOperation;
import de.avatic.lcc.repositories.NodeRepository;
import de.avatic.lcc.service.bulk.bulkImport.MaterialBulkImportService;
import de.avatic.lcc.service.bulk.bulkImport.NodeBulkImportService;
import de.avatic.lcc.service.bulk.bulkImport.PackagingBulkImportService;
import de.avatic.lcc.service.bulk.bulkImport.*;
import de.avatic.lcc.service.excelMapper.*;
import de.avatic.lcc.service.transformer.generic.NodeTransformer;
import de.avatic.lcc.util.exception.internalerror.ExcelValidationError;
@ -32,8 +30,10 @@ public class BulkImportService {
private final NodeBulkImportService nodeBulkImportService;
private final PackagingBulkImportService packagingBulkImportService;
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.containerRateExcelMapper = containerRateExcelMapper;
this.materialExcelMapper = materialExcelMapper;
@ -44,6 +44,8 @@ public class BulkImportService {
this.nodeBulkImportService = nodeBulkImportService;
this.packagingBulkImportService = packagingBulkImportService;
this.materialBulkImportService = materialBulkImportService;
this.matrixRateImportService = matrixRateImportService;
this.containerRateImportService = containerRateImportService;
}
public void processOperation(BulkOperation op) throws IOException {
@ -56,14 +58,18 @@ public class BulkImportService {
try (Workbook workbook = new XSSFWorkbook(in)) {
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) {
case CONTAINER_RATE:
var containerRates = containerRateExcelMapper.extractSheet(sheet);
containerRateImportService.processContainerRates(containerRates);
break;
case COUNTRY_MATRIX:
var matrixRates = matrixRateExcelMapper.extractSheet(sheet);
matrixRates.forEach(System.out::println);
matrixRateImportService.processMatrixRates(matrixRates);
break;
case MATERIAL:
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;
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.service.access.PropertyService;
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.
@ -15,9 +21,11 @@ public class RateApprovalService {
private final ValidityPeriodRepository validityPeriodRepository;
private final PropertyService propertyService;
public RateApprovalService(ValidityPeriodRepository validityPeriodRepository) {
public RateApprovalService(ValidityPeriodRepository validityPeriodRepository, PropertyService propertyService) {
this.validityPeriodRepository = validityPeriodRepository;
this.propertyService = propertyService;
}
/**
@ -25,14 +33,65 @@ public class RateApprovalService {
*
* @return {@code true} if rate drafts exist, {@code false} otherwise.
*/
public boolean hasRateDrafts() {
return validityPeriodRepository.hasRateDrafts();
public StagedRatesDTO getStagedRateDTO() {
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.
*/
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),
`data_type` VARCHAR(16) NOT NULL,
`validation_rule` VARCHAR(64),
`description` VARCHAR(255) NOT NULL,
`description` VARCHAR(255) NOT NULL,
`property_group` VARCHAR(32) NOT NULL,
`sequence_number` INT NOT NULL,
`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,
start_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
end_date TIMESTAMP DEFAULT NULL,
renewals INT UNSIGNED DEFAULT 0,
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`)
);
@ -237,7 +238,8 @@ CREATE TABLE IF NOT EXISTS container_rate
FOREIGN KEY (to_node_id) REFERENCES node (id),
FOREIGN KEY (validity_period_id) REFERENCES validity_period (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
@ -251,7 +253,8 @@ CREATE TABLE IF NOT EXISTS country_matrix_rate
FOREIGN KEY (to_country_id) REFERENCES country (id),
FOREIGN KEY (validity_period_id) REFERENCES validity_period (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,
`name` VARCHAR(255) NOT NULL,
external_mapping_id VARCHAR(16) NOT NULL,
`description` VARCHAR(255) NOT NULL,
`property_group` VARCHAR(32) NOT NULL,
`sequence_number` INT NOT NULL,
`description` VARCHAR(255) NOT NULL,
`property_group` VARCHAR(32) NOT NULL,
`sequence_number` INT NOT NULL,
`data_type` VARCHAR(16),
`validation_rule` VARCHAR(64),
`is_required` BOOLEAN NOT NULL DEFAULT FALSE,