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: [