From 98dea7dd395acdd8bdba464c9acf721f6be58cca Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Thu, 4 Dec 2025 14:48:55 +0100 Subject: [PATCH] Implement empty transport feature in DirectorInfo component - Added functionality to allow directors to initiate empty transports without products, enhancing logistics management. - Introduced a new transport form in the DirectorInfo component, enabling selection of vehicle types and target branches. - Updated the i18n localization files to include new translations for the empty transport feature. - Enhanced the BranchView to pass vehicle and branch data to the DirectorInfo component, ensuring proper functionality. - This update aims to improve user experience and streamline transport operations within the application. --- .../make_transport_product_nullable.sql | 7 + backend/models/falukant/data/transport.js | 4 +- backend/services/falukantService.js | 178 +++++++------ .../src/components/falukant/DirectorInfo.vue | 251 +++++++++++++++++- frontend/src/i18n/locales/de/falukant.json | 17 +- frontend/src/views/falukant/BranchView.vue | 8 +- 6 files changed, 382 insertions(+), 83 deletions(-) create mode 100644 backend/migrations/make_transport_product_nullable.sql diff --git a/backend/migrations/make_transport_product_nullable.sql b/backend/migrations/make_transport_product_nullable.sql new file mode 100644 index 0000000..dd30ea5 --- /dev/null +++ b/backend/migrations/make_transport_product_nullable.sql @@ -0,0 +1,7 @@ +-- Migration: Make productId and size nullable in transport table +-- This allows empty transports (moving vehicles without products) + +ALTER TABLE falukant_data.transport + ALTER COLUMN product_id DROP NOT NULL, + ALTER COLUMN size DROP NOT NULL; + diff --git a/backend/models/falukant/data/transport.js b/backend/models/falukant/data/transport.js index 82b1dcc..667b323 100644 --- a/backend/models/falukant/data/transport.js +++ b/backend/models/falukant/data/transport.js @@ -15,11 +15,11 @@ Transport.init( }, productId: { type: DataTypes.INTEGER, - allowNull: false, + allowNull: true, // Nullable für leere Transporte (nur Fahrzeuge bewegen) }, size: { type: DataTypes.INTEGER, - allowNull: false, + allowNull: true, // Nullable für leere Transporte (nur Fahrzeuge bewegen) }, vehicleId: { type: DataTypes.INTEGER, diff --git a/backend/services/falukantService.js b/backend/services/falukantService.js index 1a9270d..1104174 100644 --- a/backend/services/falukantService.js +++ b/backend/services/falukantService.js @@ -92,12 +92,14 @@ function calcSellPrice(product, knowledgeFactor = 0) { return min + (max - min) * (knowledgeFactor / 100); } -async function calcRegionalSellPrice(product, knowledgeFactor, regionId) { - // Hole TownProductWorth für diese Region und dieses Produkt - const townWorth = await TownProductWorth.findOne({ - where: { productId: product.id, regionId: regionId } - }); - const worthPercent = townWorth?.worthPercent || 50; // Default 50% wenn nicht gefunden +async function calcRegionalSellPrice(product, knowledgeFactor, regionId, worthPercent = null) { + // Wenn worthPercent nicht übergeben wurde, hole es aus der Datenbank + if (worthPercent === null) { + const townWorth = await TownProductWorth.findOne({ + where: { productId: product.id, regionId: regionId } + }); + worthPercent = townWorth?.worthPercent || 50; // Default 50% wenn nicht gefunden + } // Basispreis basierend auf regionalem worthPercent const basePrice = product.sellCost * (worthPercent / 100); @@ -795,42 +797,59 @@ class FalukantService extends BaseService { } const maxByVehicles = capacityPerVehicle * freeVehicles.length; - const stock = await FalukantStock.findOne({ where: { branchId: sourceBranch.id } }); - if (!stock) { - throw new Error('Stock not found'); + // Produkt-Transport oder leerer Transport (nur Fahrzeuge bewegen)? + const isEmptyTransport = !productId || !quantity || quantity <= 0; + + let inventory = []; + let available = 0; + let maxByInventory = 0; + let hardMax = 0; + let requested = 0; + let transportCost = 0.1; // Minimale Kosten für leeren Transport + + if (!isEmptyTransport) { + // Produkt-Transport: Inventar prüfen + const stock = await FalukantStock.findOne({ where: { branchId: sourceBranch.id } }); + if (!stock) { + throw new Error('Stock not found'); + } + + inventory = await Inventory.findAll({ + where: { stockId: stock.id }, + include: [ + { + model: ProductType, + as: 'productType', + required: true, + where: { id: productId }, + }, + ], + }); + + available = inventory.reduce((sum, i) => sum + i.quantity, 0); + if (available <= 0) { + throw new PreconditionError('noInventory'); + } + + maxByInventory = available; + hardMax = Math.min(maxByVehicles, maxByInventory); + + requested = Math.max(1, parseInt(quantity, 10) || 0); + if (requested > hardMax) { + throw new PreconditionError('quantityTooHigh'); + } + + // Transportkosten: 1 % des Warenwerts, mindestens 0,1 + const productType = inventory[0]?.productType; + const unitValue = productType?.sellCost || 0; + const totalValue = unitValue * requested; + transportCost = Math.max(0.1, totalValue * 0.01); + } else { + // Leerer Transport: Ein Fahrzeug wird bewegt + requested = 1; + hardMax = 1; } - const inventory = await Inventory.findAll({ - where: { stockId: stock.id }, - include: [ - { - model: ProductType, - as: 'productType', - required: true, - where: { id: productId }, - }, - ], - }); - - const available = inventory.reduce((sum, i) => sum + i.quantity, 0); - if (available <= 0) { - throw new PreconditionError('noInventory'); - } - - const maxByInventory = available; - const hardMax = Math.min(maxByVehicles, maxByInventory); - - const requested = Math.max(1, parseInt(quantity, 10) || 0); - if (requested > hardMax) { - throw new PreconditionError('quantityTooHigh'); - } - - // Transportkosten: 1 % des Warenwerts, mindestens 0,1 - const productType = inventory[0]?.productType; - const unitValue = productType?.sellCost || 0; - const totalValue = unitValue * requested; - const transportCost = Math.max(0.1, totalValue * 0.01); - if (user.money < transportCost) { throw new PreconditionError('insufficientFunds'); } @@ -852,44 +871,52 @@ class FalukantService extends BaseService { for (const v of freeVehicles) { if (remaining <= 0) break; - const size = Math.min(remaining, capacityPerVehicle); + const size = isEmptyTransport ? 0 : Math.min(remaining, capacityPerVehicle); const t = await Transport.create( { sourceRegionId, targetRegionId, - productId, - size, + productId: isEmptyTransport ? null : productId, + size: isEmptyTransport ? 0 : size, vehicleId: v.id, }, { transaction: tx } ); transportsCreated.push(t); - remaining -= size; - } - - if (remaining > 0) { - throw new Error('Not enough vehicle capacity for requested quantity'); - } - - // Inventar in der Quell-Niederlassung reduzieren - let left = requested; - for (const inv of inventory) { - if (left <= 0) break; - if (inv.quantity <= left) { - left -= inv.quantity; - await inv.destroy({ transaction: tx }); + if (!isEmptyTransport) { + remaining -= size; } else { - await inv.update({ quantity: inv.quantity - left }, { transaction: tx }); - left = 0; - break; + // Bei leerem Transport nur ein Fahrzeug bewegen + remaining = 0; } } - if (left > 0) { - throw new Error('Inventory changed during transport creation'); + if (remaining > 0 && !isEmptyTransport) { + throw new Error('Not enough vehicle capacity for requested quantity'); + } + + // Inventar in der Quell-Niederlassung reduzieren (nur bei Produkt-Transport) + if (!isEmptyTransport && inventory.length > 0) { + let left = requested; + for (const inv of inventory) { + if (left <= 0) break; + if (inv.quantity <= left) { + left -= inv.quantity; + await inv.destroy({ transaction: tx }); + } else { + await inv.update({ quantity: inv.quantity - left }, { transaction: tx }); + left = 0; + break; + } + } + + if (left > 0) { + throw new Error('Inventory changed during transport creation'); + } + + notifyUser(user.user.hashedId, 'stock_change', { branchId: sourceBranch.id }); } - notifyUser(user.user.hashedId, 'stock_change', { branchId: sourceBranch.id }); notifyUser(user.user.hashedId, 'falukantBranchUpdate', { branchId: sourceBranch.id }); return { @@ -3721,7 +3748,8 @@ class FalukantService extends BaseService { ] }); - // TownProductWorth für alle Städte und dieses Produkt abrufen + // TownProductWorth für alle Städte und dieses Produkt einmalig abrufen + // (vermeidet N+1 Query Problem) const townWorths = await TownProductWorth.findAll({ where: { productId: productId }, attributes: ['regionId', 'worthPercent'] @@ -3729,13 +3757,14 @@ class FalukantService extends BaseService { const worthMap = new Map(townWorths.map(tw => [tw.regionId, tw.worthPercent])); // Berechne den regionalen Preis für die aktuelle Region (falls angegeben) + // WICHTIG: Ignoriere den übergebenen currentPrice, da er möglicherweise nicht + // den regionalen Faktor berücksichtigt. Berechne stattdessen immer den korrekten + // regionalen Preis basierend auf currentRegionId. let currentRegionalPrice = currentPrice; // Fallback auf übergebenen Preis if (currentRegionId) { const currentWorthPercent = worthMap.get(currentRegionId) || 50; - const currentBasePrice = product.sellCost * (currentWorthPercent / 100); - const currentMin = currentBasePrice * 0.6; - const currentMax = currentBasePrice; - currentRegionalPrice = currentMin + (currentMax - currentMin) * (knowledgeFactor / 100); + // Verwende calcRegionalSellPrice mit bereits geladenem worthPercent (keine DB-Query) + currentRegionalPrice = await calcRegionalSellPrice(product, knowledgeFactor, currentRegionId, currentWorthPercent); } // Für jede Stadt den Preis berechnen und Branch-Typ bestimmen @@ -3746,16 +3775,9 @@ class FalukantService extends BaseService { continue; } - // Regionaler Preis-Faktor (worthPercent zwischen 40-60) - const worthPercent = worthMap.get(city.id) || 50; // Default 50% wenn nicht gefunden - - // Basispreis basierend auf regionalem worthPercent - const basePrice = product.sellCost * (worthPercent / 100); - - // Dann Knowledge-Faktor anwenden (wie in calcSellPrice) - const min = basePrice * 0.6; - const max = basePrice; - const priceInCity = min + (max - min) * (knowledgeFactor / 100); + const worthPercent = worthMap.get(city.id) || 50; + // Verwende calcRegionalSellPrice mit bereits geladenem worthPercent (keine DB-Query) + const priceInCity = await calcRegionalSellPrice(product, knowledgeFactor, city.id, worthPercent); // Nur Städte zurückgeben, wo der Preis höher ist // Kleine Toleranz (0.01) für Rundungsfehler bei Gleitkommaberechnungen diff --git a/frontend/src/components/falukant/DirectorInfo.vue b/frontend/src/components/falukant/DirectorInfo.vue index e37147f..03b6ee7 100644 --- a/frontend/src/components/falukant/DirectorInfo.vue +++ b/frontend/src/components/falukant/DirectorInfo.vue @@ -116,6 +116,59 @@ + + +
+

{{ $t('falukant.branch.director.emptyTransport.title') }}

+

+ {{ $t('falukant.branch.director.emptyTransport.description') }} +

+
+ + + + +
+ {{ $t('falukant.branch.director.emptyTransport.cost', { cost: emptyTransportForm.costLabel }) }} +
+ +
+
+ {{ $t('falukant.branch.director.emptyTransport.duration', { duration: emptyTransportForm.durationLabel }) }} +
+
+ {{ $t('falukant.branch.director.emptyTransport.arrival', { datetime: emptyTransportForm.etaLabel }) }} +
+
+ {{ $t('falukant.branch.director.emptyTransport.route') }}: + {{ emptyTransportForm.routeNames.join(' → ') }} +
+
+ + +
+
@@ -126,7 +179,11 @@ import NewDirectorDialog from '@/dialogues/falukant/NewDirectorDialog.vue'; export default { name: "DirectorInfo", - props: { branchId: { type: Number, required: true } }, + props: { + branchId: { type: Number, required: true }, + vehicles: { type: Array, default: () => [] }, + branches: { type: Array, default: () => [] }, + }, components: { NewDirectorDialog }, @@ -135,6 +192,18 @@ export default { director: null, showNewDirectorDialog: false, editIncome: null, + emptyTransportForm: { + vehicleTypeId: null, + targetBranchId: null, + distance: null, + durationHours: null, + eta: null, + durationLabel: '', + etaLabel: '', + routeNames: [], + cost: 0.1, + costLabel: '', + }, }; }, async mounted() { @@ -212,6 +281,140 @@ export default { teachDirector() { alert(this.$t('falukant.branch.director.teachAlert')); }, + + vehicleTypeOptions() { + const groups = {}; + for (const v of this.vehicles || []) { + if (v.status !== 'available' || !v.type || !v.type.id) continue; + const id = v.type.id; + if (!groups[id]) { + groups[id] = { + id, + tr: v.type.tr, + capacity: v.type.capacity, + speed: v.type.speed, + count: 0, + }; + } + groups[id].count += 1; + } + return Object.values(groups); + }, + + targetBranchOptions() { + return (this.branches || []) + .filter(b => ['store', 'fullstack'].includes(b.branchTypeLabelTr)) + .filter(b => b.id !== this.branchId) + .map(b => ({ + id: b.id, + label: `${b.cityName} – ${b.type}`, + })); + }, + + async loadEmptyTransportRoute() { + this.emptyTransportForm.distance = null; + this.emptyTransportForm.durationHours = null; + this.emptyTransportForm.eta = null; + this.emptyTransportForm.durationLabel = ''; + this.emptyTransportForm.etaLabel = ''; + this.emptyTransportForm.routeNames = []; + + const vType = this.vehicleTypeOptions().find(v => v.id === this.emptyTransportForm.vehicleTypeId); + const targetBranch = (this.branches || []).find(b => b.id === this.emptyTransportForm.targetBranchId); + const sourceBranch = (this.branches || []).find(b => b.id === this.branchId); + + if (!vType || !targetBranch || !sourceBranch) { + return; + } + + try { + const { data } = await apiClient.get('/api/falukant/transports/route', { + params: { + sourceRegionId: sourceBranch.regionId, + targetRegionId: targetBranch.regionId, + vehicleTypeId: vType.id, + }, + }); + if (data && data.totalDistance != null) { + const distance = data.totalDistance; + const speed = vType.speed || 1; + const hours = distance / speed; + + this.emptyTransportForm.distance = distance; + this.emptyTransportForm.durationHours = hours; + + const now = new Date(); + const etaMs = now.getTime() + hours * 60 * 60 * 1000; + const etaDate = new Date(etaMs); + + const fullHours = Math.floor(hours); + const minutes = Math.round((hours - fullHours) * 60); + const parts = []; + if (fullHours > 0) parts.push(`${fullHours} h`); + if (minutes > 0) parts.push(`${minutes} min`); + this.emptyTransportForm.durationLabel = parts.length ? parts.join(' ') : '0 min'; + this.emptyTransportForm.etaLabel = etaDate.toLocaleString(); + + this.emptyTransportForm.routeNames = (data.regions || []).map(r => r.name); + } + // Kosten für leeren Transport: 0.1 + this.emptyTransportForm.cost = 0.1; + this.emptyTransportForm.costLabel = this.formatMoney(0.1); + } catch (error) { + console.error('Error loading transport route:', error); + this.emptyTransportForm.distance = null; + this.emptyTransportForm.durationHours = null; + this.emptyTransportForm.eta = null; + this.emptyTransportForm.durationLabel = ''; + this.emptyTransportForm.etaLabel = ''; + this.emptyTransportForm.routeNames = []; + } + }, + + formatMoney(amount) { + if (amount == null) return ''; + try { + return amount.toLocaleString(undefined, { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }); + } catch (e) { + return String(amount); + } + }, + + async createEmptyTransport() { + if (!this.emptyTransportForm.vehicleTypeId || !this.emptyTransportForm.targetBranchId) { + return; + } + try { + await apiClient.post('/api/falukant/transports', { + branchId: this.branchId, + vehicleTypeId: this.emptyTransportForm.vehicleTypeId, + productId: null, + quantity: 0, + targetBranchId: this.emptyTransportForm.targetBranchId, + }); + // Formular zurücksetzen + this.emptyTransportForm = { + vehicleTypeId: null, + targetBranchId: null, + distance: null, + durationHours: null, + eta: null, + durationLabel: '', + etaLabel: '', + routeNames: [], + cost: 0.1, + costLabel: '', + }; + alert(this.$t('falukant.branch.director.emptyTransport.success')); + this.$emit('transportCreated'); + } catch (error) { + console.error('Error creating empty transport:', error); + alert(this.$t('falukant.branch.director.emptyTransport.error')); + } + }, }, }; @@ -284,4 +487,50 @@ export default { border: 1px solid #ddd; padding: 4px 6px; } + +.director-transport-section { + margin-top: 2rem; + padding-top: 1rem; + border-top: 1px solid #ddd; +} + +.transport-description { + margin-bottom: 1rem; + color: #666; + font-style: italic; +} + +.transport-form { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.transport-form label { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.transport-form select { + padding: 0.5rem; + border: 1px solid #ccc; + border-radius: 4px; +} + +.transport-cost { + font-weight: bold; + color: #333; +} + +.transport-route { + padding: 0.75rem; + background-color: #f5f5f5; + border-radius: 4px; + font-size: 0.9em; +} + +.transport-route > div { + margin-bottom: 0.25rem; +} \ No newline at end of file diff --git a/frontend/src/i18n/locales/de/falukant.json b/frontend/src/i18n/locales/de/falukant.json index 4dd7afe..d6a2caa 100644 --- a/frontend/src/i18n/locales/de/falukant.json +++ b/frontend/src/i18n/locales/de/falukant.json @@ -151,7 +151,22 @@ "teach": "Weiterbilden", "produce": "Darf produzieren", "sell": "Darf verkaufen", - "starttransport": "Darf Transporte veranlassen" + "starttransport": "Darf Transporte veranlassen", + "emptyTransport": { + "title": "Transport ohne Produkte", + "description": "Bewege Transportmittel von dieser Niederlassung zu einer anderen, um sie besser zu nutzen.", + "vehicleType": "Fahrzeugtyp", + "selectVehicle": "Fahrzeugtyp auswählen", + "targetBranch": "Ziel-Niederlassung", + "selectTarget": "Ziel-Niederlassung auswählen", + "cost": "Kosten: {cost}", + "duration": "Dauer: {duration}", + "arrival": "Ankunft: {datetime}", + "route": "Route", + "create": "Transport starten", + "success": "Transport erfolgreich gestartet!", + "error": "Fehler beim Starten des Transportes." + } }, "sale": { "title": "Inventar", diff --git a/frontend/src/views/falukant/BranchView.vue b/frontend/src/views/falukant/BranchView.vue index 7fc4d13..df63cdc 100644 --- a/frontend/src/views/falukant/BranchView.vue +++ b/frontend/src/views/falukant/BranchView.vue @@ -24,7 +24,13 @@
- +