Füge Spalte product_quality zur Tabelle stock hinzu und erstelle Migration für weather_type_id in production

This commit is contained in:
Torsten Schulz (local)
2025-12-16 13:00:29 +01:00
parent 43d86cce18
commit ee4b0ee7c2
11 changed files with 325 additions and 16 deletions

View File

@@ -8,29 +8,87 @@ module.exports = {
ADD COLUMN IF NOT EXISTS character_name text; ADD COLUMN IF NOT EXISTS character_name text;
`); `);
// 2) Create helper function to populate character_name from character_id // 1b) Add character_id column so triggers and application can set a reference
await queryInterface.sequelize.query(`
ALTER TABLE IF EXISTS falukant_log.notification
ADD COLUMN IF NOT EXISTS character_id integer;
`);
// Create an index on character_id to speed lookups (if not exists)
await queryInterface.sequelize.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'i' AND c.relname = 'idx_notification_character_id' AND n.nspname = 'falukant_log'
) THEN
CREATE INDEX idx_notification_character_id ON falukant_log.notification (character_id);
END IF;
END$$;
`);
// 2) Create helper function to populate character_name from character_id or user_id
// - Resolve name via character_id if present
// - Fallback to a character for the same user_id when character_id is NULL
// - Only set NEW.character_name when the column exists and is NULL
await queryInterface.sequelize.query(` await queryInterface.sequelize.query(`
CREATE OR REPLACE FUNCTION falukant_log.populate_notification_character_name() CREATE OR REPLACE FUNCTION falukant_log.populate_notification_character_name()
RETURNS TRIGGER AS $function$ RETURNS TRIGGER AS $function$
DECLARE DECLARE
v_first_name TEXT; v_first_name TEXT;
v_last_name TEXT; v_last_name TEXT;
v_char_id INTEGER;
v_column_exists BOOLEAN;
BEGIN BEGIN
IF NEW.character_name IS NULL AND NEW.character_id IS NOT NULL THEN -- check if target column exists in the notification table
SELECT EXISTS(
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'falukant_log' AND table_name = 'notification' AND column_name = 'character_name'
) INTO v_column_exists;
IF NOT v_column_exists THEN
-- Nothing to do when target column absent
RETURN NEW;
END IF;
-- only populate when column is NULL
IF NEW.character_name IS NOT NULL THEN
RETURN NEW;
END IF;
-- prefer explicit character_id
v_char_id := NEW.character_id;
-- when character_id is null, try to find a character for the user_id
IF v_char_id IS NULL AND NEW.user_id IS NOT NULL THEN
-- choose a representative character: the one with highest id for this user (change if different policy required)
SELECT id INTO v_char_id
FROM falukant_data.character
WHERE user_id = NEW.user_id
ORDER BY id DESC
LIMIT 1;
END IF;
IF v_char_id IS NOT NULL THEN
SELECT pf.name, pl.name SELECT pf.name, pl.name
INTO v_first_name, v_last_name INTO v_first_name, v_last_name
FROM falukant_data.character c FROM falukant_data.character c
LEFT JOIN falukant_predefine.firstname pf ON pf.id = c.first_name LEFT JOIN falukant_predefine.firstname pf ON pf.id = c.first_name
LEFT JOIN falukant_predefine.lastname pl ON pl.id = c.last_name LEFT JOIN falukant_predefine.lastname pl ON pl.id = c.last_name
WHERE c.id = NEW.character_id; WHERE c.id = v_char_id;
IF v_first_name IS NOT NULL OR v_last_name IS NOT NULL THEN IF v_first_name IS NOT NULL OR v_last_name IS NOT NULL THEN
NEW.character_name := COALESCE(v_first_name, '') || CASE WHEN v_first_name IS NOT NULL AND v_last_name IS NOT NULL THEN ' ' ELSE '' END || COALESCE(v_last_name, ''); NEW.character_name := COALESCE(v_first_name, '') || CASE WHEN v_first_name IS NOT NULL AND v_last_name IS NOT NULL THEN ' ' ELSE '' END || COALESCE(v_last_name, '');
ELSE ELSE
-- Fallback to placeholder with id NEW.character_name := ('#' || v_char_id::text);
NEW.character_name := ('#' || NEW.character_id::text); END IF;
ELSE
-- last resort fallback: use user_id as identifier if present
IF NEW.user_id IS NOT NULL THEN
NEW.character_name := ('#u' || NEW.user_id::text);
END IF; END IF;
END IF; END IF;
RETURN NEW; RETURN NEW;
END; END;
$function$ LANGUAGE plpgsql; $function$ LANGUAGE plpgsql;
@@ -53,9 +111,25 @@ module.exports = {
await queryInterface.sequelize.query(` await queryInterface.sequelize.query(`
DROP FUNCTION IF EXISTS falukant_log.populate_notification_character_name(); DROP FUNCTION IF EXISTS falukant_log.populate_notification_character_name();
`); `);
await queryInterface.sequelize.query(`
-- drop index if exists
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'i' AND c.relname = 'idx_notification_character_id' AND n.nspname = 'falukant_log'
) THEN
EXECUTE 'DROP INDEX falukant_log.idx_notification_character_id';
END IF;
END$$;
`);
await queryInterface.sequelize.query(` await queryInterface.sequelize.query(`
ALTER TABLE IF EXISTS falukant_log.notification ALTER TABLE IF EXISTS falukant_log.notification
DROP COLUMN IF EXISTS character_name; DROP COLUMN IF EXISTS character_name;
`); `);
await queryInterface.sequelize.query(`
ALTER TABLE IF EXISTS falukant_log.notification
DROP COLUMN IF EXISTS character_id;
`);
} }
}; };

View File

@@ -0,0 +1,68 @@
"use strict";
module.exports = {
async up(queryInterface, Sequelize) {
// Add nullable weather_type_id column
await queryInterface.sequelize.query(`
ALTER TABLE IF EXISTS falukant_data.production
ADD COLUMN IF NOT EXISTS weather_type_id integer;
`);
// Add foreign key constraint if not exists
await queryInterface.sequelize.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu ON kcu.constraint_name = tc.constraint_name AND kcu.constraint_schema = tc.constraint_schema
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.constraint_schema = 'falukant_data'
AND tc.table_name = 'production'
AND kcu.column_name = 'weather_type_id'
) THEN
ALTER TABLE falukant_data.production
ADD CONSTRAINT fk_production_weather_type
FOREIGN KEY (weather_type_id) REFERENCES falukant_type.weather(id);
END IF;
END$$;
`);
// create index to speed lookups
await queryInterface.sequelize.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'i' AND c.relname = 'idx_production_weather_type_id' AND n.nspname = 'falukant_data'
) THEN
CREATE INDEX idx_production_weather_type_id ON falukant_data.production (weather_type_id);
END IF;
END$$;
`);
},
async down(queryInterface, Sequelize) {
await queryInterface.sequelize.query(`
ALTER TABLE IF EXISTS falukant_data.production
DROP CONSTRAINT IF EXISTS fk_production_weather_type;
`);
await queryInterface.sequelize.query(`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'i' AND c.relname = 'idx_production_weather_type_id' AND n.nspname = 'falukant_data'
) THEN
EXECUTE 'DROP INDEX falukant_data.idx_production_weather_type_id';
END IF;
END$$;
`);
await queryInterface.sequelize.query(`
ALTER TABLE IF EXISTS falukant_data.production
DROP COLUMN IF EXISTS weather_type_id;
`);
}
};

View File

@@ -0,0 +1,17 @@
"use strict";
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.sequelize.query(`
ALTER TABLE IF EXISTS falukant_data.stock
ADD COLUMN IF NOT EXISTS product_quality integer;
`);
},
async down(queryInterface, Sequelize) {
await queryInterface.sequelize.query(`
ALTER TABLE IF EXISTS falukant_data.stock
DROP COLUMN IF EXISTS product_quality;
`);
}
};

View File

@@ -14,7 +14,13 @@ FalukantStock.init({
allowNull: false}, allowNull: false},
quantity: { quantity: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false}}, { allowNull: false},
productQuality: {
type: DataTypes.INTEGER,
allowNull: true,
comment: 'Quality of the stored product (0-100)'
}
}, {
sequelize, sequelize,
modelName: 'StockData', modelName: 'StockData',
tableName: 'stock', tableName: 'stock',

View File

@@ -133,6 +133,64 @@ async function calcRegionalSellPrice(product, knowledgeFactor, regionId, worthPe
return parseFloat(val) || 0; return parseFloat(val) || 0;
} }
// Returns cumulative tax percent for a region, but excludes regions where the user holds
// a political office that grants tax exemption according to the rules.
// exemptionsMap maps political office.name -> array of regionType labelTr that are exempted
const POLITICAL_TAX_EXEMPTIONS = {
'council': ['city'],
'taxman': ['city', 'county'],
'treasurerer': ['city', 'county', 'shire'],
'super-state-administrator': ['city', 'county', 'shire', 'markgrave', 'duchy'],
'chancellor': ['city','county','shire','markgrave','duchy','duchy'] // chancellor = all types; we'll handle as wildcard
};
async function getCumulativeTaxPercentWithExemptions(userId, regionId) {
if (!regionId) return 0;
// fetch user's political offices (active) and their region types
const offices = await PoliticalOffice.findAll({
where: { userId },
include: [{ model: PoliticalOfficeType, as: 'type', attributes: ['name'] }, { model: RegionData, as: 'region', include: [{ model: RegionType, as: 'regionType', attributes: ['labelTr'] }] }]
});
// build set of exempt region type labels from user's offices
const exemptTypes = new Set();
let hasChancellor = false;
for (const o of offices) {
const name = o.type?.name;
if (!name) continue;
if (name === 'chancellor') { hasChancellor = true; break; }
const allowed = POLITICAL_TAX_EXEMPTIONS[name];
if (allowed && Array.isArray(allowed)) {
for (const t of allowed) exemptTypes.add(t);
}
}
// If chancellor, exempt all region types -> tax = 0
if (hasChancellor) return 0;
// Now compute cumulative tax but exclude regions whose regionType.labelTr is in exemptTypes
const rows = await sequelize.query(
`WITH RECURSIVE ancestors AS (
SELECT r.id, r.parent_id, r.tax_percent, rt.label_tr as region_type
FROM falukant_data.region r
JOIN falukant_type.region_type rt ON rt.id = r.region_type_id
WHERE r.id = :id
UNION ALL
SELECT reg.id, reg.parent_id, reg.tax_percent, rt2.label_tr
FROM falukant_data.region reg
JOIN falukant_type.region_type rt2 ON rt2.id = reg.region_type_id
JOIN ancestors a ON reg.id = a.parent_id
)
SELECT COALESCE(SUM(CASE WHEN :exempt_types::text[] && ARRAY[region_type] THEN 0 ELSE tax_percent END),0) AS total FROM ancestors;`,
{
replacements: { id: regionId, exempt_types: Array.from(exemptTypes) },
type: sequelize.QueryTypes.SELECT
}
);
const val = rows?.[0]?.total ?? 0;
return parseFloat(val) || 0;
}
function calculateMarriageCost(titleOfNobility, age) { function calculateMarriageCost(titleOfNobility, age) {
const minTitle = 1; const minTitle = 1;
const adjustedTitle = titleOfNobility - minTitle + 1; const adjustedTitle = titleOfNobility - minTitle + 1;
@@ -1527,8 +1585,8 @@ class FalukantService extends BaseService {
const knowledgeVal = item.knowledges?.[0]?.knowledge || 0; const knowledgeVal = item.knowledges?.[0]?.knowledge || 0;
const pricePerUnit = await calcRegionalSellPrice(item, knowledgeVal, branch.regionId); const pricePerUnit = await calcRegionalSellPrice(item, knowledgeVal, branch.regionId);
// compute cumulative tax (region + ancestors) and inflate price so seller net is unchanged // compute cumulative tax (region + ancestors) with political exemptions and inflate price so seller net is unchanged
const cumulativeTax = await getCumulativeTaxPercent(branch.regionId); const cumulativeTax = await getCumulativeTaxPercentWithExemptions(user.id, branch.regionId);
const inflationFactor = cumulativeTax >= 100 ? 1 : (1 / (1 - cumulativeTax / 100)); const inflationFactor = cumulativeTax >= 100 ? 1 : (1 / (1 - cumulativeTax / 100));
const adjustedPricePerUnit = Math.round(pricePerUnit * inflationFactor * 100) / 100; const adjustedPricePerUnit = Math.round(pricePerUnit * inflationFactor * 100) / 100;
const revenue = quantity * adjustedPricePerUnit; const revenue = quantity * adjustedPricePerUnit;
@@ -1614,7 +1672,7 @@ class FalukantService extends BaseService {
const knowledgeVal = item.productType.knowledges[0]?.knowledge || 0; const knowledgeVal = item.productType.knowledges[0]?.knowledge || 0;
const regionId = item.stock.branch.regionId; const regionId = item.stock.branch.regionId;
const pricePerUnit = await calcRegionalSellPrice(item.productType, knowledgeVal, regionId); const pricePerUnit = await calcRegionalSellPrice(item.productType, knowledgeVal, regionId);
const cumulativeTax = await getCumulativeTaxPercent(regionId); const cumulativeTax = await getCumulativeTaxPercentWithExemptions(user.id, regionId);
const inflationFactor = cumulativeTax >= 100 ? 1 : (1 / (1 - cumulativeTax / 100)); const inflationFactor = cumulativeTax >= 100 ? 1 : (1 / (1 - cumulativeTax / 100));
const adjustedPricePerUnit = Math.round(pricePerUnit * inflationFactor * 100) / 100; const adjustedPricePerUnit = Math.round(pricePerUnit * inflationFactor * 100) / 100;
total += item.quantity * adjustedPricePerUnit; total += item.quantity * adjustedPricePerUnit;
@@ -4363,15 +4421,26 @@ class FalukantService extends BaseService {
// Unikate nach character.id // Unikate nach character.id
const map = new Map(); const map = new Map();
const POLITICAL_TAX_EXEMPTIONS = {
'council': ['city'],
'taxman': ['city','county'],
'treasurerer': ['city','county','shire'],
'super-state-administrator': ['city','county','shire','markgrave','duchy'],
'chancellor': ['*']
};
histories.forEach(h => { histories.forEach(h => {
const c = h.holder; const c = h.holder;
if (c && c.id && !map.has(c.id)) { if (c && c.id && !map.has(c.id)) {
const officeName = h.type?.name;
const benefit = POLITICAL_TAX_EXEMPTIONS[officeName] || [];
map.set(c.id, { map.set(c.id, {
id: c.id, id: c.id,
name: `${c.definedFirstName.name} ${c.definedLastName.name}`, name: `${c.definedFirstName.name} ${c.definedLastName.name}`,
title: c.nobleTitle.labelTr, title: c.nobleTitle.labelTr,
officeType: h.type.name, officeType: officeName,
gender: c.gender gender: c.gender,
benefit
}); });
} }
}); });

View File

@@ -0,0 +1,11 @@
-- Migration script: add_product_quality_to_stock.sql
-- Fügt die Spalte product_quality zur Tabelle falukant_data.stock hinzu (nullable, idempotent)
BEGIN;
ALTER TABLE IF EXISTS falukant_data.stock
ADD COLUMN IF NOT EXISTS product_quality integer;
COMMIT;
-- Ende

View File

@@ -0,0 +1,38 @@
-- Migration script: add_weather_type_to_production.sql
-- Legt die Spalte weather_type_id in falukant_data.production an,
-- fügt optional einen Foreign Key zu falukant_type.weather(id) hinzu
-- und erstellt einen Index. Idempotent (mehrfaches Ausführen ist unproblematisch).
BEGIN;
-- 1) Spalte anlegen (nullable, idempotent)
ALTER TABLE IF EXISTS falukant_data.production
ADD COLUMN IF NOT EXISTS weather_type_id integer;
-- 2) Fremdschlüssel nur hinzufügen, falls noch kein FK für diese Spalte existiert
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON kcu.constraint_name = tc.constraint_name
AND kcu.constraint_schema = tc.constraint_schema
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.constraint_schema = 'falukant_data'
AND tc.table_name = 'production'
AND kcu.column_name = 'weather_type_id'
) THEN
ALTER TABLE falukant_data.production
ADD CONSTRAINT fk_production_weather_type
FOREIGN KEY (weather_type_id) REFERENCES falukant_type.weather(id);
END IF;
END$$;
-- 3) Index (Postgres: CREATE INDEX IF NOT EXISTS)
CREATE INDEX IF NOT EXISTS idx_production_weather_type_id
ON falukant_data.production (weather_type_id);
COMMIT;
-- Ende

View File

@@ -5,7 +5,10 @@
:title="'falukant.messages.title'" :title="'falukant.messages.title'"
:isTitleTranslated="true" :isTitleTranslated="true"
icon="falukant/messages24.png" icon="falukant/messages24.png"
:buttons="[{ text: 'message.close', action: 'close' }]" :buttons="[
{ text: 'falukant.messages.markAllRead', action: 'markAll' },
{ text: 'message.close', action: 'close' }
]"
width="520px" width="520px"
height="420px" height="420px"
> >
@@ -59,6 +62,15 @@ export default {
// mark unread as shown // mark unread as shown
try { await apiClient.post('/api/falukant/notifications/mark-shown'); } catch {} try { await apiClient.post('/api/falukant/notifications/mark-shown'); } catch {}
}, },
async markAll() {
try {
await apiClient.post('/api/falukant/notifications/mark-shown');
// reload to update shown flags and unread count
await this.load();
} catch (e) {
// ignore errors silently
}
},
async load() { async load() {
try { try {
const { data } = await apiClient.get('/api/falukant/notifications/all', { params: { page: this.page, size: this.size } }); const { data } = await apiClient.get('/api/falukant/notifications/all', { params: { page: this.page, size: this.size } });

View File

@@ -27,7 +27,8 @@
"messages": { "messages": {
"title": "Nachrichten", "title": "Nachrichten",
"tooltip": "Nachrichten", "tooltip": "Nachrichten",
"empty": "Keine Nachrichten vorhanden." "empty": "Keine Nachrichten vorhanden.",
"markAllRead": "Alle als gelesen markieren"
}, },
"notifications": { "notifications": {
"notify_election_created": "Es wurde eine neue Wahl ausgeschrieben.", "notify_election_created": "Es wurde eine neue Wahl ausgeschrieben.",
@@ -902,6 +903,8 @@
"office": "Amt", "office": "Amt",
"region": "Region", "region": "Region",
"termEnds": "Läuft ab am", "termEnds": "Läuft ab am",
"benefit": "Vorteil",
"benefit_all": "Alle Regionen",
"income": "Einkommen", "income": "Einkommen",
"none": "Keine aktuelle Position vorhanden.", "none": "Keine aktuelle Position vorhanden.",
"holder": "Inhaber" "holder": "Inhaber"

View File

@@ -13,7 +13,8 @@
"messages": { "messages": {
"title": "Messages", "title": "Messages",
"tooltip": "Messages", "tooltip": "Messages",
"empty": "No messages." "empty": "No messages.",
"markAllRead": "Mark all as read"
}, },
"notifications": { "notifications": {
"notify_election_created": "A new election has been scheduled.", "notify_election_created": "A new election has been scheduled.",
@@ -211,6 +212,8 @@
"office": "Office", "office": "Office",
"region": "Region", "region": "Region",
"termEnds": "Term Ends", "termEnds": "Term Ends",
"benefit": "Benefit",
"benefit_all": "All regions",
"income": "Income", "income": "Income",
"none": "No current position available.", "none": "No current position available.",
"holder": "Holder" "holder": "Holder"

View File

@@ -17,7 +17,8 @@
<tr> <tr>
<th>{{ $t('falukant.politics.current.office') }}</th> <th>{{ $t('falukant.politics.current.office') }}</th>
<th>{{ $t('falukant.politics.current.region') }}</th> <th>{{ $t('falukant.politics.current.region') }}</th>
<th>{{ $t('falukant.politics.current.holder') }}</th> <th>{{ $t('falukant.politics.current.holder') }}</th>
<th>{{ $t('falukant.politics.current.benefit') }}</th>
<th>{{ $t('falukant.politics.current.termEnds') }}</th> <th>{{ $t('falukant.politics.current.termEnds') }}</th>
</tr> </tr>
</thead> </thead>
@@ -32,6 +33,13 @@
</span> </span>
<span v-else></span> <span v-else></span>
</td> </td>
<td>
<span v-if="pos.benefit && pos.benefit.length">
<span v-if="pos.benefit.includes('*')">{{ $t('falukant.politics.current.benefit_all') }}</span>
<span v-else>{{ pos.benefit.join(', ') }}</span>
</span>
<span v-else></span>
</td>
<td> <td>
<span v-if="pos.termEnds"> <span v-if="pos.termEnds">
{{ formatDate(pos.termEnds) }} {{ formatDate(pos.termEnds) }}