Add product price retrieval feature in cities

- Implemented a new endpoint in FalukantController to fetch product prices in various cities based on product ID and current price.
- Developed the corresponding service method in FalukantService to calculate and return prices, considering user knowledge and city branches.
- Updated frontend components (RevenueSection and SaleSection) to display better prices for products, including loading logic and UI enhancements for price visibility.
- Added styling for price indicators based on branch types to improve user experience.
This commit is contained in:
Torsten Schulz (local)
2025-12-01 16:42:54 +01:00
parent 8c8841705c
commit adc7132404
5 changed files with 260 additions and 0 deletions

View File

@@ -144,6 +144,14 @@ class FalukantController {
this.applyForElections = this._wrapWithUser((userId, req) => this.service.applyForElections(userId, req.body.electionIds)); this.applyForElections = this._wrapWithUser((userId, req) => this.service.applyForElections(userId, req.body.electionIds));
this.getRegions = this._wrapWithUser((userId) => this.service.getRegions(userId)); this.getRegions = this._wrapWithUser((userId) => this.service.getRegions(userId));
this.getProductPricesInCities = this._wrapWithUser((userId, req) => {
const productId = parseInt(req.query.productId, 10);
const currentPrice = parseFloat(req.query.currentPrice);
if (Number.isNaN(productId) || Number.isNaN(currentPrice)) {
throw new Error('productId and currentPrice are required');
}
return this.service.getProductPricesInCities(userId, productId, currentPrice);
});
this.renovate = this._wrapWithUser((userId, req) => this.service.renovate(userId, req.body.element)); this.renovate = this._wrapWithUser((userId, req) => this.service.renovate(userId, req.body.element));
this.renovateAll = this._wrapWithUser((userId) => this.service.renovateAll(userId)); this.renovateAll = this._wrapWithUser((userId) => this.service.renovateAll(userId));

View File

@@ -70,6 +70,7 @@ router.post('/politics/elections', falukantController.vote);
router.get('/politics/open', falukantController.getOpenPolitics); router.get('/politics/open', falukantController.getOpenPolitics);
router.post('/politics/open', falukantController.applyForElections); router.post('/politics/open', falukantController.applyForElections);
router.get('/cities', falukantController.getRegions); router.get('/cities', falukantController.getRegions);
router.get('/products/prices-in-cities', falukantController.getProductPricesInCities);
router.get('/vehicles/types', falukantController.getVehicleTypes); router.get('/vehicles/types', falukantController.getVehicleTypes);
router.post('/vehicles', falukantController.buyVehicles); router.post('/vehicles', falukantController.buyVehicles);
router.get('/vehicles', falukantController.getVehicles); router.get('/vehicles', falukantController.getVehicles);

View File

@@ -3518,6 +3518,86 @@ class FalukantService extends BaseService {
return regions; return regions;
} }
async getProductPricesInCities(hashedUserId, productId, currentPrice) {
const user = await this.getFalukantUserByHashedId(hashedUserId);
const character = await FalukantCharacter.findOne({ where: { userId: user.id } });
if (!character) {
throw new Error(`No FalukantCharacter found for user with id ${user.id}`);
}
// Produkt abrufen
const product = await ProductType.findOne({ where: { id: productId } });
if (!product) {
throw new Error(`Product not found with id ${productId}`);
}
// Knowledge für dieses Produkt abrufen
const knowledge = await Knowledge.findOne({
where: { characterId: character.id, productId: productId }
});
const knowledgeFactor = knowledge?.knowledge || 0;
// Alle Städte abrufen
const cities = await RegionData.findAll({
attributes: ['id', 'name'],
include: [
{
model: RegionType,
as: 'regionType',
where: { labelTr: 'city' },
attributes: ['labelTr']
},
{
model: Branch,
as: 'branches',
where: { falukantUserId: user.id },
include: [
{
model: BranchType,
as: 'branchType',
attributes: ['labelTr']
}
],
attributes: ['branchTypeId'],
required: false
}
]
});
// Für jede Stadt den Preis berechnen und Branch-Typ bestimmen
const results = [];
for (const city of cities) {
const priceInCity = calcSellPrice(product, knowledgeFactor);
// Nur Städte zurückgeben, wo der Preis höher ist
if (priceInCity > currentPrice) {
// Branch-Typ bestimmen
let branchType = null; // null = kein Branch
if (city.branches && city.branches.length > 0) {
// Finde den "besten" Branch-Typ (store/fullstack > production)
const branchTypes = city.branches.map(b => b.branchType?.labelTr).filter(Boolean);
if (branchTypes.includes('store') || branchTypes.includes('fullstack')) {
branchType = 'store'; // Grün
} else if (branchTypes.includes('production')) {
branchType = 'production'; // Orange
}
}
results.push({
regionId: city.id,
regionName: city.name,
price: priceInCity,
branchType: branchType // 'store' (grün), 'production' (orange), null (rot)
});
}
}
// Sortiere nach Preis (höchster zuerst)
results.sort((a, b) => b.price - a.price);
return results;
}
async renovate(hashedUserId, element) { async renovate(hashedUserId, element) {
const user = await getFalukantUserOrFail(hashedUserId); const user = await getFalukantUserOrFail(hashedUserId);
const house = await UserHouse.findOne({ const house = await UserHouse.findOne({

View File

@@ -16,6 +16,7 @@
<th>{{ $t('falukant.branch.revenue.perMinute') }}</th> <th>{{ $t('falukant.branch.revenue.perMinute') }}</th>
<th>{{ $t('falukant.branch.revenue.profitAbsolute') }}</th> <th>{{ $t('falukant.branch.revenue.profitAbsolute') }}</th>
<th>{{ $t('falukant.branch.revenue.profitPerMinute') }}</th> <th>{{ $t('falukant.branch.revenue.profitPerMinute') }}</th>
<th>Bessere Preise</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -26,6 +27,16 @@
<td>{{ calculateProductRevenue(product).perMinute }}</td> <td>{{ calculateProductRevenue(product).perMinute }}</td>
<td>{{ calculateProductProfit(product).absolute }}</td> <td>{{ calculateProductProfit(product).absolute }}</td>
<td>{{ calculateProductProfit(product).perMinute }}</td> <td>{{ calculateProductProfit(product).perMinute }}</td>
<td>
<div v-if="product.betterPrices && product.betterPrices.length > 0" class="price-cities">
<span v-for="city in product.betterPrices" :key="city.regionId"
:class="['city-price', getCityPriceClass(city.branchType)]"
:title="`${city.regionName}: ${formatPrice(city.price)}`">
{{ city.regionName }}
</span>
</div>
<span v-else class="no-better-prices"></span>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -34,6 +45,8 @@
</template> </template>
<script> <script>
import apiClient from '@/utils/axios.js';
export default { export default {
name: "RevenueSection", name: "RevenueSection",
props: { props: {
@@ -44,6 +57,7 @@
data() { data() {
return { return {
isRevenueTableOpen: false, isRevenueTableOpen: false,
loadingPrices: new Set(),
}; };
}, },
computed: { computed: {
@@ -56,9 +70,64 @@
}, null); }, null);
}, },
}, },
async mounted() {
if (this.isRevenueTableOpen) {
await this.loadPricesForAllProducts();
}
},
watch: {
isRevenueTableOpen(newVal) {
if (newVal) {
this.loadPricesForAllProducts();
}
},
products: {
handler() {
if (this.isRevenueTableOpen) {
this.loadPricesForAllProducts();
}
},
deep: true
}
},
methods: { methods: {
toggleRevenueTable() { toggleRevenueTable() {
this.isRevenueTableOpen = !this.isRevenueTableOpen; this.isRevenueTableOpen = !this.isRevenueTableOpen;
if (this.isRevenueTableOpen) {
this.loadPricesForAllProducts();
}
},
async loadPricesForAllProducts() {
for (const product of this.products) {
if (this.loadingPrices.has(product.id)) continue;
this.loadingPrices.add(product.id);
try {
const currentPrice = parseFloat(this.calculateProductRevenue(product).absolute);
const { data } = await apiClient.get('/api/falukant/products/prices-in-cities', {
params: {
productId: product.id,
currentPrice: currentPrice
}
});
this.$set(product, 'betterPrices', data || []);
} catch (error) {
console.error(`Error loading prices for product ${product.id}:`, error);
this.$set(product, 'betterPrices', []);
} finally {
this.loadingPrices.delete(product.id);
}
}
},
getCityPriceClass(branchType) {
if (branchType === 'store') return 'city-price-green';
if (branchType === 'production') return 'city-price-orange';
return 'city-price-red';
},
formatPrice(price) {
return new Intl.NumberFormat(navigator.language, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(price);
}, },
}, },
}; };
@@ -94,5 +163,32 @@
.highlight { .highlight {
background-color: #dfffd6; background-color: #dfffd6;
} }
.price-cities {
display: flex;
flex-wrap: wrap;
gap: 0.3em;
}
.city-price {
padding: 0.2em 0.4em;
border-radius: 3px;
font-size: 0.85em;
cursor: help;
}
.city-price-green {
background-color: #90EE90;
color: #000;
}
.city-price-orange {
background-color: #FFA500;
color: #000;
}
.city-price-red {
background-color: #FF6B6B;
color: #fff;
}
.no-better-prices {
color: #999;
font-style: italic;
}
</style> </style>

View File

@@ -10,6 +10,7 @@
<th>{{ $t('falukant.branch.sale.quality') }}</th> <th>{{ $t('falukant.branch.sale.quality') }}</th>
<th>{{ $t('falukant.branch.sale.quantity') }}</th> <th>{{ $t('falukant.branch.sale.quantity') }}</th>
<th>{{ $t('falukant.branch.sale.sell') }}</th> <th>{{ $t('falukant.branch.sale.sell') }}</th>
<th>Bessere Preise</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -22,6 +23,16 @@
<input type="number" v-model.number="item.sellQuantity" :min="1" :max="item.totalQuantity" /> <input type="number" v-model.number="item.sellQuantity" :min="1" :max="item.totalQuantity" />
<button @click="sellItem(index)">{{ $t('falukant.branch.sale.sellButton') }}</button> <button @click="sellItem(index)">{{ $t('falukant.branch.sale.sellButton') }}</button>
</td> </td>
<td>
<div v-if="item.betterPrices && item.betterPrices.length > 0" class="price-cities">
<span v-for="city in item.betterPrices" :key="city.regionId"
:class="['city-price', getCityPriceClass(city.branchType)]"
:title="`${city.regionName}: ${formatPrice(city.price)}`">
{{ city.regionName }}
</span>
</div>
<span v-else class="no-better-prices"></span>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -178,6 +189,7 @@
runningTransports: [], runningTransports: [],
nowTs: Date.now(), nowTs: Date.now(),
_transportTimer: null, _transportTimer: null,
loadingPrices: new Set(),
}; };
}, },
async mounted() { async mounted() {
@@ -186,6 +198,7 @@
this._transportTimer = setInterval(() => { this._transportTimer = setInterval(() => {
this.nowTs = Date.now(); this.nowTs = Date.now();
}, 1000); }, 1000);
await this.loadPricesForInventory();
}, },
beforeUnmount() { beforeUnmount() {
if (this._transportTimer) { if (this._transportTimer) {
@@ -201,10 +214,45 @@
...item, ...item,
sellQuantity: item.totalQuantity, sellQuantity: item.totalQuantity,
})); }));
await this.loadPricesForInventory();
} catch (error) { } catch (error) {
console.error('Error loading inventory:', error); console.error('Error loading inventory:', error);
} }
}, },
async loadPricesForInventory() {
for (const item of this.inventory) {
const itemKey = `${item.region.id}-${item.product.id}-${item.quality}`;
if (this.loadingPrices.has(itemKey)) continue;
this.loadingPrices.add(itemKey);
try {
// Aktueller Preis basierend auf sellCost
const currentPrice = item.product.sellCost || 0;
const { data } = await apiClient.get('/api/falukant/products/prices-in-cities', {
params: {
productId: item.product.id,
currentPrice: currentPrice
}
});
this.$set(item, 'betterPrices', data || []);
} catch (error) {
console.error(`Error loading prices for item ${itemKey}:`, error);
this.$set(item, 'betterPrices', []);
} finally {
this.loadingPrices.delete(itemKey);
}
}
},
getCityPriceClass(branchType) {
if (branchType === 'store') return 'city-price-green';
if (branchType === 'production') return 'city-price-orange';
return 'city-price-red';
},
formatPrice(price) {
return new Intl.NumberFormat(navigator.language, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(price);
},
sellItem(index) { sellItem(index) {
const item = this.inventory[index]; const item = this.inventory[index];
const quantityToSell = item.sellQuantity || item.totalQuantity; const quantityToSell = item.sellQuantity || item.totalQuantity;
@@ -448,5 +496,32 @@
border-collapse: separate; border-collapse: separate;
border-spacing: 16px 0; /* horizontaler Abstand zwischen Spalten */ border-spacing: 16px 0; /* horizontaler Abstand zwischen Spalten */
} }
.price-cities {
display: flex;
flex-wrap: wrap;
gap: 0.3em;
}
.city-price {
padding: 0.2em 0.4em;
border-radius: 3px;
font-size: 0.85em;
cursor: help;
}
.city-price-green {
background-color: #90EE90;
color: #000;
}
.city-price-orange {
background-color: #FFA500;
color: #000;
}
.city-price-red {
background-color: #FF6B6B;
color: #fff;
}
.no-better-prices {
color: #999;
font-style: italic;
}
</style> </style>