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.description') }} +
+