feat(falukant): add scandalExtraDailyPct field and update related components
All checks were successful
Deploy to production / deploy (push) Successful in 2m59s
All checks were successful
Deploy to production / deploy (push) Successful in 2m59s
- 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.
This commit is contained in:
@@ -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;
|
||||
`);
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -41,10 +41,10 @@
|
||||
>, </span>
|
||||
<span
|
||||
:class="['city-price', getCityPriceClass(city.branchType)]"
|
||||
:title="`${city.regionName}: ${formatPrice(city.price)}`"
|
||||
:title="`${city.regionName}: ${formatCityPrice(city, product)}`"
|
||||
>
|
||||
<span class="city-name">{{ city.regionName }}</span>
|
||||
<span class="city-price-value">({{ formatPrice(city.price) }})</span>
|
||||
<span class="city-price-value">({{ formatCityPrice(city, product) }})</span>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
@@ -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)}`;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -397,6 +397,10 @@
|
||||
<dt>{{ $t('falukant.family.lovers.maintenance') }}</dt>
|
||||
<dd>{{ lover.maintenanceLevel }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ $t('falukant.family.lovers.scandalExtraDailyPct') }}</dt>
|
||||
<dd>{{ Number(lover.scandalExtraDailyPct || 0).toFixed(1) }}%</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ $t('falukant.family.lovers.monthlyCost') }}</dt>
|
||||
<dd>
|
||||
|
||||
Reference in New Issue
Block a user