From 86e14a875d22d24e81f4bff5ca59e6502f0ced4c Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Mon, 13 Apr 2026 15:31:47 +0200 Subject: [PATCH] feat(falukant): add scandalExtraDailyPct field and update related components - Introduced a new field `scandalExtraDailyPct` in the relationship state model to track additional scandal risk per day, with validation constraints. - Updated the FalukantService to include the new field in relevant calculations and data handling. - Enhanced the frontend components, including RevenueSection and FamilyView, to display the scandal risk information and updated price calculations. - Added localization entries for the new field in multiple languages to ensure clarity for users. --- ...tionship-state-scandal-extra-daily-pct.cjs | 32 +++++ .../falukant/data/relationship_state.js | 10 ++ backend/services/falukantService.js | 127 ++++++++++++++---- ...elationship_state_and_child_legitimacy.sql | 1 + .../components/falukant/RevenueSection.vue | 12 +- frontend/src/i18n/locales/ceb/falukant.json | 1 + frontend/src/i18n/locales/de/falukant.json | 1 + frontend/src/i18n/locales/en/falukant.json | 1 + frontend/src/i18n/locales/es/falukant.json | 1 + frontend/src/i18n/locales/fr/falukant.json | 1 + frontend/src/views/falukant/BranchView.vue | 11 ++ frontend/src/views/falukant/FamilyView.vue | 4 + 12 files changed, 176 insertions(+), 26 deletions(-) create mode 100644 backend/migrations/20260413152500-add-relationship-state-scandal-extra-daily-pct.cjs diff --git a/backend/migrations/20260413152500-add-relationship-state-scandal-extra-daily-pct.cjs b/backend/migrations/20260413152500-add-relationship-state-scandal-extra-daily-pct.cjs new file mode 100644 index 0000000..041dc09 --- /dev/null +++ b/backend/migrations/20260413152500-add-relationship-state-scandal-extra-daily-pct.cjs @@ -0,0 +1,32 @@ +/* eslint-disable */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.sequelize.query(` + ALTER TABLE falukant_data.relationship_state + ADD COLUMN IF NOT EXISTS scandal_extra_daily_pct double precision NOT NULL DEFAULT 0; + `); + + await queryInterface.sequelize.query(` + ALTER TABLE falukant_data.relationship_state + DROP CONSTRAINT IF EXISTS relationship_state_scandal_extra_daily_pct_chk; + `); + + await queryInterface.sequelize.query(` + ALTER TABLE falukant_data.relationship_state + ADD CONSTRAINT relationship_state_scandal_extra_daily_pct_chk + CHECK (scandal_extra_daily_pct >= 0 AND scandal_extra_daily_pct <= 100); + `); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.sequelize.query(` + ALTER TABLE falukant_data.relationship_state + DROP CONSTRAINT IF EXISTS relationship_state_scandal_extra_daily_pct_chk; + `); + + await queryInterface.sequelize.query(` + ALTER TABLE falukant_data.relationship_state + DROP COLUMN IF EXISTS scandal_extra_daily_pct; + `); + }, +}; diff --git a/backend/models/falukant/data/relationship_state.js b/backend/models/falukant/data/relationship_state.js index 8006f59..e4ee3b4 100644 --- a/backend/models/falukant/data/relationship_state.js +++ b/backend/models/falukant/data/relationship_state.js @@ -88,6 +88,16 @@ RelationshipState.init( min: 0, }, }, + scandalExtraDailyPct: { + type: DataTypes.FLOAT, + allowNull: false, + defaultValue: 0, + validate: { + min: 0, + max: 100, + }, + field: 'scandal_extra_daily_pct', + }, monthsUnderfunded: { type: DataTypes.INTEGER, allowNull: false, diff --git a/backend/services/falukantService.js b/backend/services/falukantService.js index 06a150d..e50063a 100644 --- a/backend/services/falukantService.js +++ b/backend/services/falukantService.js @@ -587,6 +587,7 @@ class FalukantService extends BaseService { maintenanceLevel: 50, statusFit: 0, monthlyBaseCost: 0, + scandalExtraDailyPct: 0, monthsUnderfunded: 0, active: true, acknowledged: false, @@ -3514,6 +3515,7 @@ class FalukantService extends BaseService { maintenanceLevel: state.maintenanceLevel, statusFit: state.statusFit, monthlyBaseCost: state.monthlyBaseCost, + scandalExtraDailyPct: state.scandalExtraDailyPct, monthsUnderfunded: state.monthsUnderfunded, active: state.active, acknowledged: state.acknowledged, @@ -3649,6 +3651,7 @@ class FalukantService extends BaseService { statusFit: state.statusFit, monthlyBaseCost: monthlyCost, monthlyCost, + scandalExtraDailyPct: state.scandalExtraDailyPct, acknowledged: state.acknowledged, active: state.active, monthsUnderfunded: state.monthsUnderfunded, @@ -6889,7 +6892,7 @@ class FalukantService extends BaseService { } const [products, knowledges, townWorths] = await Promise.all([ - ProductType.findAll({ attributes: ['id', 'sellCost'] }), + ProductType.findAll({ attributes: ['id', 'sellCost', 'category', 'productionTime'] }), Knowledge.findAll({ where: { characterId: character.id }, attributes: ['productId', 'knowledge'] @@ -6900,22 +6903,63 @@ class FalukantService extends BaseService { }) ]); - const knowledgeMap = new Map(knowledges.map(k => [k.productId, k.knowledge || 0])); - const maxWorthByProduct = new Map(); - for (const tw of townWorths) { - const w = tw.worthPercent ?? 50; - const prev = maxWorthByProduct.get(tw.productId); - maxWorthByProduct.set(tw.productId, prev == null ? w : Math.max(prev, w)); - } + const knowledgeMap = new Map(knowledges.map((k) => [k.productId, k.knowledge || 0])); + const worthByProductRegion = new Map( + townWorths.map((tw) => [`${tw.productId}-${tw.regionId}`, tw.worthPercent ?? 50]) + ); + const certificate = Number(user.certificate || 1); + const taxByRegion = new Map(); + await Promise.all( + worthRegionIds.map(async (rid) => { + taxByRegion.set(rid, await getCumulativeTaxPercentWithExemptions(user.id, rid)); + }) + ); const prices = {}; + const netMetrics = {}; for (const product of products) { - const worthPercent = maxWorthByProduct.get(product.id) ?? 50; const knowledgeFactor = knowledgeMap.get(product.id) || 0; - const price = calcRegionalSellPriceSync(product, knowledgeFactor, worthPercent); - if (price !== null) prices[product.id] = price; + const productionTime = Number(product.productionTime || 0); + const pieceCost = productionPieceCost(certificate, product.category); + let best = null; + for (const rid of worthRegionIds) { + const worthPercent = worthByProductRegion.get(`${product.id}-${rid}`) ?? 50; + const grossPrice = calcRegionalSellPriceSync(product, knowledgeFactor, worthPercent); + if (grossPrice == null) continue; + const taxPercent = Number(taxByRegion.get(rid) || 0); + const transportPerPiece = networkWorth && rid !== regionId ? (grossPrice * 0.01) : 0; + const taxableProfit = Math.max(0, grossPrice - pieceCost - transportPerPiece); + const taxPerPiece = taxableProfit * (taxPercent / 100); + const netPerPiece = grossPrice - pieceCost - taxPerPiece - transportPerPiece; + const netPerMinute = productionTime > 0 ? (netPerPiece / productionTime) : 0; + const candidate = { + regionId: rid, + grossPrice, + netPerPiece, + netPerMinute, + pieceCost, + taxPercent, + taxPerPiece, + transportPerPiece, + }; + if (!best || candidate.netPerMinute > best.netPerMinute) { + best = candidate; + } + } + if (!best) continue; + prices[product.id] = best.grossPrice; + netMetrics[product.id] = { + regionId: best.regionId, + grossPerPiece: Number(best.grossPrice.toFixed(2)), + netPerPiece: Number(best.netPerPiece.toFixed(2)), + netPerMinute: Number(best.netPerMinute.toFixed(2)), + pieceCost: Number(best.pieceCost.toFixed(2)), + taxPercent: Number(best.taxPercent.toFixed(2)), + taxPerPiece: Number(best.taxPerPiece.toFixed(2)), + transportPerPiece: Number(best.transportPerPiece.toFixed(2)), + }; } - return { prices }; + return { prices, netMetrics }; } catch (error) { console.error(`[getAllProductPricesInRegion] Error for regionId=${regionId}:`, error); throw error; @@ -7061,7 +7105,10 @@ ORDER BY r.id`, const { cities, branchTypeByCityId } = citiesWithBranchType; const [products, knowledges, townWorths] = await Promise.all([ - ProductType.findAll({ where: { id: { [Op.in]: productIds } }, attributes: ['id', 'sellCost'] }), + ProductType.findAll({ + where: { id: { [Op.in]: productIds } }, + attributes: ['id', 'sellCost', 'category', 'productionTime'] + }), Knowledge.findAll({ where: { characterId: characterId, productId: { [Op.in]: productIds } }, attributes: ['productId', 'knowledge'] @@ -7075,44 +7122,76 @@ ORDER BY r.id`, const knowledgeByProduct = new Map(knowledges.map(k => [k.productId, k.knowledge || 0])); const worthByProductRegion = new Map(townWorths.map(tw => [`${tw.productId}-${tw.regionId}`, tw.worthPercent])); - const PRICE_TOLERANCE = 0.01; + const certificate = Number(user.certificate || 1); + const taxByCity = new Map(); + await Promise.all( + cities.map(async (city) => { + taxByCity.set(city.id, await getCumulativeTaxPercentWithExemptions(user.id, city.id)); + }) + ); + + const PRICE_TOLERANCE = 0.0001; const out = {}; for (const product of products) { const knowledgeFactor = knowledgeByProduct.get(product.id) || 0; + const pieceCost = productionPieceCost(certificate, product.category); + const productionTime = Number(product.productionTime || 0); const clientPriceRaw = priceByProduct.get(product.id); const clientPriceNum = clientPriceRaw != null && clientPriceRaw !== '' ? Number(clientPriceRaw) : NaN; - let currentRegionalPrice; + let currentRegionalNetPerMinute; // Wie getProductPricesInCities: bei bekannter Standort-Region immer // serverseitigen Verkaufspreis dieser Region als Referenz — nicht den // Client-Wert (Ertrags-Tabelle kann MAX-Worth über Filialen nutzen). if (currentRegionId) { const wp = worthByProductRegion.get(`${product.id}-${currentRegionId}`) ?? 50; - currentRegionalPrice = calcRegionalSellPriceSync(product, knowledgeFactor, wp) + const grossCurrent = calcRegionalSellPriceSync(product, knowledgeFactor, wp) ?? (!Number.isNaN(clientPriceNum) ? clientPriceNum : Number(product.sellCost) ?? 0); + const taxPercentCurrent = Number(taxByCity.get(currentRegionId) || 0); + const taxableProfitCurrent = Math.max(0, grossCurrent - pieceCost); + const taxCurrent = taxableProfitCurrent * (taxPercentCurrent / 100); + const netPieceCurrent = grossCurrent - pieceCost - taxCurrent; + currentRegionalNetPerMinute = productionTime > 0 ? (netPieceCurrent / productionTime) : 0; } else if (!Number.isNaN(clientPriceNum)) { - currentRegionalPrice = clientPriceNum; + const taxPercentCurrent = Number(taxByCity.get(currentRegionId) || 0); + const taxableProfitCurrent = Math.max(0, clientPriceNum - pieceCost); + const taxCurrent = taxableProfitCurrent * (taxPercentCurrent / 100); + const netPieceCurrent = clientPriceNum - pieceCost - taxCurrent; + currentRegionalNetPerMinute = productionTime > 0 ? (netPieceCurrent / productionTime) : 0; } else { - currentRegionalPrice = Number(product.sellCost) || 0; + const fallbackGross = Number(product.sellCost) || 0; + const netPieceCurrent = fallbackGross - pieceCost; + currentRegionalNetPerMinute = productionTime > 0 ? (netPieceCurrent / productionTime) : 0; } const results = []; for (const city of cities) { if (currentRegionId && city.id === currentRegionId) continue; const worthPercent = worthByProductRegion.get(`${product.id}-${city.id}`) ?? 50; - const priceInCity = calcRegionalSellPriceSync(product, knowledgeFactor, worthPercent); - if (priceInCity == null) continue; - if (priceInCity <= currentRegionalPrice - PRICE_TOLERANCE) continue; + const grossPrice = calcRegionalSellPriceSync(product, knowledgeFactor, worthPercent); + if (grossPrice == null) continue; + const taxPercent = Number(taxByCity.get(city.id) || 0); + const transportPerPiece = currentRegionId && city.id !== currentRegionId ? (grossPrice * 0.01) : 0; + const taxableProfit = Math.max(0, grossPrice - pieceCost - transportPerPiece); + const taxPerPiece = taxableProfit * (taxPercent / 100); + const netPerPiece = grossPrice - pieceCost - taxPerPiece - transportPerPiece; + const netPerMinute = productionTime > 0 ? (netPerPiece / productionTime) : 0; + if (netPerMinute <= currentRegionalNetPerMinute + PRICE_TOLERANCE) continue; results.push({ regionId: city.id, regionName: city.name, - price: priceInCity, - branchType: branchTypeByCityId.get(city.id) ?? null + price: Number(grossPrice.toFixed(2)), + grossPerMinute: Number((productionTime > 0 ? grossPrice / productionTime : 0).toFixed(2)), + netPerPiece: Number(netPerPiece.toFixed(2)), + netPerMinute: Number(netPerMinute.toFixed(2)), + taxPercent: Number(taxPercent.toFixed(2)), + transportPerPiece: Number(transportPerPiece.toFixed(2)), + branchType: branchTypeByCityId.get(city.id) ?? null, }); } - results.sort((a, b) => b.price - a.price); + results.sort((a, b) => b.netPerMinute - a.netPerMinute); out[product.id] = results; } diff --git a/backend/sql/add_relationship_state_and_child_legitimacy.sql b/backend/sql/add_relationship_state_and_child_legitimacy.sql index 189fbcc..8a8c28e 100644 --- a/backend/sql/add_relationship_state_and_child_legitimacy.sql +++ b/backend/sql/add_relationship_state_and_child_legitimacy.sql @@ -16,6 +16,7 @@ CREATE TABLE IF NOT EXISTS falukant_data.relationship_state ( maintenance_level integer NOT NULL DEFAULT 50 CHECK (maintenance_level >= 0 AND maintenance_level <= 100), status_fit integer NOT NULL DEFAULT 0 CHECK (status_fit >= -2 AND status_fit <= 2), monthly_base_cost integer NOT NULL DEFAULT 0 CHECK (monthly_base_cost >= 0), + scandal_extra_daily_pct double precision NOT NULL DEFAULT 0 CHECK (scandal_extra_daily_pct >= 0 AND scandal_extra_daily_pct <= 100), months_underfunded integer NOT NULL DEFAULT 0 CHECK (months_underfunded >= 0), active boolean NOT NULL DEFAULT true, acknowledged boolean NOT NULL DEFAULT false, diff --git a/frontend/src/components/falukant/RevenueSection.vue b/frontend/src/components/falukant/RevenueSection.vue index 8fc4521..c0dcb0d 100644 --- a/frontend/src/components/falukant/RevenueSection.vue +++ b/frontend/src/components/falukant/RevenueSection.vue @@ -41,10 +41,10 @@ >, {{ city.regionName }} - ({{ formatPrice(city.price) }}) + ({{ formatCityPrice(city, product) }}) @@ -169,6 +169,14 @@ maximumFractionDigits: 2, }).format(price); }, + formatCityPrice(city, product) { + const absolute = Number(city?.price || 0); + const productionTime = Number(product?.productionTime || 0); + const perMinute = Number.isFinite(Number(city?.grossPerMinute)) + ? Number(city.grossPerMinute) + : (productionTime > 0 ? (absolute / productionTime) : 0); + return `${this.formatPrice(absolute)} / ${this.formatPrice(perMinute)}`; + }, }, }; diff --git a/frontend/src/i18n/locales/ceb/falukant.json b/frontend/src/i18n/locales/ceb/falukant.json index e037678..45d50b3 100644 --- a/frontend/src/i18n/locales/ceb/falukant.json +++ b/frontend/src/i18n/locales/ceb/falukant.json @@ -796,6 +796,7 @@ "visibility": "Klaro ba", "discretion": "Diskreto", "maintenance": "Suporta bulanan", + "scandalExtraDailyPct": "Dugang risgo sa iskandalo/adlaw", "monthlyCost": "Gasto kada bulan", "politicalFreeSlotsHint": "Ang mga politikal nga opisina naghatag og {count} ka affair slot nga walay bulan nga suporta (ang barato nga relasyon una).", "politicalFreeMaintenance": "Opisina (libre)", diff --git a/frontend/src/i18n/locales/de/falukant.json b/frontend/src/i18n/locales/de/falukant.json index ec6344a..7ea1e76 100644 --- a/frontend/src/i18n/locales/de/falukant.json +++ b/frontend/src/i18n/locales/de/falukant.json @@ -761,6 +761,7 @@ "visibility": "Sichtbarkeit", "discretion": "Diskretion", "maintenance": "Unterhalt", + "scandalExtraDailyPct": "Zusatz-Skandalrisiko/Tag", "monthlyCost": "Monatskosten", "politicalFreeSlotsHint": "Politische Ämter gewähren dir {count} Liebschaftsplatz/-plätze ohne monatlichen Unterhalt (die günstigsten Beziehungen zählen zuerst).", "politicalFreeMaintenance": "Amt (frei)", diff --git a/frontend/src/i18n/locales/en/falukant.json b/frontend/src/i18n/locales/en/falukant.json index 10a9101..8f95a51 100644 --- a/frontend/src/i18n/locales/en/falukant.json +++ b/frontend/src/i18n/locales/en/falukant.json @@ -907,6 +907,7 @@ "visibility": "Visibility", "discretion": "Discretion", "maintenance": "Maintenance", + "scandalExtraDailyPct": "Extra scandal risk/day", "monthlyCost": "Monthly Cost", "politicalFreeSlotsHint": "Political offices grant you {count} affair slot(s) with no monthly upkeep (cheapest relationships count first).", "politicalFreeMaintenance": "Office (free)", diff --git a/frontend/src/i18n/locales/es/falukant.json b/frontend/src/i18n/locales/es/falukant.json index dbcb1b8..6c981ab 100644 --- a/frontend/src/i18n/locales/es/falukant.json +++ b/frontend/src/i18n/locales/es/falukant.json @@ -761,6 +761,7 @@ "visibility": "Visibilidad", "discretion": "Discreción", "maintenance": "Mantenimiento", + "scandalExtraDailyPct": "Riesgo extra de escándalo/día", "monthlyCost": "Coste mensual", "politicalFreeSlotsHint": "Los cargos políticos te conceden {count} plaza(s) de relación sin mantenimiento mensual (primero cuentan las relaciones más baratas).", "politicalFreeMaintenance": "Cargo (gratis)", diff --git a/frontend/src/i18n/locales/fr/falukant.json b/frontend/src/i18n/locales/fr/falukant.json index 34e97a4..16b0cc2 100644 --- a/frontend/src/i18n/locales/fr/falukant.json +++ b/frontend/src/i18n/locales/fr/falukant.json @@ -759,6 +759,7 @@ "visibility": "visibilité", "discretion": "discrétion", "maintenance": "Entretien", + "scandalExtraDailyPct": "Risque de scandale supplémentaire/jour", "monthlyCost": "Coûts mensuels", "politicalFreeSlotsHint": "Les bureaux politiques vous accordent {count} intérêts amoureux sans entretien mensuel (les relations les moins chères comptent en premier).", "politicalFreeMaintenance": "Bureau (vacant)", diff --git a/frontend/src/views/falukant/BranchView.vue b/frontend/src/views/falukant/BranchView.vue index 27740b5..b02b021 100644 --- a/frontend/src/views/falukant/BranchView.vue +++ b/frontend/src/views/falukant/BranchView.vue @@ -436,6 +436,7 @@ export default { vehicles: [], activeTab: 'production', productPricesCache: {}, // Cache für regionale Preise: { productId: price } + productNetMetricsCache: {}, // Netto-Metriken vom Backend: { productId: { netPerPiece, netPerMinute, ... } } /** Cache-Schlüssel: Region + ob MAX(worth) über alle Filialregionen (bei Fahrzeug) */ productPricesCacheKey: null, tabs: [ @@ -739,6 +740,7 @@ export default { async loadProductPricesForCurrentBranch() { if (!this.selectedBranch || !this.selectedBranch.regionId) { this.productPricesCache = {}; + this.productNetMetricsCache = {}; this.productPricesCacheKey = null; return; } @@ -755,6 +757,7 @@ export default { } const { data } = await apiClient.get('/api/falukant/products/prices-in-region', { params }); this.productPricesCache = data.prices || {}; + this.productNetMetricsCache = data.netMetrics || {}; this.productPricesCacheKey = cacheKey; } catch (error) { console.error(`Error loading product prices for region ${this.selectedBranch.regionId}:`, error); @@ -780,6 +783,7 @@ export default { } } this.productPricesCache = prices; + this.productNetMetricsCache = {}; this.productPricesCacheKey = cacheKey; } }, @@ -873,6 +877,13 @@ export default { }, calculateProductProfit(product) { + const netMetrics = this.productNetMetricsCache?.[product.id]; + if (netMetrics && Number.isFinite(Number(netMetrics.netPerPiece)) && Number.isFinite(Number(netMetrics.netPerMinute))) { + return { + absolute: Number(netMetrics.netPerPiece).toFixed(2), + perMinute: Number(netMetrics.netPerMinute).toFixed(2), + }; + } const { absolute: revenueAbsoluteStr, perMinute: revenuePerMinuteStr } = this.calculateProductRevenue(product); const revenueAbsolute = parseFloat(revenueAbsoluteStr); diff --git a/frontend/src/views/falukant/FamilyView.vue b/frontend/src/views/falukant/FamilyView.vue index c77de96..2c68a98 100644 --- a/frontend/src/views/falukant/FamilyView.vue +++ b/frontend/src/views/falukant/FamilyView.vue @@ -397,6 +397,10 @@
{{ $t('falukant.family.lovers.maintenance') }}
{{ lover.maintenanceLevel }}
+
+
{{ $t('falukant.family.lovers.scandalExtraDailyPct') }}
+
{{ Number(lover.scandalExtraDailyPct || 0).toFixed(1) }}%
+
{{ $t('falukant.family.lovers.monthlyCost') }}