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.

This commit is contained in:
Torsten Schulz (local)
2026-03-25 13:23:51 +01:00
parent 44991743d2
commit 8af726c65a
15 changed files with 498 additions and 46 deletions

1
.gitignore vendored
View File

@@ -7,6 +7,7 @@ node_modules
node_modules/* node_modules/*
**/package-lock.json **/package-lock.json
backend/.env backend/.env
backend/.env.local
backend/images backend/images
backend/images/* backend/images/*
backend/node_modules backend/node_modules

View File

@@ -7,6 +7,16 @@ import fs from 'fs';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); 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 // Versuche zuerst Produktions-.env, dann lokale .env
const productionEnvPath = '/opt/yourpart/backend/.env'; const productionEnvPath = '/opt/yourpart/backend/.env';
const localEnvPath = path.resolve(__dirname, '../.env'); const localEnvPath = path.resolve(__dirname, '../.env');
@@ -19,54 +29,68 @@ if (fs.existsSync(productionEnvPath)) {
fs.accessSync(productionEnvPath, fs.constants.R_OK); fs.accessSync(productionEnvPath, fs.constants.R_OK);
envPath = productionEnvPath; envPath = productionEnvPath;
usingProduction = true; usingProduction = true;
console.log('[env] Produktions-.env gefunden und lesbar:', productionEnvPath); log('[env] Produktions-.env gefunden und lesbar:', productionEnvPath);
} catch (err) { } catch (err) {
console.warn('[env] Produktions-.env vorhanden, aber nicht lesbar - verwende lokale .env stattdessen:', productionEnvPath); if (!quietEnv) {
console.warn('[env] Fehler:', err && err.message); warn('[env] Produktions-.env vorhanden, aber nicht lesbar - verwende lokale .env stattdessen:', productionEnvPath);
warn('[env] Fehler:', err && err.message);
}
envPath = localEnvPath; envPath = localEnvPath;
} }
} else { } 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) // Lade .env-Datei (robust gegen Fehler)
console.log('[env] Versuche .env zu laden von:', envPath); log('[env] Versuche .env zu laden von:', envPath);
console.log('[env] Datei existiert:', fs.existsSync(envPath)); log('[env] Datei existiert:', fs.existsSync(envPath));
let result; let result;
try { try {
result = dotenv.config({ path: envPath }); result = dotenv.config({ path: envPath, quiet: dotenvQuiet });
if (result.error) { if (result.error) {
console.warn('[env] Konnte .env nicht laden:', result.error.message); warn('[env] Konnte .env nicht laden:', result.error.message);
console.warn('[env] Fehler-Details:', result.error); warn('[env] Fehler-Details:', result.error);
} else { } else {
console.log('[env] .env erfolgreich geladen von:', envPath, usingProduction ? '(production)' : '(local)'); log('[env] .env erfolgreich geladen von:', envPath, usingProduction ? '(production)' : '(local)');
console.log('[env] Geladene Variablen:', Object.keys(result.parsed || {})); log('[env] Geladene Variablen:', Object.keys(result.parsed || {}));
} }
} catch (err) { } catch (err) {
// Sollte nicht passieren, aber falls dotenv intern eine Exception wirft (z.B. EACCES), fange sie ab // 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); warn('[env] Unerwarteter Fehler beim Laden der .env:', err && err.message);
console.warn('[env] Stack:', err && err.stack); warn('[env] Stack:', err && err.stack);
if (envPath !== localEnvPath && fs.existsSync(localEnvPath)) { if (envPath !== localEnvPath && fs.existsSync(localEnvPath)) {
console.log('[env] Versuche stattdessen lokale .env:', localEnvPath); log('[env] Versuche stattdessen lokale .env:', localEnvPath);
try { try {
result = dotenv.config({ path: localEnvPath }); result = dotenv.config({ path: localEnvPath, quiet: dotenvQuiet });
if (!result.error) { if (!result.error) {
console.log('[env] Lokale .env erfolgreich geladen von:', localEnvPath); log('[env] Lokale .env erfolgreich geladen von:', localEnvPath);
} }
} catch (err2) { } 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 // 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-Konfiguration:');
console.log('[env] REDIS_HOST:', process.env.REDIS_HOST); console.log('[env] REDIS_HOST:', process.env.REDIS_HOST);
console.log('[env] REDIS_PORT:', process.env.REDIS_PORT); 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_PASSWORD:', process.env.REDIS_PASSWORD ? '***gesetzt***' : 'NICHT GESETZT');
console.log('[env] REDIS_URL:', process.env.REDIS_URL); console.log('[env] REDIS_URL:', process.env.REDIS_URL);
}
if (!process.env.SECRET_KEY) { if (!process.env.SECRET_KEY) {
console.warn('[env] SECRET_KEY nicht gesetzt in .env'); warn('[env] SECRET_KEY nicht gesetzt in .env');
} }
export {}; export {};

21
backend/env.example Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -22,8 +22,9 @@ TownProductWorth.init({
timestamps: false, timestamps: false,
underscored: true, underscored: true,
hooks: { hooks: {
// Neu: 5585 %; ältere Einträge können 4060 % sein (Preislogik im Service deckelt nach unten ab).
beforeCreate: (worthPercent) => { beforeCreate: (worthPercent) => {
worthPercent.worthPercent = Math.floor(Math.random() * 20) + 40; worthPercent.worthPercent = Math.floor(Math.random() * 31) + 55;
} }
} }
}); });

View File

@@ -12,6 +12,8 @@
"sync-tables": "node sync-tables-only.js", "sync-tables": "node sync-tables-only.js",
"check-connections": "node check-connections.js", "check-connections": "node check-connections.js",
"cleanup-connections": "node cleanup-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" "test": "echo \"Error: no test specified\" && exit 1"
}, },
"keywords": [], "keywords": [],

View File

@@ -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 (13650, 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);
}

View File

@@ -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=<lokaler Forward, z. B. 60000> — 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);
}

View File

@@ -97,17 +97,40 @@ async function getBranchOrFail(userId, branchId) {
return branch; 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 4060 %, 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) { function calcSellPrice(product, knowledgeFactor = 0) {
const max = product.sellCost; const max = product.sellCost;
const min = max * 0.6; const min = max * KNOWLEDGE_PRICE_FLOOR;
return min + (max - min) * (knowledgeFactor / 100); return min + (max - min) * (knowledgeFactor / 100);
} }
/** Synchrone Preisberechnung, wenn worthPercent bereits bekannt ist (kein DB-Zugriff). */ /** Synchrone Preisberechnung, wenn worthPercent bereits bekannt ist (kein DB-Zugriff). */
function calcRegionalSellPriceSync(product, knowledgeFactor, worthPercent) { function calcRegionalSellPriceSync(product, knowledgeFactor, worthPercent) {
if (product.sellCost === null || product.sellCost === undefined) return null; if (product.sellCost === null || product.sellCost === undefined) return null;
const basePrice = product.sellCost * (worthPercent / 100); const w = effectiveWorthPercent(worthPercent);
const min = basePrice * 0.6; const basePrice = product.sellCost * (w / 100);
const min = basePrice * KNOWLEDGE_PRICE_FLOOR;
const max = basePrice; const max = basePrice;
return min + (max - min) * (knowledgeFactor / 100); return min + (max - min) * (knowledgeFactor / 100);
} }
@@ -230,11 +253,10 @@ async function calcRegionalSellPrice(product, knowledgeFactor, regionId, worthPe
throw new Error(`Product ${product.id} has no sellCost defined`); throw new Error(`Product ${product.id} has no sellCost defined`);
} }
// Basispreis basierend auf regionalem worthPercent const w = effectiveWorthPercent(worthPercent);
const basePrice = product.sellCost * (worthPercent / 100); const basePrice = product.sellCost * (w / 100);
// Dann Knowledge-Faktor anwenden const min = basePrice * KNOWLEDGE_PRICE_FLOOR;
const min = basePrice * 0.6;
const max = basePrice; const max = basePrice;
return min + (max - min) * (knowledgeFactor / 100); return min + (max - min) * (knowledgeFactor / 100);
} }
@@ -2031,7 +2053,7 @@ class FalukantService extends BaseService {
} }
if (!p) throw new Error('Product not found'); if (!p) throw new Error('Product not found');
quantity = Math.min(100, quantity); 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'); if (u.money < cost) throw new Error('notenoughmoney');
const r = await updateFalukantUserMoney(u.id, -cost, 'Production cost', u.id); const r = await updateFalukantUserMoney(u.id, -cost, 'Production cost', u.id);
if (!r.success) throw new Error('Failed to update money'); if (!r.success) throw new Error('Failed to update money');

View File

@@ -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';

View File

@@ -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:<productId>
-- Sonst: new nobility title; health.<aktivität>; 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 = <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;

View File

@@ -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__;

View File

@@ -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;

View File

@@ -250,7 +250,8 @@ async function initializeFalukantProducts() {
const baseProducts = [ const baseProducts = [
{ labelTr: 'wheat', category: 1, productionTime: 2, sellCost: 7 }, { labelTr: 'wheat', category: 1, productionTime: 2, sellCost: 7 },
{ labelTr: 'grain', 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: 'fish', category: 1, productionTime: 2, sellCost: 7 },
{ labelTr: 'meat', category: 1, productionTime: 2, sellCost: 7 }, { labelTr: 'meat', category: 1, productionTime: 2, sellCost: 7 },
{ labelTr: 'leather', category: 1, productionTime: 2, sellCost: 7 }, { labelTr: 'leather', category: 1, productionTime: 2, sellCost: 7 },

View File

@@ -1,7 +1,18 @@
import { Sequelize, DataTypes } from 'sequelize'; import { Sequelize, DataTypes } from 'sequelize';
import dotenv from 'dotenv'; 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) // Optionales Performance-Logging (aktivierbar per ENV)
// - SQL_BENCHMARK=1: Sequelize liefert Query-Timing (ms) an logger // - 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 dbUser = process.env.DB_USER;
const dbPass = process.env.DB_PASS || ''; // Fallback auf leeren String const dbPass = process.env.DB_PASS || ''; // Fallback auf leeren String
const dbHost = process.env.DB_HOST; const dbHost = process.env.DB_HOST;
const dbPort = Number.parseInt(process.env.DB_PORT || '5432', 10);
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('Database configuration:');
console.log('DB_NAME:', dbName); console.log('DB_NAME:', dbName);
console.log('DB_USER:', dbUser); console.log('DB_USER:', dbUser);
console.log('DB_PASS:', dbPass ? '[SET]' : '[NOT SET]'); console.log('DB_PASS:', dbPass ? '[SET]' : '[NOT SET]');
console.log('DB_HOST:', dbHost); console.log('DB_HOST:', dbHost);
console.log('DB_PORT:', dbPort);
console.log('DB_SSL:', useSsl ? 'on' : 'off');
}
if (!dbName || !dbUser || !dbHost) { if (!dbName || !dbUser || !dbHost) {
throw new Error('Missing required database environment variables: DB_NAME, DB_USER, or DB_HOST'); 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 poolIdle = Number.parseInt(process.env.DB_POOL_IDLE || '10000', 10);
const poolEvict = Number.parseInt(process.env.DB_POOL_EVICT || '1000', 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, { const sequelize = new Sequelize(dbName, dbUser, dbPass, {
host: dbHost, host: dbHost,
port: dbPort,
dialect: 'postgres', dialect: 'postgres',
define: { define: {
timestamps: false, timestamps: false,
@@ -61,9 +94,7 @@ const sequelize = new Sequelize(dbName, dbUser, dbPass, {
evict: poolEvict, // Intervall (ms) zum Prüfen auf idle Verbindungen evict: poolEvict, // Intervall (ms) zum Prüfen auf idle Verbindungen
handleDisconnects: true // Automatisches Reconnect bei Verbindungsverlust handleDisconnects: true // Automatisches Reconnect bei Verbindungsverlust
}, },
dialectOptions: { dialectOptions,
connectTimeout: 30000 // Timeout für Verbindungsaufbau (30 Sekunden)
},
retry: { retry: {
max: 3, // Maximale Anzahl von Wiederholungsversuchen max: 3, // Maximale Anzahl von Wiederholungsversuchen
match: [ match: [