FRONTEND/BACKEND: Add batch destination editing with DestinationSetDTO support; refactor DestinationService to handle bulk updates and enhance validation; update PremiseEditStore with batch processing methods (finishMassEdit, cancelMassEdit); implement conditional UI behavior for bulk edit mode in CalculationMassEdit.vue and DestinationListView.vue; introduce new DTOs (DestinationSetDTO, DestinationSetListItemDTO) and repository methods (getByPremiseIdsAndNodeIds).

This commit is contained in:
Jan 2025-08-26 16:20:34 +02:00
parent 1690d869d6
commit 0061d01b75
10 changed files with 323 additions and 60 deletions

View file

@ -72,6 +72,9 @@
</div>
<div class="edit-calculation-cell-subline" v-for="name in destinationNames"> {{ name }}</div>
</div>
<div class="edit-calculation-empty" v-else-if="showMassEdit">
<spinner> </spinner>
</div>
<div class="edit-calculation-empty" :class="copyModeClass" v-else
@click="action('destinations')">
<basic-badge variant="exception" icon="warning">MISSING</basic-badge>
@ -105,12 +108,14 @@ import {
PhVectorTwo
} from "@phosphor-icons/vue";
import {UrlSafeBase64} from "@/common.js";
import Spinner from "@/components/UI/Spinner.vue";
export default {
name: "BulkEditRow",
emits: ['remove', 'action'],
components: {
Spinner,
PhMapPin,
PhFactory,
PhPercent,
@ -128,7 +133,7 @@ export default {
},
computed: {
destinationsCount() {
return this.premise.destinations.length;
return this.premise.destinations?.length ?? 0;
},
destinationsText() {
return this.premise.destinations.map(d => d.destination_node.name).join(', ');
@ -145,6 +150,9 @@ export default {
showDestinations() {
return (this.destinationsCount > 0);
},
showMassEdit() {
return this.premiseEditStore.showProcessingModal;
},
showHu() {
return (this.hu.width && this.hu.length && this.hu.height && this.hu.weight && this.hu.content_unit_count)
},

View file

@ -1,5 +1,5 @@
<template>
<div class="container">
<div class="container" :class="massEditClasses">
<div class="search-bar-container">
<autosuggest-searchbar class="search-bar"
placeholder="Add new Destination ..."
@ -72,6 +72,9 @@ export default {
}
},
computed: {
massEditClasses() {
return this.premiseEditStore.isSingleSelect ? '' : 'container--mass-edit';
},
...mapStores(usePremiseEditStore, useNodeStore),
destinations() {
return this.premiseEditStore.getDestinationsView;
@ -131,6 +134,10 @@ export default {
}
.container--mass-edit {
width: min(80vw, 120rem);
}
.search-bar-container {
margin: 3rem 3rem 0 3rem;

View file

@ -124,7 +124,6 @@ export default {
},
...mapStores(usePremiseEditStore),
destination() {
//TODO handle multiselect (here?)
return this.premiseEditStore.getSelectedDestinationsData;
},
annualAmount: {

View file

@ -194,18 +194,21 @@ export default {
this.openModal(action, this.selectedPremisses.map(p => p.id));
},
onClickAction(data) {
if (0 !== this.premiseEditStore.selectCount) {
if (0 !== this.selectCount) {
this.openModal(data.action, this.premiseEditStore.getSelectedPremissesIds, data.id);
} else {
this.openModal(data.action, [data.id], data.id);
this.openModal(data.action, [data.id], data.id, true);
}
},
openModal(type, ids, dataSource = -1) {
openModal(type, ids, dataSource = -1, noMassEdit = false) {
if (type !== 'destinations')
this.fillData(type, dataSource)
else {
this.premiseEditStore.prepareMassEdit(dataSource, ids);
if (noMassEdit)
this.premiseEditStore.selectPremise(dataSource);
else
this.premiseEditStore.prepareMassEdit(dataSource, ids);
}
this.editIds = ids;
@ -214,12 +217,51 @@ export default {
closeEditModalAction(action) {
if (this.modalType === "destinations") {
if(action === "accept") {
this.premiseEditStore.finishMassEdit();
if(this.premiseEditStore.isMassEdit) {
if (action === "accept") {
this.premiseEditStore.finishMassEdit();
} else {
console.log("cancel mass edit")
this.premiseEditStore.cancelMassEdit();
}
} else {
//TODO save
this.premiseEditStore.deselectPremise();
}
} else {
if (action === "accept") {
if (this.modalType === "price") {
this.selectedPremisses.forEach(p => {
p.material_cost = componentsData[this.modalType].props.price;
p.oversea_share = componentsData[this.modalType].props.overSeaShare;
p.is_fca_enabled = componentsData[this.modalType].props.includeFcaFee;
})
} else if (this.modalType === "material") {
this.selectedPremisses.forEach(p => {
p.material.part_number = componentsData[this.modalType].props.partNumber;
p.material.hs_code = componentsData[this.modalType].props.hsCode;
p.tariff_rate = componentsData[this.modalType].props.tariffRate;
});
} else if (this.modalType === "packaging") {
this.selectedPremisses.forEach(p => {
p.handling_unit.weight = componentsData[this.modalType].props.weight;
p.handling_unit.height = componentsData[this.modalType].props.height;
p.handling_unit.length = componentsData[this.modalType].props.length;
p.handling_unit.height = componentsData[this.modalType].props.height;
p.handling_unit.weight_unit = componentsData[this.modalType].props.weightUnit;
p.handling_unit.dimension_unit = componentsData[this.modalType].props.dimensionUnit;
p.handling_unit.content_unit_count = componentsData[this.modalType].props.unitCount;
p.is_stackable = componentsData[this.modalType].props.is_stackable;
p.is_mixable = componentsData[this.modalType].props.is_mixable;
});
} else if (this.modalType === "supplier") {
//set supplier.
}
}
}
@ -227,15 +269,16 @@ export default {
this.fillData(this.modalType);
this.closeEditModal();
},
triggerProcessingModal(text) {
if(text) {
this.processingMessage = text;
this.processingModal = true;
}
else
this.processingModal = false
},
closeEditModal() {
if (this.modalType === "destinations") {
if(!this.premiseEditStore.isMassEdit) {
this.premiseEditStore.deselectPremise();
}
this.premiseEditStore.cancelMassEdit();
}
this.modalType = null;
},
fillData(type, id = -1) {

View file

@ -75,7 +75,7 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
return null;
}
if((id ?? null) === null) {
if ((id ?? null) === null) {
return null;
}
@ -223,7 +223,7 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
*/
getDestinationsView(state) {
if(state.massEditDestinations !== null) {
if (state.massEditDestinations !== null) {
return state.massEditDestinations.destinations;
}
@ -241,7 +241,7 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
for (const p of state.premisses) {
const d = p.destinations.find(d => d.id === id);
if (d !== null) return d;
if ((d ?? null) !== null) return d;
}
}
},
@ -282,17 +282,45 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
async finishMassEdit() {
this.processMassEdit = true;
const destinations = [];
this.massEditDestinations.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.massEditDestinations.premise_ids};
const url = `${config.backendUrl}/calculation/destination/`;
const data = await this.performRequest('PUT', url, body).catch(e => {
this.massEditDestinations = null;
this.processMassEdit = false;
})
if (data) {
for (const id of Object.keys(data)) {
this.premisses.find(p => String(p.id) === id).destinations = data[id];
}
}
this.massEditDestinations = null;
this.processMassEdit = false;
},
cancelMassEdit() {
this.massEditDestinations = null;
},
copyAllButRouting(from, to = null) {
const fromIsOrig = (to === null);
const d = to ?? {};
if(fromIsOrig) {
if (fromIsOrig) {
d.id = `e${from.id}`;
d.destination_node = structuredClone(toRaw(from.destination_node));
d.massEdit = true;
@ -361,11 +389,11 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
},
async deleteDestination(id) {
if(this.isMassEdit) {
if (this.isMassEdit) {
const idx = this.massEditDestinations.destinations.findIndex(d => d.id === id);
if(idx === -1) {
if (idx === -1) {
console.info("Destination not found in mass edit: , id)");
return;
}
@ -392,12 +420,12 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
},
async addDestination(node) {
if(this.isMassEdit) {
if (this.isMassEdit) {
const existing = this.massEditDestinations.destinations.find(d => d.destination_node.id === node.id);
console.log(existing)
console.log(existing)
if((existing ?? null) !== null) {
if ((existing ?? null) !== null) {
console.info("Destination already exists", node.id);
return [existing.id];
}
@ -506,7 +534,16 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
* PREMISE stuff
* =================
*/
deselectPremise() {
this.selectedLoading = true;
this.premisses.forEach(p => p.selected = false);
this.selectedLoading = false;
},
selectPremise(id) {
this.selectedLoading = true;
this.premisses.forEach(p => p.selected = p.id === id);
this.selectedLoading = false;
},
setSelectTo(ids, value) {
this.selectedLoading = true;
this.premisses.forEach(p => p.selected = ids.includes(p.id) ? value : p.selected);

View file

@ -9,6 +9,7 @@ import de.avatic.lcc.dto.calculation.create.PremiseSearchResultDTO;
import de.avatic.lcc.dto.calculation.edit.PremiseDetailDTO;
import de.avatic.lcc.dto.calculation.edit.SetDataDTO;
import de.avatic.lcc.dto.calculation.edit.destination.DestinationCreateDTO;
import de.avatic.lcc.dto.calculation.edit.destination.DestinationSetDTO;
import de.avatic.lcc.dto.calculation.edit.destination.DestinationUpdateDTO;
import de.avatic.lcc.dto.calculation.edit.masterData.MaterialUpdateDTO;
import de.avatic.lcc.dto.calculation.edit.masterData.PackagingUpdateDTO;
@ -149,6 +150,11 @@ public class PremiseController {
return ResponseEntity.ok(destinationService.createDestination(destinationCreateDTO));
}
@PutMapping({"/destination", "/destination/"})
public ResponseEntity<Map<Integer, List<DestinationDTO>>> setDestination(@RequestBody DestinationSetDTO destinationSetDTO) {
return ResponseEntity.ok(destinationService.setDestination(destinationSetDTO));
}
@GetMapping({"/destination/{id}", "/destination/{id}/"})
public ResponseEntity<DestinationDTO> getDestination(@PathVariable Integer id) {
return ResponseEntity.ok(destinationService.getDestination(id));

View file

@ -0,0 +1,33 @@
package de.avatic.lcc.dto.calculation.edit.destination;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Digits;
import jakarta.validation.constraints.Min;
import java.util.List;
public class DestinationSetDTO {
@JsonProperty("premise_id")
List<Integer> premiseId;
@JsonProperty("destinations")
List<DestinationSetListItemDTO> destinations;
public List<Integer> getPremiseId() {
return premiseId;
}
public void setPremiseId(List<Integer> premiseId) {
this.premiseId = premiseId;
}
public List<DestinationSetListItemDTO> getDestinations() {
return destinations;
}
public void setDestinations(List<DestinationSetListItemDTO> destinations) {
this.destinations = destinations;
}
}

View file

@ -0,0 +1,73 @@
package de.avatic.lcc.dto.calculation.edit.destination;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Digits;
import jakarta.validation.constraints.Min;
import java.util.List;
public class DestinationSetListItemDTO {
@JsonProperty("destination_node_id")
Integer destinationNodeId;
@JsonProperty("annual_amount")
@Min(1)
private Integer annualAmount;
@JsonProperty("repacking_cost")
@DecimalMin(value = "0.00", message = "Amount must be greater than or equal 0")
@Digits(integer = 13, fraction = 2, message = "Amount must have at most 2 decimal places")
private Number repackingCost;
@JsonProperty("handling_cost")
@DecimalMin(value = "0.00", message = "Amount must be greater than or equal 0")
@Digits(integer = 13, fraction = 2, message = "Amount must have at most 2 decimal places")
private Number handlingCost;
@JsonProperty("disposal_cost")
@DecimalMin(value = "0.00", message = "Amount must be greater than or equal 0")
@Digits(integer = 13, fraction = 2, message = "Amount must have at most 2 decimal places")
private Number disposalCost;
public Integer getDestinationNodeId() {
return destinationNodeId;
}
public void setDestinationNodeId(Integer destinationNodeId) {
this.destinationNodeId = destinationNodeId;
}
public Integer getAnnualAmount() {
return annualAmount;
}
public void setAnnualAmount(Integer annualAmount) {
this.annualAmount = annualAmount;
}
public Number getRepackingCost() {
return repackingCost;
}
public void setRepackingCost(Number repackingCost) {
this.repackingCost = repackingCost;
}
public Number getHandlingCost() {
return handlingCost;
}
public void setHandlingCost(Number handlingCost) {
this.handlingCost = handlingCost;
}
public Number getDisposalCost() {
return disposalCost;
}
public void setDisposalCost(Number disposalCost) {
this.disposalCost = disposalCost;
}
}

View file

@ -130,7 +130,7 @@ public class DestinationRepository {
@Transactional
public List<Destination> getByPremiseIdsAndNodeId(List<Integer> premiseId, Integer nodeId, Integer userId) {
String placeholder = String.join(",", Collections.nCopies(premiseId.size(), "?"));
String query = "SELECT * FROM premise_destination JOIN premise ON premise_destination.premise_id = premise.id WHERE premise_destination.premise_id IN ("+placeholder+") AND premise_destination.destination_node_id = ? AND premise.user_id = ?";
String query = "SELECT * FROM premise_destination JOIN premise ON premise_destination.premise_id = premise.id WHERE premise_destination.premise_id IN (" + placeholder + ") AND premise_destination.destination_node_id = ? AND premise.user_id = ?";
// Create array with all parameters
Object[] params = new Object[premiseId.size() + 2];
@ -143,6 +143,28 @@ public class DestinationRepository {
return jdbcTemplate.query(query, new DestinationMapper(), params);
}
@Transactional
public Map<Integer, List<Destination>> getByPremiseIdsAndNodeIds(List<Integer> premiseIds, List<Integer> nodeIds, Integer userId) {
Map<Integer, List<Destination>> destinations = new HashMap<>();
for (Integer premiseId : premiseIds) {
String placeholder = String.join(",", Collections.nCopies(nodeIds.size(), "?"));
String query = "SELECT * FROM premise_destination JOIN premise ON premise_destination.premise_id = premise.id WHERE premise_destination.premise_id = ? AND premise_destination.destination_node_id IN (" + placeholder + ") AND premise.user_id = ?";
// Create array with all parameters
Object[] params = new Object[nodeIds.size() + 2];
for (int i = 0; i < nodeIds.size(); i++) {
params[i] = nodeIds.get(i);
}
params[nodeIds.size()] = premiseId;
params[nodeIds.size() + 1] = userId;
destinations.put(premiseId,jdbcTemplate.query(query, new DestinationMapper(), params));
}
return destinations;
}
@Transactional
public Integer insert(Destination destination) {
KeyHolder keyHolder = new GeneratedKeyHolder();
@ -152,7 +174,7 @@ public class DestinationRepository {
jdbcTemplate.update(connection -> {
var ps = connection.prepareStatement(query, Statement.RETURN_GENERATED_KEYS);
if(destination.getAnnualAmount() == null)
if (destination.getAnnualAmount() == null)
ps.setNull(1, java.sql.Types.INTEGER);
else
ps.setInt(1, destination.getAnnualAmount());
@ -162,7 +184,7 @@ public class DestinationRepository {
ps.setInt(4, destination.getCountryId());
ps.setBigDecimal(5, destination.getRateD2d());
if(destination.getLeadTimeD2d() == null)
if (destination.getLeadTimeD2d() == null)
ps.setNull(6, java.sql.Types.INTEGER);
else
ps.setInt(6, destination.getLeadTimeD2d());
@ -192,7 +214,7 @@ public class DestinationRepository {
var ownerId = getOwnerIdById(id);
if (ownerId.isEmpty() || !ownerId.get().equals(userId)) {
throw new ForbiddenException("Access violation. Accessing destination with id = " + id);
throw new ForbiddenException("Access violation. Accessing destination with id " + id + " (owner: "+ownerId.orElse(null)+" user: " + userId + ")");
}
}

View file

@ -2,6 +2,7 @@ package de.avatic.lcc.service.access;
import de.avatic.lcc.dto.calculation.DestinationDTO;
import de.avatic.lcc.dto.calculation.edit.destination.DestinationCreateDTO;
import de.avatic.lcc.dto.calculation.edit.destination.DestinationSetDTO;
import de.avatic.lcc.dto.calculation.edit.destination.DestinationUpdateDTO;
import de.avatic.lcc.model.nodes.Node;
import de.avatic.lcc.model.premises.route.*;
@ -51,6 +52,58 @@ public class DestinationService {
this.propertyService = propertyService;
}
@Transactional
public Map<Integer, List<DestinationDTO>> setDestination(DestinationSetDTO dto) {
//TODO fix user authorization
var userId = 1;
//dto.getPremiseId().forEach(id -> destinationRepository.checkOwner(id, userId));
deleteAllDestinationsByPremiseId(dto.getPremiseId(), false);
Map<Integer, List<Destination>> destinations = dto.getDestinations().stream()
.flatMap(destination -> createDestination(dto.getPremiseId(), destination.getDestinationNodeId(), destination.getAnnualAmount(), destination.getRepackingCost(), destination.getDisposalCost(), destination.getHandlingCost()).stream())
.collect(Collectors.groupingBy(Destination::getPremiseId));
return destinations.entrySet().stream()
.collect(Collectors.toMap(
Map.Entry::getKey,
entry -> entry.getValue().stream().map(destinationTransformer::toDestinationDTO).toList()));
}
private List<Destination> createDestination(List<Integer> premiseIds, Integer destinationNodeId, Integer annualAmount, Number repackingCost, Number disposalCost, Number handlingCost) {
var premisesToProcess = premiseRepository.getPremisesById(premiseIds);
Node destinationNode = nodeRepository.getById(destinationNodeId).orElseThrow();
var destinations = new ArrayList<Destination>();
for (var premise : premisesToProcess) {
var destination = new Destination();
destination.setDestinationNodeId(destinationNodeId);
destination.setPremiseId(premise.getId());
destination.setAnnualAmount(annualAmount);
destination.setD2d(false);
destination.setLeadTimeD2d(null);
destination.setRateD2d(null);
destination.setDisposalCost(disposalCost == null ? null : BigDecimal.valueOf(disposalCost.doubleValue()));
destination.setHandlingCost(handlingCost == null ? null : BigDecimal.valueOf(handlingCost.doubleValue()));
destination.setRepackingCost(repackingCost == null ? null : BigDecimal.valueOf(repackingCost.doubleValue()));
destination.setCountryId(destinationNode.getCountryId());
destination.setGeoLat(destinationNode.getGeoLat());
destination.setGeoLng(destinationNode.getGeoLng());
destination.setId(destinationRepository.insert(destination));
Node source = premise.getSupplierNodeId() == null ? userNodeRepository.getById(premise.getUserSupplierNodeId()).orElseThrow() : nodeRepository.getById(premise.getSupplierNodeId()).orElseThrow();
findRouteAndSave(destination.getId(), destinationNode, source, premise.getSupplierNodeId() == null);
destinations.add(destination);
}
return destinations;
}
@Transactional
public Map<Integer, DestinationDTO> createDestination(DestinationCreateDTO dto) {
@ -69,33 +122,7 @@ public class DestinationService {
return new HashMap<>();
var premisesToProcess = premiseRepository.getPremisesById(premisesIdsToProcess);
Node destinationNode = nodeRepository.getById(dto.getDestinationNodeId()).orElseThrow();
var destinations = new ArrayList<Destination>();
for (var premise : premisesToProcess) {
var destination = new Destination();
destination.setDestinationNodeId(dto.getDestinationNodeId());
destination.setPremiseId(premise.getId());
destination.setAnnualAmount(0);
destination.setD2d(false);
destination.setLeadTimeD2d(null);
destination.setRateD2d(null);
destination.setDisposalCost(null);
destination.setHandlingCost(null);
destination.setRepackingCost(null);
destination.setCountryId(destinationNode.getCountryId());
destination.setGeoLat(destinationNode.getGeoLat());
destination.setGeoLng(destinationNode.getGeoLng());
destination.setId(destinationRepository.insert(destination));
Node source = premise.getSupplierNodeId() == null ? userNodeRepository.getById(premise.getUserSupplierNodeId()).orElseThrow() : nodeRepository.getById(premise.getSupplierNodeId()).orElseThrow();
findRouteAndSave(destination.getId(), destinationNode, source, premise.getSupplierNodeId() == null);
destinations.add(destination);
}
var destinations = createDestination(premisesIdsToProcess, dto.getDestinationNodeId(), null, null, null, null);
return destinations.stream().collect(Collectors.toMap(Destination::getPremiseId, destinationTransformer::toDestinationDTO));
}
@ -167,7 +194,13 @@ public class DestinationService {
@Transactional
public void deleteAllDestinationsByPremiseId(List<Integer> ids, boolean deleteRoutesOnly) {
ids.forEach(id -> deleteDestinationById(id, deleteRoutesOnly));
ids.forEach(id -> deleteAllDestinationsByPremiseId(id, deleteRoutesOnly));
}
@Transactional
public void deleteAllDestinationsByPremiseId(Integer premiseId, boolean deleteRoutesOnly) {
var destinations = destinationRepository.getByPremiseId(premiseId);
destinations.forEach(destination -> deleteDestinationById(destination.getId(), deleteRoutesOnly));
}
@ -229,4 +262,6 @@ public class DestinationService {
}
}
}
}