lcc_tool/src/frontend/src/store/premiseEdit.js
Jan ce6b8dd9b0 Integrated tariff rate calculations and customs API:
- **Backend**: Introduced `EUTaxationApiService` for TARIC data retrieval and integrated tariff rate lookup functionality. Added supporting DTOs and updated services to handle custom measures and updated logic for material and supplier changes.
- **Frontend**: Enhanced mass and single edit calculation components to include tariff rate lookup functionality and warnings. Introduced `useCustomsStore` for state management of customs data.
- **Database**: Added `nomenclature` table for storing HS code-related information.
- **Other**: Configured SOAP client for TARIC API and added logging for debugging.
2025-11-06 17:35:41 +01:00

911 lines
No EOL
35 KiB
JavaScript

import {defineStore} from 'pinia'
import {config} from '@/config'
import {toRaw} from "vue";
import {useErrorStore} from "@/store/error.js";
import logger from "@/logger.js"
import performRequest from '@/backend.js'
export const usePremiseEditStore = defineStore('premiseEdit', {
state() {
return {
premisses: null,
/**
* set to true while the store is loading the premises.
*/
loading: false,
/**
* set to true while the store sets the selected/deselected field in the premises.
*/
selectedLoading: false,
destinations: null,
processDestinationMassEdit: false,
selectedDestination: null,
throwsException: true,
}
},
getters: {
getCountryIdByPremiseIds(state) {
return function (ids) {
if (state.loading) {
if (state.throwsException)
throw new Error("Premises are accessed while still loading.");
return null;
}
const premiss = state.premisses?.filter(p => ids.some(id => id === p.id));
const premiseCountryMap = new Map();
premiss?.forEach(premise => {
premiseCountryMap.set(premise.id, premise.supplier?.country?.id);
});
return premiseCountryMap;
}
},
/**
* Returns the ids of all premises.
* @param state
* @returns {*}
*/
getPremiseIds(state) {
if (state.loading) {
if (state.throwsException)
throw new Error("Premises are accessed while still loading.");
return null;
}
return state.premisses?.map(p => p.id);
},
/**
* Returns the premises.
* @param state
* @returns {null|*[]|*}
*/
getPremisses(state) {
if (state.loading) {
if (state.throwsException)
throw new Error("Premises are accessed while still loading.");
return null;
}
return state.premisses;
},
/**
* Returns the premise with the given id.
* @param state
* @returns {function(*): *}
*/
getById(state) {
return function (id) {
if (state.loading) {
if (state.throwsException)
throw new Error("Premises are accessed while still loading.");
return null;
}
if ((id ?? null) === null) {
return null;
}
return state.premisses.find(p => p.id === id);
};
},
/**
* Returns all premises that are selected.
* @param state
* @returns {T[]}
*/
getSelectedPremisses(state) {
if (state.loading || state.selectedLoading) {
if (state.throwsException)
throw new Error("Premises are accessed while still loading.");
return null;
}
return state.premisses.filter(p => p.selected);
},
/**
* Returns all premise ids that are selected.
* @param state
* @returns {T[]}
*/
getSelectedPremissesIds(state) {
if (state.loading || state.selectedLoading) {
if (state.throwsException)
throw new Error("Premises are accessed while still loading.");
return null;
}
return state.premisses.filter(p => p.selected).map(p => p.id);
},
/**
* Getters for controlling frontend views
* ======================================
*/
/**
* Returns true if no premises are loaded. The frontend can show a message that no premises are found.
* @param state
* @returns {*|boolean}
*/
showEmpty(state) {
return (!state.loading && (((state.premisses ?? null) === null) || state.premisses.length === 0))
},
/**
* Returns true if the premises are loaded and not empty. The frontend can show a the loaded premisses.
* @param state
* @returns {boolean}
*/
showData(state) {
return (!state.loading && !((state.premisses ?? null) === null) && state.premisses.length !== 0)
},
/**
* Returns true if the premises are loaded and not empty. The frontend can show a the loaded premisses.
* @param state
* @returns {boolean}
*/
isLoading(state) {
return (state.loading);
},
showProcessingModal(state) {
return state.processDestinationMassEdit;
},
/**
* Getters for single edit view
* ============================
*/
/**
* Returns true if only one premise is selected.
* @param state
* @returns {boolean|null}
*/
isSingleSelect(state) {
if (state.loading || state.selectedLoading) {
if (state.throwsException)
throw new Error("Premises are accessed while still loading.");
return null;
}
return state.premisses.filter(p => p.selected).length === 1;
},
/**
* Returns the id of the single selected premise.
* @param state
* @returns {*}
*/
singleSelectId(state) {
if (state.loading || state.selectedLoading) {
if (state.throwsException)
throw new Error("Premises are accessed while still loading.");
return null;
}
if (!state.isSingleSelect) {
return null;
// throw new Error("Single selected premise accessed, but not in single select mode");
}
return state.premisses.find(p => p.selected)?.id;
},
/**
* Returns the single selected premise.
* @param state
* @returns {*}
*/
singleSelectedPremise(state) {
if (state.loading || state.selectedLoading) {
if (state.throwsException)
throw new Error("Premises are accessed while still loading.");
return null;
}
if (!state.isSingleSelect) {
return null;
// throw new Error("Single selected premise accessed, but not in single select mode");
}
return state.premisses?.find(p => p.selected);
},
/**
* Getters for destination editing
* ===============================
*/
getDestinationsView(state) {
return state.destinations?.destinations ?? [];
},
getDestinationById(state) {
return function (id) {
if (state.loading || state.selectedLoading) {
if (state.throwsException)
throw new Error("Premises are accessed while still loading.");
return null;
}
for (const p of state.premisses) {
const d = p.destinations.find(d => d.id === id);
if ((d ?? null) !== null) return d;
}
}
},
getSelectedDestinationsData: (state) => state.selectedDestination,
},
actions: {
async batchUpdatePrice(ids, priceData) {
const updatedPremises = this.premisses.map(p => {
if (ids.includes(p.id)) {
return {
...p,
...(priceData.price !== null && {material_cost: priceData.price}),
...(priceData.overSeaShare !== null && {oversea_share: priceData.overSeaShare}),
...(priceData.includeFcaFee !== null && {is_fca_enabled: priceData.includeFcaFee})
};
}
return p;
});
this.premisses = updatedPremises;
return await this.savePrice(ids, priceData);
},
async batchUpdateMaterial(ids, materialData, tariffRates = null) {
const updatedPremises = this.premisses.map(p => {
if (ids.includes(p.id)) {
return {
...p,
material: {
...p.material,
},
...(materialData.hsCode !== null && {hs_code: materialData.hsCode}),
...((tariffRates !== null) ? tariffRates.get(p.id) : (materialData.tariffRate !== null && {tariff_rate: materialData.tariffRate}))
};
}
return p;
});
this.premisses = updatedPremises;
return await this.saveMaterial(ids, materialData, tariffRates);
},
async batchUpdatePackaging(ids, packagingData) {
const updatedPremises = this.premisses.map(p => {
if (ids.includes(p.id)) {
return {
...p,
handling_unit: {
...p.handling_unit,
...(packagingData.weight !== null && {weight: packagingData.weight}),
...(packagingData.width !== null && {width: packagingData.width}),
...(packagingData.length !== null && {length: packagingData.length}),
...(packagingData.height !== null && {height: packagingData.height}),
...(packagingData.weightUnit !== null && {weight_unit: packagingData.weightUnit}),
...(packagingData.dimensionUnit !== null && {dimension_unit: packagingData.dimensionUnit}),
...(packagingData.unitCount !== null && {content_unit_count: packagingData.unitCount})
},
...(packagingData.stackable !== null && {is_stackable: packagingData.stackable}),
...(packagingData.mixable !== null && {is_mixable: packagingData.mixable})
};
}
return p;
});
this.premisses = updatedPremises;
logger.info("packaging data:", toRaw(packagingData), "update result", toRaw(updatedPremises));
return await this.savePackaging(ids, packagingData);
},
async startCalculation() {
const body = this.premisses.map(p => p.id);
const url = `${config.backendUrl}/calculation/start/`;
let error = null;
await performRequest(this, 'PUT', url, body, false, ['Premiss validation error', 'Internal Server Error']).catch(e => {
logger.log("startCalculation exception", e.errorObj);
error = e.errorObj;
})
return error;
},
/**
* DESTINATION stuff
* =================
*/
prepareDestinations(dataSourcePremiseId, editedPremiseIds, massEdit = false, fromMassEditView = false) {
if (this.premisses === null) return;
if (!editedPremiseIds || !dataSourcePremiseId || editedPremiseIds.length === 0) return;
this.destinations = {
premise_ids: editedPremiseIds,
massEdit: massEdit,
fromMassEditView: fromMassEditView,
destinations: this.premisses.find(p => String(p.id) === String(dataSourcePremiseId))?.destinations.map(d => this.copyAllFromPremises(d, !massEdit)) ?? [],
};
this.selectedDestination = null;
},
async executeDestinationsMassEdit() {
if (!this.destinations.massEdit) {
this.destinations.premise_ids.forEach(premiseId => {
const toPremise = this.getById(premiseId);
this.destinations.destinations.forEach(fromDest => {
const toDest = toPremise.destinations.find(to => fromDest.id.substring(1) === String(to.id));
if ((toDest ?? null) === null) {
throw new Error("Destination not found in premise: " + premiseId + " -> " + d.id);
}
this.copyAllToPremise(fromDest, toDest);
const body = {
annual_amount: toDest.annual_amount,
repackaging_costs: toDest.repackaging_costs,
handling_costs: toDest.handling_costs,
disposal_costs: toDest.disposal_costs,
is_d2d: toDest.is_d2d,
rate_d2d: toDest.rate_d2d,
lead_time_d2d: toDest.lead_time_d2d,
route_selected_id: toDest.routes.find(r => r.is_selected)?.id ?? null,
};
const url = `${config.backendUrl}/calculation/destination/${toDest.id}`;
performRequest(this, 'PUT', url, body, false);
});
});
} else {
this.processDestinationMassEdit = true;
const destinations = [];
this.destinations.destinations.forEach(d => {
const dest = {
destination_node_id: d.destination_node.id,
annual_amount: d.annual_amount,
disposal_costs: d.userDefinedHandlingCosts ? d.disposal_costs : null,
repackaging_costs: d.userDefinedHandlingCosts ? d.repackaging_costs : null,
handling_costs: d.userDefinedHandlingCosts ? d.handling_costs : null,
}
destinations.push(dest);
})
const body = {destinations: destinations, premise_id: this.destinations.premise_ids};
const url = `${config.backendUrl}/calculation/destination/`;
const {data: data, headers: headers} = await performRequest(this, 'PUT', url, body).catch(e => {
this.destinations = null;
this.processDestinationMassEdit = false;
});
if (data) {
for (const id of Object.keys(data)) {
this.premisses.find(p => String(p.id) === id).destinations = data[id];
}
}
this.destinations = null;
this.processDestinationMassEdit = false;
}
},
cancelMassEdit() {
this.destinations = null;
},
copyAllFromPremises(from, fullCopy = true) {
const d = {};
d.id = `e${from.id}`;
d.destination_node = structuredClone(toRaw(from.destination_node));
d.routes = fullCopy ? structuredClone(toRaw(from.routes)) : null;
d.annual_amount = from.annual_amount;
d.is_d2d = from.is_d2d;
d.rate_d2d = from.is_d2d ? from.rate_d2d : null;
d.lead_time_d2d = from.is_d2d ? from.lead_time_d2d : null;
d.handling_costs = from.handling_costs;
d.disposal_costs = from.disposal_costs;
d.repackaging_costs = from.repackaging_costs;
d.userDefinedHandlingCosts = from.handling_costs !== null || from.disposal_costs !== null || from.repackaging_costs !== null;
return d;
},
copyAllToPremise(from, to, fullCopy = true) {
const d = to ?? {};
d.annual_amount = from.annual_amount;
d.is_d2d = from.is_d2d;
d.rate_d2d = from.is_d2d ? from.rate_d2d : null;
d.lead_time_d2d = from.is_d2d ? from.lead_time_d2d : null;
if (from.userDefinedHandlingCosts) {
d.disposal_costs = from.disposal_costs;
d.repackaging_costs = from.repackaging_costs;
d.handling_costs = from.handling_costs;
} else {
d.disposal_costs = null;
d.repackaging_costs = null;
d.handling_costs = null;
}
if (fullCopy && (from.routes ?? null) !== null) {
to.routes.forEach(route => route.is_selected = from.routes.find(r => r.id === route.id)?.is_selected ?? false);
}
return d;
},
/**
* Selects all destinations for the given "ids" for editing.
* This creates a copy of the destination with id "id".
* They are written back as soon as the user closes the dialog.
*/
selectDestination(id) {
if (this.premisses === null) return;
logger.info("selectDestination:", id)
const dest = this.destinations.destinations.find(d => d.id === id);
if ((dest ?? null) == null) {
const error = {
code: 'Frontend error.',
message: `Destination not found: ${id}. Please contact support.`,
trace: null
}
throw new Error("Internal frontend error: Destination not found: " + id);
}
this.selectedDestination = structuredClone(toRaw(dest));
},
async deselectDestinations(save = false) {
if (this.premisses === null) return;
if (save) {
const idx = this.destinations.destinations.findIndex(d => d.id === this.selectedDestination.id);
this.destinations.destinations.splice(idx, 1, this.selectedDestination);
if (!this.destinations.fromMassEditView) {
//TODO write trough backend if no massEdit
const toDest = this.singleSelectedPremise.destinations.find(to => this.selectedDestination.id.substring(1) === String(to.id));
this.copyAllToPremise(this.selectedDestination, toDest);
const body = {
annual_amount: toDest.annual_amount,
repackaging_costs: toDest.repackaging_costs,
handling_costs: toDest.handling_costs,
disposal_costs: toDest.disposal_costs,
is_d2d: toDest.is_d2d,
rate_d2d: toDest.rate_d2d,
lead_time_d2d: toDest.lead_time_d2d,
route_selected_id: toDest.routes.find(r => r.is_selected)?.id ?? null,
};
logger.info(body)
const url = `${config.backendUrl}/calculation/destination/${toDest.id}`;
await performRequest(this, 'PUT', url, body, false);
}
}
this.selectedDestination = null;
},
async deleteDestination(id) {
/*
* 1. delete from destinations copy
*/
const idx = this.destinations.destinations.findIndex(d => d.id === id);
if (idx === -1) {
logger.info("Destination not found in mass edit: , id)");
return;
}
this.destinations.destinations.splice(idx, 1);
/*
* 2. delete from backend if not mass edit
*/
if (!this.destinations.massEdit && id.startsWith('e')) { /* 'v'-ids cannot be deleted because they only exist in the frontend */
if (this.premisses === null) return;
const origId = id.substring(1);
const url = `${config.backendUrl}/calculation/destination/${origId}`;
await performRequest(this, 'DELETE', url, null, false).catch(async e => {
logger.error("Unable to delete destination: " + origId + "");
logger.error(e);
await this.loadPremissesIfNeeded(this.premisses.map(p => p.id));
});
for (const p of this.premisses) {
const toBeDeleted = p.destinations.findIndex(d => String(d.id) === String(origId))
logger.info(toBeDeleted)
if (toBeDeleted !== -1) {
p.destinations.splice(toBeDeleted, 1)
break;
}
}
}
},
async addDestination(node) {
if (this.destinations.massEdit) {
const existing = this.destinations.destinations.find(d => d.destination_node.id === node.id);
logger.info(existing)
if ((existing ?? null) !== null) {
logger.info("Destination already exists", node.id);
return [existing.id];
}
const destination = {
id: `v${node.id}`,
destination_node: structuredClone(toRaw(node)),
massEdit: true,
annual_amount: 0,
is_d2d: false,
rate_d2d: null,
lead_time_d2d: null,
disposal_costs: null,
repackaging_costs: null,
handling_costs: null,
userDefinedHandlingCosts: false,
};
this.destinations.destinations.push(destination);
return [destination.id];
} else {
const id = node.id;
this.processDestinationMassEdit = true;
const toBeUpdated = this.destinations.fromMassEditView ? this.destinations.premise_ids : this.premisses?.filter(p => p.selected).map(p => p.id);
if (toBeUpdated === null || toBeUpdated.length === 0) return;
const body = {destination_node_id: id, premise_id: toBeUpdated};
const url = `${config.backendUrl}/calculation/destination/`;
const {data: destinations} = await performRequest(this, 'POST', url, body).catch(e => {
this.loading = false;
this.selectedLoading = false;
this.processDestinationMassEdit = false;
throw e;
});
const mappedIds = []
for (const id of Object.keys(destinations)) {
const premise = this.premisses.find(p => String(p.id) === id)
premise.destinations.push(destinations[id]);
const mappedDestination = this.copyAllFromPremises(destinations[id], true);
mappedIds.push(mappedDestination.id);
this.destinations.destinations.push(mappedDestination);
}
this.processDestinationMassEdit = false;
return mappedIds;
}
},
/**
* Set methods
* (these are more extensive changes. The edited premises are replaced by the one returned by the backend)
*/
async setSupplier(id, updateMasterData, ids = null) {
logger.info("setSupplier");
const selectedId = this.singleSelectId;
this.processDestinationMassEdit = true;
const body = {supplier_node_id: id, update_master_data: updateMasterData};
const url = `${config.backendUrl}/calculation/supplier/`;
await this.setData(url, body, ids);
if (selectedId != null && this.destinations && !this.destinations.fromMassEditView) {
this.prepareDestinations(selectedId, [selectedId]);
}
this.processDestinationMassEdit = false;
},
async setMaterial(id, updateMasterData, ids = null) {
logger.info("setMaterial");
const body = {material_id: id, update_master_data: updateMasterData};
const url = `${config.backendUrl}/calculation/material/`;
await this.setData(url, body, ids);
},
async setData(url, body, ids = null) {
const toBeUpdated = this.premisses ? (ids ? (ids) : (this.premisses.filter(p => p.selected).map(p => p.id))) : null;
if (null !== toBeUpdated) {
this.selectedLoading = true;
this.loading = true;
body.premise_id = toBeUpdated;
logger.info(url, body)
const {data: data} = await performRequest(this, 'PUT', url, body).catch(e => {
this.loading = false;
});
if (data) {
data.forEach(p => p.selected = true);
this.premisses = this.replacePremissesById(this.premisses, data);
}
this.loading = false;
this.selectedLoading = false;
}
},
/**
* Replace the premisses with the loaded ones by id.
* This is used to update the premisses after a "Set" change.
* @param premisses
* @param loadedData
* @returns {*}
*/
replacePremissesById(premisses, loadedData) {
const replacementMap = new Map(loadedData.map(obj => [obj.id, obj]));
const replaced = premisses.map(obj => replacementMap.get(obj.id) || obj);
logger.info("Replaced", replaced);
return replaced;
},
/**
* Save
*/
async savePrice(ids = null, priceData = null) {
let success = true;
const toBeUpdated = this.premisses ? (ids ? (ids.map(id => this.premisses.find(p => String(p.id) === String(id)))) : (this.premisses.filter(p => p.selected))) : null;
if (!toBeUpdated?.length) return;
const body = {
premise_ids: toBeUpdated.map(p => p.id),
material_cost: priceData === null ? toBeUpdated[0].material_cost : priceData.price,
oversea_share: priceData === null ? toBeUpdated[0].oversea_share : priceData.overSeaShare,
is_fca_enabled: priceData === null ? toBeUpdated[0].is_fca_enabled : priceData.includeFcaFee
};
await performRequest(this, 'POST', `${config.backendUrl}/calculation/price/`, body, false).catch(e => {
success = false;
})
return success;
},
async savePackaging(ids = null, packagingData = null) {
let success = true;
const toBeUpdated = this.premisses ? (ids ? (ids.map(id => this.premisses.find(p => String(p.id) === String(id)))) : (this.premisses.filter(p => p.selected))) : null;
if (!toBeUpdated?.length) return;
const body = {
premise_ids: toBeUpdated.map(p => p.id),
handling_unit: {
weight: packagingData === null ? toBeUpdated[0].handling_unit.weight : packagingData.weight,
weight_unit: packagingData === null ? toBeUpdated[0].handling_unit.weight_unit : packagingData.weightUnit,
length: packagingData === null ? toBeUpdated[0].handling_unit.length : packagingData.length,
width: packagingData === null ? toBeUpdated[0].handling_unit.width : packagingData.width,
height: packagingData === null ? toBeUpdated[0].handling_unit.height : packagingData.height,
dimension_unit: packagingData === null ? toBeUpdated[0].handling_unit.dimension_unit : packagingData.dimensionUnit,
content_unit_count: packagingData === null ? toBeUpdated[0].handling_unit.content_unit_count : packagingData.unitCount,
},
is_mixable: packagingData === null ? toBeUpdated[0].is_mixable : packagingData.mixable,
is_stackable: packagingData === null ? toBeUpdated[0].is_stackable : packagingData.stackable,
};
await performRequest(this, 'POST', `${config.backendUrl}/calculation/packaging/`, body, false).catch(() => {
success = false;
})
return success;
},
async saveMaterial(ids = null, materialData = null, tariffRates = null) {
let success = true;
const toBeUpdated = this.premisses ? (ids ? (ids.map(id => this.premisses.find(p => String(p.id) === String(id)))) : (this.premisses.filter(p => p.selected))) : null;
if (!toBeUpdated?.length) return;
const body = {
premise_ids: toBeUpdated.map(p => p.id),
hs_code: materialData === null ? toBeUpdated[0].hs_code : materialData.hsCode,
tariff_rate: materialData === null ? toBeUpdated[0].tariff_rate : materialData.tariffRate,
tariff_rates: (null === tariffRates) ? null : Object.fromEntries(tariffRates),
};
await performRequest(this, 'POST', `${config.backendUrl}/calculation/material/`, body, false).catch(() => {
success = false;
})
return success;
},
/**
* PREMISE stuff
* =================
*/
deselectPremise() {
this.selectedLoading = true;
this.premisses.forEach(p => p.selected = false);
this.destinations = null;
this.selectedDestination = null;
this.selectedLoading = false;
},
setAll(value) {
this.selectedLoading = true;
const updatedPremises = this.premisses.map(p => ({
...p,
selected: value
}));
this.premisses = updatedPremises;
this.selectedLoading = false;
},
setSelectTo(ids, value) {
this.selectedLoading = true;
const idsSet = new Set(ids);
const updatedPremises = this.premisses.map(p => ({
...p,
selected: idsSet.has(p.id) ? value : p.selected
}));
this.premisses = updatedPremises;
this.selectedLoading = false;
},
async selectSinglePremise(id, ids) {
this.selectedLoading = true;
await this.loadPremissesIfNeeded(ids);
this.premisses.forEach(p => p.selected = String(id) === String(p.id));
this.prepareDestinations(id, [id]);
this.selectedLoading = false;
},
async loadAndSelectSinglePremise(id) {
this.loading = true;
this.selectedLoading = true;
this.premises = [];
const params = new URLSearchParams();
params.append('premissIds', `${[id]}`);
const url = `${config.backendUrl}/calculation/edit/${params.size === 0 ? '' : '?'}${params.toString()}`;
const {data: data, headers: headers} = await performRequest(this, 'GET', url, null).catch(e => {
this.selectedLoading = false;
this.loading = false;
});
this.premisses = data;
this.premisses.forEach(p => p.selected = true);
this.prepareDestinations(id, [id]);
this.selectedLoading = false;
this.loading = false;
},
async loadPremissesIfNeeded(ids, exact = false) {
const reload = this.premisses ? !ids.every((id) => this.premisses.find(d => d.id === id) && (!exact || ids.length === this.premisses.length)) : true;
if (reload) {
await this.loadPremissesForced(ids);
}
},
async loadPremissesForced(ids) {
this.loading = true;
this.premises = [];
const params = new URLSearchParams();
params.append('premissIds', ids.join(', '));
const url = `${config.backendUrl}/calculation/edit/${params.size === 0 ? '' : '?'}${params.toString()}`;
const {data: data, headers: headers} = await performRequest(this, 'GET', url, null).catch(e => {
this.loading = false;
});
this.premisses = data;
this.premisses.forEach(p => p.selected = false);
this.loading = false;
},
removePremise(id) {
const idx = this.premisses.findIndex(p => p.id === id);
this.premisses.splice(idx, 1);
}
}
})
;