From 8af726c65af490fc7f8a63b9c5793937eed8d1ff Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Wed, 25 Mar 2026 13:23:51 +0100 Subject: [PATCH] Update environment configuration and enhance logging: Add support for loading a local .env file and improve logging behavior based on QUIET_ENV_LOGS settings. Introduce new diagnostic scripts in package.json for town worth and money flow analysis. Adjust production cost calculations in FalukantService to align with updated pricing logic and enhance product initialization parameters. --- .gitignore | 1 + backend/config/loadEnv.js | 72 ++++++--- backend/env.example | 21 +++ backend/env.local.example | 5 + .../falukant/data/town_product_worth.js | 3 +- backend/package.json | 2 + backend/scripts/falukant-moneyflow-report.mjs | 142 ++++++++++++++++++ .../falukant-town-product-worth-stats.mjs | 103 +++++++++++++ backend/services/falukantService.js | 44 ++++-- backend/sql/balance_carrot_product.sql | 38 +++++ .../falukant_moneyflow_by_activity.sql | 31 ++++ .../falukant_moneyflow_window_totals.sql | 14 ++ .../falukant_town_product_worth_stats.sql | 16 ++ .../falukant/initializeFalukantPredefines.js | 3 +- backend/utils/sequelize.js | 49 ++++-- 15 files changed, 498 insertions(+), 46 deletions(-) create mode 100644 backend/env.example create mode 100644 backend/env.local.example create mode 100644 backend/scripts/falukant-moneyflow-report.mjs create mode 100644 backend/scripts/falukant-town-product-worth-stats.mjs create mode 100644 backend/sql/balance_carrot_product.sql create mode 100644 backend/sql/diagnostics/falukant_moneyflow_by_activity.sql create mode 100644 backend/sql/diagnostics/falukant_moneyflow_window_totals.sql create mode 100644 backend/sql/diagnostics/falukant_town_product_worth_stats.sql diff --git a/.gitignore b/.gitignore index b262bb5..1eafed7 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ node_modules node_modules/* **/package-lock.json backend/.env +backend/.env.local backend/images backend/images/* backend/node_modules diff --git a/backend/config/loadEnv.js b/backend/config/loadEnv.js index 924df09..818964a 100644 --- a/backend/config/loadEnv.js +++ b/backend/config/loadEnv.js @@ -7,6 +7,16 @@ import fs from 'fs'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +const quietEnv = process.env.QUIET_ENV_LOGS === '1'; +const dotenvQuiet = quietEnv || process.env.DOTENV_CONFIG_QUIET === '1'; + +function log(...args) { + if (!quietEnv) console.log(...args); +} +function warn(...args) { + console.warn(...args); +} + // Versuche zuerst Produktions-.env, dann lokale .env const productionEnvPath = '/opt/yourpart/backend/.env'; const localEnvPath = path.resolve(__dirname, '../.env'); @@ -19,54 +29,68 @@ if (fs.existsSync(productionEnvPath)) { fs.accessSync(productionEnvPath, fs.constants.R_OK); envPath = productionEnvPath; usingProduction = true; - console.log('[env] Produktions-.env gefunden und lesbar:', productionEnvPath); + log('[env] Produktions-.env gefunden und lesbar:', productionEnvPath); } catch (err) { - console.warn('[env] Produktions-.env vorhanden, aber nicht lesbar - verwende lokale .env stattdessen:', productionEnvPath); - console.warn('[env] Fehler:', err && err.message); + if (!quietEnv) { + warn('[env] Produktions-.env vorhanden, aber nicht lesbar - verwende lokale .env stattdessen:', productionEnvPath); + warn('[env] Fehler:', err && err.message); + } envPath = localEnvPath; } } else { - console.log('[env] Produktions-.env nicht gefunden, lade lokale .env:', localEnvPath); + log('[env] Produktions-.env nicht gefunden, lade lokale .env:', localEnvPath); } // Lade .env-Datei (robust gegen Fehler) -console.log('[env] Versuche .env zu laden von:', envPath); -console.log('[env] Datei existiert:', fs.existsSync(envPath)); +log('[env] Versuche .env zu laden von:', envPath); +log('[env] Datei existiert:', fs.existsSync(envPath)); let result; try { - result = dotenv.config({ path: envPath }); + result = dotenv.config({ path: envPath, quiet: dotenvQuiet }); if (result.error) { - console.warn('[env] Konnte .env nicht laden:', result.error.message); - console.warn('[env] Fehler-Details:', result.error); + warn('[env] Konnte .env nicht laden:', result.error.message); + warn('[env] Fehler-Details:', result.error); } else { - console.log('[env] .env erfolgreich geladen von:', envPath, usingProduction ? '(production)' : '(local)'); - console.log('[env] Geladene Variablen:', Object.keys(result.parsed || {})); + log('[env] .env erfolgreich geladen von:', envPath, usingProduction ? '(production)' : '(local)'); + log('[env] Geladene Variablen:', Object.keys(result.parsed || {})); } } catch (err) { // Sollte nicht passieren, aber falls dotenv intern eine Exception wirft (z.B. EACCES), fange sie ab - console.warn('[env] Unerwarteter Fehler beim Laden der .env:', err && err.message); - console.warn('[env] Stack:', err && err.stack); + warn('[env] Unerwarteter Fehler beim Laden der .env:', err && err.message); + warn('[env] Stack:', err && err.stack); if (envPath !== localEnvPath && fs.existsSync(localEnvPath)) { - console.log('[env] Versuche stattdessen lokale .env:', localEnvPath); + log('[env] Versuche stattdessen lokale .env:', localEnvPath); try { - result = dotenv.config({ path: localEnvPath }); + result = dotenv.config({ path: localEnvPath, quiet: dotenvQuiet }); if (!result.error) { - console.log('[env] Lokale .env erfolgreich geladen von:', localEnvPath); + log('[env] Lokale .env erfolgreich geladen von:', localEnvPath); } } catch (err2) { - console.warn('[env] Konnte lokale .env auch nicht laden:', err2 && err2.message); + warn('[env] Konnte lokale .env auch nicht laden:', err2 && err2.message); } } } -// Debug: Zeige Redis-Konfiguration -console.log('[env] Redis-Konfiguration:'); -console.log('[env] REDIS_HOST:', process.env.REDIS_HOST); -console.log('[env] REDIS_PORT:', process.env.REDIS_PORT); -console.log('[env] REDIS_PASSWORD:', process.env.REDIS_PASSWORD ? '***gesetzt***' : 'NICHT GESETZT'); -console.log('[env] REDIS_URL:', process.env.REDIS_URL); +// Lokale Überschreibungen (nicht committen): z. B. SSH-Tunnel DB_HOST=127.0.0.1 DB_PORT=60000 +const localOverridePath = path.resolve(__dirname, '../.env.local'); +if (fs.existsSync(localOverridePath)) { + const overrideResult = dotenv.config({ path: localOverridePath, override: true, quiet: dotenvQuiet }); + if (!overrideResult.error) { + log('[env] .env.local geladen (überschreibt Werte, z. B. SSH-Tunnel)'); + } else { + warn('[env] .env.local vorhanden, aber Laden fehlgeschlagen:', overrideResult.error?.message); + } +} + +if (!quietEnv) { + console.log('[env] Redis-Konfiguration:'); + console.log('[env] REDIS_HOST:', process.env.REDIS_HOST); + console.log('[env] REDIS_PORT:', process.env.REDIS_PORT); + console.log('[env] REDIS_PASSWORD:', process.env.REDIS_PASSWORD ? '***gesetzt***' : 'NICHT GESETZT'); + console.log('[env] REDIS_URL:', process.env.REDIS_URL); +} if (!process.env.SECRET_KEY) { - console.warn('[env] SECRET_KEY nicht gesetzt in .env'); + warn('[env] SECRET_KEY nicht gesetzt in .env'); } export {}; diff --git a/backend/env.example b/backend/env.example new file mode 100644 index 0000000..ad2e0b1 --- /dev/null +++ b/backend/env.example @@ -0,0 +1,21 @@ +# Kopie nach `backend/.env` — nicht committen. +# +# Produktion / direkter DB-Host steht typischerweise in `.env`. +# Für Entwicklung mit SSH-Tunnel: Datei `backend/.env.local` anlegen (wird nach `.env` +# geladen und überschreibt). So bleibt `.env` mit echtem Host, Tunnel nur lokal. +# +# Beispiel backend/.env.local: +# DB_HOST=127.0.0.1 +# DB_PORT=60000 +# # DB_SSL=0 +# (Tunnel z. B.: ssh -L 60000:127.0.0.1:5432 user@server) +# +DB_NAME= +DB_USER= +DB_PASS= +# DB_HOST= +# DB_PORT=5432 +# DB_SSL=0 +# +# Optional (Defaults siehe utils/sequelize.js) +# DB_CONNECT_TIMEOUT_MS=30000 diff --git a/backend/env.local.example b/backend/env.local.example new file mode 100644 index 0000000..b4dec71 --- /dev/null +++ b/backend/env.local.example @@ -0,0 +1,5 @@ +# Kopie nach backend/.env.local (liegt neben .env, wird nicht committet). +# Überschreibt nur bei dir lokal z. B. SSH-Tunnel — .env kann weiter DB_HOST=tsschulz.de haben. + +DB_HOST=127.0.0.1 +DB_PORT=60000 diff --git a/backend/models/falukant/data/town_product_worth.js b/backend/models/falukant/data/town_product_worth.js index e314890..9551558 100644 --- a/backend/models/falukant/data/town_product_worth.js +++ b/backend/models/falukant/data/town_product_worth.js @@ -22,8 +22,9 @@ TownProductWorth.init({ timestamps: false, underscored: true, hooks: { + // Neu: 55–85 %; ältere Einträge können 40–60 % sein (Preislogik im Service deckelt nach unten ab). beforeCreate: (worthPercent) => { - worthPercent.worthPercent = Math.floor(Math.random() * 20) + 40; + worthPercent.worthPercent = Math.floor(Math.random() * 31) + 55; } } }); diff --git a/backend/package.json b/backend/package.json index 7c5fd17..6da8090 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,6 +12,8 @@ "sync-tables": "node sync-tables-only.js", "check-connections": "node check-connections.js", "cleanup-connections": "node cleanup-connections.js", + "diag:town-worth": "QUIET_ENV_LOGS=1 DOTENV_CONFIG_QUIET=1 node scripts/falukant-town-product-worth-stats.mjs", + "diag:moneyflow": "QUIET_ENV_LOGS=1 DOTENV_CONFIG_QUIET=1 node scripts/falukant-moneyflow-report.mjs", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], diff --git a/backend/scripts/falukant-moneyflow-report.mjs b/backend/scripts/falukant-moneyflow-report.mjs new file mode 100644 index 0000000..f361548 --- /dev/null +++ b/backend/scripts/falukant-moneyflow-report.mjs @@ -0,0 +1,142 @@ +#!/usr/bin/env node +/** + * Aggregiert falukant_log.moneyflow nach activity (reale Buchungen aus dem Spiel). + * + * cd backend && npm run diag:moneyflow + * + * Umgebung: + * DIAG_DAYS=30 — Fenster in Tagen (1–3650, Standard 30) + * DIAG_USER_ID=123 — optional: nur dieser falukant_user + * DIAG_QUERY_TIMEOUT_MS, DIAG_AUTH_TIMEOUT_MS — wie diag:town-worth + */ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const sqlByActivity = join(__dirname, '../sql/diagnostics/falukant_moneyflow_by_activity.sql'); +const sqlTotals = join(__dirname, '../sql/diagnostics/falukant_moneyflow_window_totals.sql'); + +const QUERY_TIMEOUT_MS = Number.parseInt(process.env.DIAG_QUERY_TIMEOUT_MS || '60000', 10); +const AUTH_TIMEOUT_MS = Number.parseInt(process.env.DIAG_AUTH_TIMEOUT_MS || '25000', 10); + +process.env.QUIET_ENV_LOGS = process.env.QUIET_ENV_LOGS || '1'; +process.env.DOTENV_CONFIG_QUIET = process.env.DOTENV_CONFIG_QUIET || '1'; +if (!process.env.DB_CONNECT_TIMEOUT_MS) { + process.env.DB_CONNECT_TIMEOUT_MS = '15000'; +} + +await import('../config/loadEnv.js'); +const { sequelize } = await import('../utils/sequelize.js'); + +function withTimeout(promise, ms, onTimeoutError) { + let timerId; + const timeoutPromise = new Promise((_, reject) => { + timerId = setTimeout(() => reject(new Error(onTimeoutError)), ms); + }); + return Promise.race([promise, timeoutPromise]).finally(() => { + clearTimeout(timerId); + }); +} + +function raceQuery(sql) { + return withTimeout( + sequelize.query(sql), + QUERY_TIMEOUT_MS, + `Abfrage-Timeout nach ${QUERY_TIMEOUT_MS} ms (DIAG_QUERY_TIMEOUT_MS)` + ); +} + +function raceAuth() { + return withTimeout( + sequelize.authenticate(), + AUTH_TIMEOUT_MS, + `authenticate() Timeout nach ${AUTH_TIMEOUT_MS} ms (DIAG_AUTH_TIMEOUT_MS).` + ); +} + +function printConnectionHints() { + const port = process.env.DB_PORT || '5432'; + const host = process.env.DB_HOST || '?'; + const local = host === '127.0.0.1' || host === 'localhost' || host === '::1'; + console.error(''); + console.error('[diag] Mögliche Ursachen:'); + if (local) { + console.error(' • SSH-Tunnel: Läuft z. B. ssh -L ' + port + ':127.0.0.1:5432 …? Dann DB_HOST=127.0.0.1 DB_PORT=' + port + ' (DB_SSL meist aus).'); + console.error(' • Falscher lokaler Forward-Port in .env (DB_PORT).'); + } else { + console.error(' • PostgreSQL erwartet TLS: in .env DB_SSL=1 setzen (ggf. DB_SSL_REJECT_UNAUTHORIZED=0 bei selbstsigniert).'); + console.error(' • Falscher Port: DB_PORT=' + port + ' prüfen.'); + console.error(' • Server-Firewall: deine IP muss für Port', port, 'auf', host, 'freigeschaltet sein.'); + } + console.error(' • Test: nc -zv', host, port); + console.error(''); +} + +function parseDays() { + const raw = process.env.DIAG_DAYS; + const n = raw === undefined || raw === '' ? 30 : Number.parseInt(raw, 10); + if (!Number.isFinite(n) || n < 1 || n > 3650) { + throw new Error('DIAG_DAYS muss eine Ganzzahl zwischen 1 und 3650 sein'); + } + return n; +} + +function parseOptionalUserFilter() { + const raw = process.env.DIAG_USER_ID; + if (raw === undefined || raw === '') { + return ''; + } + const uid = Number.parseInt(raw, 10); + if (!Number.isInteger(uid) || uid < 1) { + throw new Error('DIAG_USER_ID muss eine positive Ganzzahl sein (falukant_user.id)'); + } + return ` AND m.falukant_user_id = ${uid}`; +} + +function applyPlaceholders(sql, days, userFilter) { + return sql + .replace(/__DIAG_DAYS__/g, String(days)) + .replace(/__DIAG_USER_FILTER__/g, userFilter); +} + +try { + const days = parseDays(); + const userFilter = parseOptionalUserFilter(); + const host = process.env.DB_HOST || '(unbekannt)'; + const t0 = Date.now(); + + console.log(''); + console.log('[diag] moneyflow — Fenster:', days, 'Tage', userFilter ? `(User ${process.env.DIAG_USER_ID})` : '(alle Nutzer)'); + console.log('[diag] PostgreSQL: authenticate() … (Host:', host + ', Port:', process.env.DB_PORT || '5432', ', DB_SSL:', process.env.DB_SSL === '1' ? '1' : '0', ')'); + console.log(''); + + await raceAuth(); + console.log('[diag] authenticate() ok nach', Date.now() - t0, 'ms'); + + await sequelize.query("SET statement_timeout = '60s'"); + + const sql1 = applyPlaceholders(readFileSync(sqlByActivity, 'utf8'), days, userFilter); + const sql2 = applyPlaceholders(readFileSync(sqlTotals, 'utf8'), days, userFilter); + + const t1 = Date.now(); + const [totalsRows] = await raceQuery(sql2); + console.log('[diag] Fenster-Totals nach', Date.now() - t1, 'ms'); + console.table(totalsRows); + + const t2 = Date.now(); + const [byActivity] = await raceQuery(sql1); + console.log('[diag] Nach activity nach', Date.now() - t2, 'ms (gesamt', Date.now() - t0, 'ms)'); + console.log(''); + console.table(byActivity); + + await sequelize.close(); + process.exit(0); +} catch (err) { + console.error(err.message || err); + printConnectionHints(); + try { + await sequelize.close(); + } catch (_) {} + process.exit(1); +} diff --git a/backend/scripts/falukant-town-product-worth-stats.mjs b/backend/scripts/falukant-town-product-worth-stats.mjs new file mode 100644 index 0000000..60877da --- /dev/null +++ b/backend/scripts/falukant-town-product-worth-stats.mjs @@ -0,0 +1,103 @@ +#!/usr/bin/env node +/** + * Liest backend/sql/diagnostics/falukant_town_product_worth_stats.sql und gibt eine Tabelle aus. + * + * cd backend && npm run diag:town-worth + * + * SSH-Tunnel: DB_HOST=127.0.0.1, DB_PORT= — siehe backend/env.example + * Hängt die Verbindung: Tunnel läuft? Sonst TLS (DB_SSL=1), falscher Port, Firewall. + */ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const sqlPath = join(__dirname, '../sql/diagnostics/falukant_town_product_worth_stats.sql'); + +const QUERY_TIMEOUT_MS = Number.parseInt(process.env.DIAG_QUERY_TIMEOUT_MS || '60000', 10); +const AUTH_TIMEOUT_MS = Number.parseInt(process.env.DIAG_AUTH_TIMEOUT_MS || '25000', 10); + +process.env.QUIET_ENV_LOGS = process.env.QUIET_ENV_LOGS || '1'; +process.env.DOTENV_CONFIG_QUIET = process.env.DOTENV_CONFIG_QUIET || '1'; +if (!process.env.DB_CONNECT_TIMEOUT_MS) { + process.env.DB_CONNECT_TIMEOUT_MS = '15000'; +} + +await import('../config/loadEnv.js'); +const { sequelize } = await import('../utils/sequelize.js'); + +/** Promise.race + Timeout, aber Timer wird bei Erfolg cleared — sonst blockiert setTimeout(60s) den Prozess. */ +function withTimeout(promise, ms, onTimeoutError) { + let timerId; + const timeoutPromise = new Promise((_, reject) => { + timerId = setTimeout(() => reject(new Error(onTimeoutError)), ms); + }); + return Promise.race([promise, timeoutPromise]).finally(() => { + clearTimeout(timerId); + }); +} + +function raceQuery(sql) { + return withTimeout( + sequelize.query(sql), + QUERY_TIMEOUT_MS, + `Abfrage-Timeout nach ${QUERY_TIMEOUT_MS} ms (DIAG_QUERY_TIMEOUT_MS)` + ); +} + +function raceAuth() { + return withTimeout( + sequelize.authenticate(), + AUTH_TIMEOUT_MS, + `authenticate() Timeout nach ${AUTH_TIMEOUT_MS} ms — TCP/TLS zu PostgreSQL kommt nicht zustande (DIAG_AUTH_TIMEOUT_MS).` + ); +} + +function printConnectionHints() { + const port = process.env.DB_PORT || '5432'; + const host = process.env.DB_HOST || '?'; + const local = host === '127.0.0.1' || host === 'localhost' || host === '::1'; + console.error(''); + console.error('[diag] Mögliche Ursachen:'); + if (local) { + console.error(' • SSH-Tunnel: Läuft z. B. ssh -L ' + port + ':127.0.0.1:5432 …? Dann DB_HOST=127.0.0.1 DB_PORT=' + port + ' (DB_SSL meist aus).'); + console.error(' • Falscher lokaler Forward-Port in .env (DB_PORT).'); + } else { + console.error(' • PostgreSQL erwartet TLS: in .env DB_SSL=1 setzen (ggf. DB_SSL_REJECT_UNAUTHORIZED=0 bei selbstsigniert).'); + console.error(' • Falscher Port: DB_PORT=' + port + ' prüfen.'); + console.error(' • Server-Firewall: deine IP muss für Port', port, 'auf', host, 'freigeschaltet sein.'); + } + console.error(' • Test: nc -zv', host, port); + console.error(''); +} + +try { + const sql = readFileSync(sqlPath, 'utf8'); + const host = process.env.DB_HOST || '(unbekannt)'; + const t0 = Date.now(); + + console.log(''); + console.log('[diag] PostgreSQL: authenticate() … (Host:', host + ', Port:', process.env.DB_PORT || '5432', ', DB_SSL:', process.env.DB_SSL === '1' ? '1' : '0', ')'); + console.log(''); + + await raceAuth(); + console.log('[diag] authenticate() ok nach', Date.now() - t0, 'ms'); + + await sequelize.query("SET statement_timeout = '30s'"); + + const t1 = Date.now(); + const [rows] = await raceQuery(sql); + console.log('[diag] SELECT ok nach', Date.now() - t1, 'ms (gesamt', Date.now() - t0, 'ms)'); + console.log(''); + + console.table(rows); + await sequelize.close(); + process.exit(0); +} catch (err) { + console.error(err.message || err); + printConnectionHints(); + try { + await sequelize.close(); + } catch (_) {} + process.exit(1); +} diff --git a/backend/services/falukantService.js b/backend/services/falukantService.js index a6d2482..b514d43 100644 --- a/backend/services/falukantService.js +++ b/backend/services/falukantService.js @@ -97,17 +97,40 @@ async function getBranchOrFail(userId, branchId) { return branch; } +/** + * Gesamtkosten für eine Produktionscharge (früher: quantity * category * 6 pro Einheit). + * Pro Einheit jetzt 2 * Kategorie (z.B. Kat.1 = 2, Kat.2 = 4), damit Nettoerlös und + * typischer town_product_worth (oft 40–60 %, siehe Model-Hook) zusammenpassen. + */ +function productionCostTotal(quantity, category) { + const c = Math.max(1, Number(category) || 1); + const q = Math.min(100, Math.max(1, Number(quantity) || 1)); + const perUnit = 2 * c; + return q * perUnit; +} + +/** Regionaler Nachfragewert: sehr niedrige Werte aus der DB sonst unspielbar; Decke nach oben bei 100. */ +function effectiveWorthPercent(worthPercent) { + const w = Number(worthPercent); + if (Number.isNaN(w)) return 75; + return Math.min(100, Math.max(75, w)); +} + +/** Untergrenze für den Wissens-Multiplikator auf den Basispreis (0 = minAnteil, 100 = voller Basispreis). */ +const KNOWLEDGE_PRICE_FLOOR = 0.7; + function calcSellPrice(product, knowledgeFactor = 0) { const max = product.sellCost; - const min = max * 0.6; + const min = max * KNOWLEDGE_PRICE_FLOOR; return min + (max - min) * (knowledgeFactor / 100); } /** Synchrone Preisberechnung, wenn worthPercent bereits bekannt ist (kein DB-Zugriff). */ function calcRegionalSellPriceSync(product, knowledgeFactor, worthPercent) { if (product.sellCost === null || product.sellCost === undefined) return null; - const basePrice = product.sellCost * (worthPercent / 100); - const min = basePrice * 0.6; + const w = effectiveWorthPercent(worthPercent); + const basePrice = product.sellCost * (w / 100); + const min = basePrice * KNOWLEDGE_PRICE_FLOOR; const max = basePrice; return min + (max - min) * (knowledgeFactor / 100); } @@ -224,17 +247,16 @@ async function calcRegionalSellPrice(product, knowledgeFactor, regionId, worthPe }); worthPercent = townWorth?.worthPercent || 50; // Default 50% wenn nicht gefunden } - + // Prüfe ob sellCost vorhanden ist if (product.sellCost === null || product.sellCost === undefined) { throw new Error(`Product ${product.id} has no sellCost defined`); } - - // Basispreis basierend auf regionalem worthPercent - const basePrice = product.sellCost * (worthPercent / 100); - - // Dann Knowledge-Faktor anwenden - const min = basePrice * 0.6; + + const w = effectiveWorthPercent(worthPercent); + const basePrice = product.sellCost * (w / 100); + + const min = basePrice * KNOWLEDGE_PRICE_FLOOR; const max = basePrice; return min + (max - min) * (knowledgeFactor / 100); } @@ -2031,7 +2053,7 @@ class FalukantService extends BaseService { } if (!p) throw new Error('Product not found'); quantity = Math.min(100, quantity); - const cost = quantity * p.category * 6; + const cost = productionCostTotal(quantity, p.category); if (u.money < cost) throw new Error('notenoughmoney'); const r = await updateFalukantUserMoney(u.id, -cost, 'Production cost', u.id); if (!r.success) throw new Error('Failed to update money'); diff --git a/backend/sql/balance_carrot_product.sql b/backend/sql/balance_carrot_product.sql new file mode 100644 index 0000000..be91ec6 --- /dev/null +++ b/backend/sql/balance_carrot_product.sql @@ -0,0 +1,38 @@ +-- Karotte: Debug-Tempo und Preis an gleiche Basis wie andere Kat.-1-Waren (siehe initializeFalukantPredefines.js). +-- Sicher für alle Installationen: nur production_time ohne optionale Spalten. + +BEGIN; + +UPDATE falukant_type.product +SET production_time = 2 +WHERE label_tr = 'carrot'; + +COMMIT; + +-- Optional (wenn Migration mit original_sell_cost läuft): in derselben Session ausführen +/* +UPDATE falukant_type.product +SET original_sell_cost = 6 +WHERE label_tr = 'carrot'; + +WITH RECURSIVE ancestors AS ( + SELECT id AS start_id, id, parent_id, tax_percent FROM falukant_data.region + UNION ALL + SELECT a.start_id, r.id, r.parent_id, r.tax_percent + FROM falukant_data.region r + JOIN ancestors a ON r.id = a.parent_id +), totals AS ( + SELECT start_id, COALESCE(SUM(tax_percent), 0) AS total FROM ancestors GROUP BY start_id +), mm AS ( + SELECT COALESCE(MAX(total), 0) AS max_total FROM totals +) +UPDATE falukant_type.product p +SET sell_cost = CEIL(p.original_sell_cost * ( + CASE WHEN (1 - mm.max_total / 100) <= 0 THEN 1 ELSE (1 / (1 - mm.max_total / 100)) END +)) +FROM mm +WHERE p.label_tr = 'carrot' AND p.original_sell_cost IS NOT NULL; +*/ + +-- Ohne original_sell_cost: grob sell_cost = 6 (wie Milch/Brot; ggf. anpassen) +-- UPDATE falukant_type.product SET sell_cost = 6 WHERE label_tr = 'carrot'; diff --git a/backend/sql/diagnostics/falukant_moneyflow_by_activity.sql b/backend/sql/diagnostics/falukant_moneyflow_by_activity.sql new file mode 100644 index 0000000..abe7882 --- /dev/null +++ b/backend/sql/diagnostics/falukant_moneyflow_by_activity.sql @@ -0,0 +1,31 @@ +-- Falukant: Geldbewegungen aus falukant_log.moneyflow (jede Zeile = ein Aufruf von +-- falukant_data.update_money → activity-String wie im Backend übergeben). +-- +-- Typische activity-Werte (siehe falukantService.js → updateFalukantUserMoney): +-- Wirtschaft: Production cost; Product sale (net); Sell all products (net); +-- Steueranteil Region (Verkauf); Sales tax (…); Buy/Sell storage (type: …) +-- Transport/Fahrzeuge: transport; build_vehicles; buy_vehicles; repair_vehicle; repair_all_vehicles +-- Filiale: create_branch +-- Haus: housebuy; servants_hired; household_order; renovation_*; renovation_all +-- Soziales: marriage_gift; Marriage cost; Gift cost; partyOrder; Baptism; Reputation action: … +-- Bildung: learnAll; learnItem: +-- Sonst: new nobility title; health.; credit taken (Kredit = positiver change_value) +-- +-- Platzhalter (ersetzt durch scripts/falukant-moneyflow-report.mjs): +-- __DIAG_DAYS__ → positive Ganzzahl (Tage zurück) +-- __DIAG_USER_FILTER__ → leer ODER " AND m.falukant_user_id = " +-- +-- Manuell in psql: __DIAG_DAYS__ durch z. B. 30 ersetzen, __DIAG_USER_FILTER__ leer lassen. + +SELECT + m.activity, + COUNT(*)::bigint AS n, + ROUND(SUM(m.change_value)::numeric, 2) AS sum_change, + ROUND(SUM(CASE WHEN m.change_value < 0 THEN -m.change_value ELSE 0 END)::numeric, 2) AS total_outflow, + ROUND(SUM(CASE WHEN m.change_value > 0 THEN m.change_value ELSE 0 END)::numeric, 2) AS total_inflow, + ROUND(AVG(m.change_value)::numeric, 4) AS avg_change +FROM falukant_log.moneyflow m +WHERE m."time" >= NOW() - (INTERVAL '1 day' * __DIAG_DAYS__) +__DIAG_USER_FILTER__ +GROUP BY m.activity +ORDER BY sum_change ASC NULLS LAST; diff --git a/backend/sql/diagnostics/falukant_moneyflow_window_totals.sql b/backend/sql/diagnostics/falukant_moneyflow_window_totals.sql new file mode 100644 index 0000000..4d5330d --- /dev/null +++ b/backend/sql/diagnostics/falukant_moneyflow_window_totals.sql @@ -0,0 +1,14 @@ +-- Kurzüberblick: Zeilen, Nutzer, Summen Ein-/Ausgang im gleichen Fenster wie +-- falukant_moneyflow_by_activity.sql (Platzhalter identisch). + +SELECT + COUNT(*)::bigint AS row_count, + COUNT(DISTINCT m.falukant_user_id)::bigint AS distinct_falukant_users, + ROUND(SUM(m.change_value)::numeric, 2) AS net_sum_all, + ROUND(SUM(CASE WHEN m.change_value < 0 THEN m.change_value ELSE 0 END)::numeric, 2) AS sum_negative_only, + ROUND(SUM(CASE WHEN m.change_value > 0 THEN m.change_value ELSE 0 END)::numeric, 2) AS sum_positive_only, + MIN(m."time") AS first_ts, + MAX(m."time") AS last_ts +FROM falukant_log.moneyflow m +WHERE m."time" >= NOW() - (INTERVAL '1 day' * __DIAG_DAYS__) +__DIAG_USER_FILTER__; diff --git a/backend/sql/diagnostics/falukant_town_product_worth_stats.sql b/backend/sql/diagnostics/falukant_town_product_worth_stats.sql new file mode 100644 index 0000000..b406e7c --- /dev/null +++ b/backend/sql/diagnostics/falukant_town_product_worth_stats.sql @@ -0,0 +1,16 @@ +-- Übersicht: regionaler Warenwert je Produkt (Nachfrage / „worth“). +-- Niedrige Mittelwerte erklären schwache Verkaufspreise; siehe auch falukant_data.town_product_worth Hooks im Backend-Model. + +SELECT + p.id, + p.label_tr, + p.category, + p.sell_cost::numeric AS sell_cost, + ROUND(AVG(tpw.worth_percent)::numeric, 2) AS avg_worth_pct, + ROUND(MIN(tpw.worth_percent)::numeric, 2) AS min_worth_pct, + ROUND(MAX(tpw.worth_percent)::numeric, 2) AS max_worth_pct, + COUNT(tpw.region_id) AS region_rows +FROM falukant_type.product p +LEFT JOIN falukant_data.town_product_worth tpw ON tpw.product_id = p.id +GROUP BY p.id, p.label_tr, p.category, p.sell_cost +ORDER BY p.category, p.label_tr; diff --git a/backend/utils/falukant/initializeFalukantPredefines.js b/backend/utils/falukant/initializeFalukantPredefines.js index 2823200..f9ddb7c 100644 --- a/backend/utils/falukant/initializeFalukantPredefines.js +++ b/backend/utils/falukant/initializeFalukantPredefines.js @@ -250,7 +250,8 @@ async function initializeFalukantProducts() { const baseProducts = [ { labelTr: 'wheat', category: 1, productionTime: 2, sellCost: 7 }, { labelTr: 'grain', category: 1, productionTime: 2, sellCost: 7 }, - { labelTr: 'carrot', category: 1, productionTime: 1, sellCost: 5}, + // Wie Weizen/Getreide (kein Debug-Tempo mehr); Verkaufspreis wie Milch/Brot (Kat. 1, schnelle Ware) + { labelTr: 'carrot', category: 1, productionTime: 2, sellCost: 6 }, { labelTr: 'fish', category: 1, productionTime: 2, sellCost: 7 }, { labelTr: 'meat', category: 1, productionTime: 2, sellCost: 7 }, { labelTr: 'leather', category: 1, productionTime: 2, sellCost: 7 }, diff --git a/backend/utils/sequelize.js b/backend/utils/sequelize.js index b28a061..3159257 100644 --- a/backend/utils/sequelize.js +++ b/backend/utils/sequelize.js @@ -1,7 +1,18 @@ import { Sequelize, DataTypes } from 'sequelize'; import dotenv from 'dotenv'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; -dotenv.config(); +const _dotenvQuiet = process.env.QUIET_ENV_LOGS === '1' || process.env.DOTENV_CONFIG_QUIET === '1'; +dotenv.config({ quiet: _dotenvQuiet }); + +// backend/.env.local — Tunnel/Entwicklung (override), auch wenn loadEnv.js nicht importiert wurde +const _sequelizeDir = path.dirname(fileURLToPath(import.meta.url)); +const _envLocalPath = path.join(_sequelizeDir, '../.env.local'); +if (fs.existsSync(_envLocalPath)) { + dotenv.config({ path: _envLocalPath, override: true, quiet: _dotenvQuiet }); +} // Optionales Performance-Logging (aktivierbar per ENV) // - SQL_BENCHMARK=1: Sequelize liefert Query-Timing (ms) an logger @@ -27,12 +38,20 @@ const dbName = process.env.DB_NAME; const dbUser = process.env.DB_USER; const dbPass = process.env.DB_PASS || ''; // Fallback auf leeren String const dbHost = process.env.DB_HOST; +const dbPort = Number.parseInt(process.env.DB_PORT || '5432', 10); -console.log('Database configuration:'); -console.log('DB_NAME:', dbName); -console.log('DB_USER:', dbUser); -console.log('DB_PASS:', dbPass ? '[SET]' : '[NOT SET]'); -console.log('DB_HOST:', dbHost); +const useSsl = process.env.DB_SSL === '1' || process.env.PGSSLMODE === 'require'; +const connectTimeoutMs = Number.parseInt(process.env.DB_CONNECT_TIMEOUT_MS || '30000', 10); + +if (process.env.QUIET_ENV_LOGS !== '1') { + console.log('Database configuration:'); + console.log('DB_NAME:', dbName); + console.log('DB_USER:', dbUser); + console.log('DB_PASS:', dbPass ? '[SET]' : '[NOT SET]'); + console.log('DB_HOST:', dbHost); + console.log('DB_PORT:', dbPort); + console.log('DB_SSL:', useSsl ? 'on' : 'off'); +} if (!dbName || !dbUser || !dbHost) { throw new Error('Missing required database environment variables: DB_NAME, DB_USER, or DB_HOST'); @@ -44,8 +63,22 @@ const poolAcquire = Number.parseInt(process.env.DB_POOL_ACQUIRE || '30000', 10); const poolIdle = Number.parseInt(process.env.DB_POOL_IDLE || '10000', 10); const poolEvict = Number.parseInt(process.env.DB_POOL_EVICT || '1000', 10); +const dialectOptions = { + connectTimeout: connectTimeoutMs, + ...(useSsl + ? { + // node-pg: true oder { rejectUnauthorized: false } bei selbstsigniertem Zertifikat + ssl: + process.env.DB_SSL_REJECT_UNAUTHORIZED === '0' + ? { rejectUnauthorized: false } + : true + } + : {}) +}; + const sequelize = new Sequelize(dbName, dbUser, dbPass, { host: dbHost, + port: dbPort, dialect: 'postgres', define: { timestamps: false, @@ -61,9 +94,7 @@ const sequelize = new Sequelize(dbName, dbUser, dbPass, { evict: poolEvict, // Intervall (ms) zum Prüfen auf idle Verbindungen handleDisconnects: true // Automatisches Reconnect bei Verbindungsverlust }, - dialectOptions: { - connectTimeout: 30000 // Timeout für Verbindungsaufbau (30 Sekunden) - }, + dialectOptions, retry: { max: 3, // Maximale Anzahl von Wiederholungsversuchen match: [