Füge Spalte product_quality zur Tabelle stock hinzu und erstelle Migration für weather_type_id in production
This commit is contained in:
@@ -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;
|
||||
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;
|
||||
`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
11
backend/sql/add_product_quality_to_stock.sql
Normal file
11
backend/sql/add_product_quality_to_stock.sql
Normal 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
|
||||
38
backend/sql/add_weather_type_to_production.sql
Normal file
38
backend/sql/add_weather_type_to_production.sql
Normal 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
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<th>{{ $t('falukant.politics.current.office') }}</th>
|
||||
<th>{{ $t('falukant.politics.current.region') }}</th>
|
||||
<th>{{ $t('falukant.politics.current.holder') }}</th>
|
||||
<th>{{ $t('falukant.politics.current.benefit') }}</th>
|
||||
<th>{{ $t('falukant.politics.current.termEnds') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -32,6 +33,13 @@
|
||||
</span>
|
||||
<span v-else>—</span>
|
||||
</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>
|
||||
<span v-if="pos.termEnds">
|
||||
{{ formatDate(pos.termEnds) }}
|
||||
|
||||
Reference in New Issue
Block a user