From ee4b0ee7c27957d8e38a5b97d51e1be12ac4cbe5 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Tue, 16 Dec 2025 13:00:29 +0100 Subject: [PATCH] =?UTF-8?q?F=C3=BCge=20Spalte=20product=5Fquality=20zur=20?= =?UTF-8?q?Tabelle=20stock=20hinzu=20und=20erstelle=20Migration=20f=C3=BCr?= =?UTF-8?q?=20weather=5Ftype=5Fid=20in=20production?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...000-add-character-name-to-notification.cjs | 86 +++++++++++++++++-- ...6000000-add-weather-type-to-production.cjs | 68 +++++++++++++++ ...216000100-add-product-quality-to-stock.cjs | 17 ++++ backend/models/falukant/data/stock.js | 8 +- backend/services/falukantService.js | 79 +++++++++++++++-- backend/sql/add_product_quality_to_stock.sql | 11 +++ .../sql/add_weather_type_to_production.sql | 38 ++++++++ .../components/falukant/MessagesDialog.vue | 14 ++- frontend/src/i18n/locales/de/falukant.json | 5 +- frontend/src/i18n/locales/en/falukant.json | 5 +- frontend/src/views/falukant/PoliticsView.vue | 10 ++- 11 files changed, 325 insertions(+), 16 deletions(-) create mode 100644 backend/migrations/20251216000000-add-weather-type-to-production.cjs create mode 100644 backend/migrations/20251216000100-add-product-quality-to-stock.cjs create mode 100644 backend/sql/add_product_quality_to_stock.sql create mode 100644 backend/sql/add_weather_type_to_production.sql diff --git a/backend/migrations/20251208000000-add-character-name-to-notification.cjs b/backend/migrations/20251208000000-add-character-name-to-notification.cjs index fa684e8..d3b923c 100644 --- a/backend/migrations/20251208000000-add-character-name-to-notification.cjs +++ b/backend/migrations/20251208000000-add-character-name-to-notification.cjs @@ -8,29 +8,87 @@ module.exports = { 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(` CREATE OR REPLACE FUNCTION falukant_log.populate_notification_character_name() RETURNS TRIGGER AS $function$ DECLARE v_first_name TEXT; v_last_name TEXT; + v_char_id INTEGER; + v_column_exists BOOLEAN; 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 INTO v_first_name, v_last_name FROM falukant_data.character c LEFT JOIN falukant_predefine.firstname pf ON pf.id = c.first_name - LEFT JOIN falukant_predefine.lastname pl ON pl.id = c.last_name - WHERE c.id = NEW.character_id; + LEFT JOIN falukant_predefine.lastname pl ON pl.id = c.last_name + WHERE c.id = v_char_id; 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, ''); ELSE - -- Fallback to placeholder with id - NEW.character_name := ('#' || NEW.character_id::text); + NEW.character_name := ('#' || v_char_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; + RETURN NEW; END; $function$ LANGUAGE plpgsql; @@ -53,9 +111,25 @@ module.exports = { await queryInterface.sequelize.query(` 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(` ALTER TABLE IF EXISTS falukant_log.notification DROP COLUMN IF EXISTS character_name; `); + await queryInterface.sequelize.query(` + ALTER TABLE IF EXISTS falukant_log.notification + DROP COLUMN IF EXISTS character_id; + `); } }; diff --git a/backend/migrations/20251216000000-add-weather-type-to-production.cjs b/backend/migrations/20251216000000-add-weather-type-to-production.cjs new file mode 100644 index 0000000..2f4e04a --- /dev/null +++ b/backend/migrations/20251216000000-add-weather-type-to-production.cjs @@ -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; + `); + } +}; diff --git a/backend/migrations/20251216000100-add-product-quality-to-stock.cjs b/backend/migrations/20251216000100-add-product-quality-to-stock.cjs new file mode 100644 index 0000000..edaabb4 --- /dev/null +++ b/backend/migrations/20251216000100-add-product-quality-to-stock.cjs @@ -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; + `); + } +}; diff --git a/backend/models/falukant/data/stock.js b/backend/models/falukant/data/stock.js index e95ef8f..65dcf73 100644 --- a/backend/models/falukant/data/stock.js +++ b/backend/models/falukant/data/stock.js @@ -14,7 +14,13 @@ FalukantStock.init({ allowNull: false}, quantity: { type: DataTypes.INTEGER, - allowNull: false}}, { + allowNull: false}, + productQuality: { + type: DataTypes.INTEGER, + allowNull: true, + comment: 'Quality of the stored product (0-100)' + } +}, { sequelize, modelName: 'StockData', tableName: 'stock', diff --git a/backend/services/falukantService.js b/backend/services/falukantService.js index ed03446..5c9351b 100644 --- a/backend/services/falukantService.js +++ b/backend/services/falukantService.js @@ -133,6 +133,64 @@ async function calcRegionalSellPrice(product, knowledgeFactor, regionId, worthPe 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) { const minTitle = 1; const adjustedTitle = titleOfNobility - minTitle + 1; @@ -1527,8 +1585,8 @@ class FalukantService extends BaseService { const knowledgeVal = item.knowledges?.[0]?.knowledge || 0; const pricePerUnit = await calcRegionalSellPrice(item, knowledgeVal, branch.regionId); - // compute cumulative tax (region + ancestors) and inflate price so seller net is unchanged - const cumulativeTax = await getCumulativeTaxPercent(branch.regionId); + // compute cumulative tax (region + ancestors) with political exemptions and inflate price so seller net is unchanged + const cumulativeTax = await getCumulativeTaxPercentWithExemptions(user.id, branch.regionId); const inflationFactor = cumulativeTax >= 100 ? 1 : (1 / (1 - cumulativeTax / 100)); const adjustedPricePerUnit = Math.round(pricePerUnit * inflationFactor * 100) / 100; const revenue = quantity * adjustedPricePerUnit; @@ -1614,7 +1672,7 @@ class FalukantService extends BaseService { const knowledgeVal = item.productType.knowledges[0]?.knowledge || 0; const regionId = item.stock.branch.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 adjustedPricePerUnit = Math.round(pricePerUnit * inflationFactor * 100) / 100; total += item.quantity * adjustedPricePerUnit; @@ -4363,15 +4421,26 @@ class FalukantService extends BaseService { // Unikate nach character.id 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 => { const c = h.holder; if (c && c.id && !map.has(c.id)) { + const officeName = h.type?.name; + const benefit = POLITICAL_TAX_EXEMPTIONS[officeName] || []; map.set(c.id, { id: c.id, name: `${c.definedFirstName.name} ${c.definedLastName.name}`, title: c.nobleTitle.labelTr, - officeType: h.type.name, - gender: c.gender + officeType: officeName, + gender: c.gender, + benefit }); } }); diff --git a/backend/sql/add_product_quality_to_stock.sql b/backend/sql/add_product_quality_to_stock.sql new file mode 100644 index 0000000..e1c41fb --- /dev/null +++ b/backend/sql/add_product_quality_to_stock.sql @@ -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 diff --git a/backend/sql/add_weather_type_to_production.sql b/backend/sql/add_weather_type_to_production.sql new file mode 100644 index 0000000..b6fa5df --- /dev/null +++ b/backend/sql/add_weather_type_to_production.sql @@ -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 diff --git a/frontend/src/components/falukant/MessagesDialog.vue b/frontend/src/components/falukant/MessagesDialog.vue index 9e962e2..8b1c00f 100644 --- a/frontend/src/components/falukant/MessagesDialog.vue +++ b/frontend/src/components/falukant/MessagesDialog.vue @@ -5,7 +5,10 @@ :title="'falukant.messages.title'" :isTitleTranslated="true" icon="falukant/messages24.png" - :buttons="[{ text: 'message.close', action: 'close' }]" + :buttons="[ + { text: 'falukant.messages.markAllRead', action: 'markAll' }, + { text: 'message.close', action: 'close' } + ]" width="520px" height="420px" > @@ -59,6 +62,15 @@ export default { // mark unread as shown 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() { try { const { data } = await apiClient.get('/api/falukant/notifications/all', { params: { page: this.page, size: this.size } }); diff --git a/frontend/src/i18n/locales/de/falukant.json b/frontend/src/i18n/locales/de/falukant.json index abc2859..e6d532a 100644 --- a/frontend/src/i18n/locales/de/falukant.json +++ b/frontend/src/i18n/locales/de/falukant.json @@ -27,7 +27,8 @@ "messages": { "title": "Nachrichten", "tooltip": "Nachrichten", - "empty": "Keine Nachrichten vorhanden." + "empty": "Keine Nachrichten vorhanden.", + "markAllRead": "Alle als gelesen markieren" }, "notifications": { "notify_election_created": "Es wurde eine neue Wahl ausgeschrieben.", @@ -902,6 +903,8 @@ "office": "Amt", "region": "Region", "termEnds": "Läuft ab am", + "benefit": "Vorteil", + "benefit_all": "Alle Regionen", "income": "Einkommen", "none": "Keine aktuelle Position vorhanden.", "holder": "Inhaber" diff --git a/frontend/src/i18n/locales/en/falukant.json b/frontend/src/i18n/locales/en/falukant.json index 060700d..fd7ed8f 100644 --- a/frontend/src/i18n/locales/en/falukant.json +++ b/frontend/src/i18n/locales/en/falukant.json @@ -13,7 +13,8 @@ "messages": { "title": "Messages", "tooltip": "Messages", - "empty": "No messages." + "empty": "No messages.", + "markAllRead": "Mark all as read" }, "notifications": { "notify_election_created": "A new election has been scheduled.", @@ -211,6 +212,8 @@ "office": "Office", "region": "Region", "termEnds": "Term Ends", + "benefit": "Benefit", + "benefit_all": "All regions", "income": "Income", "none": "No current position available.", "holder": "Holder" diff --git a/frontend/src/views/falukant/PoliticsView.vue b/frontend/src/views/falukant/PoliticsView.vue index de828ef..2303524 100644 --- a/frontend/src/views/falukant/PoliticsView.vue +++ b/frontend/src/views/falukant/PoliticsView.vue @@ -17,7 +17,8 @@ {{ $t('falukant.politics.current.office') }} {{ $t('falukant.politics.current.region') }} - {{ $t('falukant.politics.current.holder') }} + {{ $t('falukant.politics.current.holder') }} + {{ $t('falukant.politics.current.benefit') }} {{ $t('falukant.politics.current.termEnds') }} @@ -32,6 +33,13 @@ + + + {{ $t('falukant.politics.current.benefit_all') }} + {{ pos.benefit.join(', ') }} + + + {{ formatDate(pos.termEnds) }}