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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,6 +7,7 @@ node_modules
|
||||
node_modules/*
|
||||
**/package-lock.json
|
||||
backend/.env
|
||||
backend/.env.local
|
||||
backend/images
|
||||
backend/images/*
|
||||
backend/node_modules
|
||||
|
||||
@@ -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
|
||||
// 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 {};
|
||||
|
||||
21
backend/env.example
Normal file
21
backend/env.example
Normal 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
|
||||
5
backend/env.local.example
Normal file
5
backend/env.local.example
Normal 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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
142
backend/scripts/falukant-moneyflow-report.mjs
Normal file
142
backend/scripts/falukant-moneyflow-report.mjs
Normal 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 (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);
|
||||
}
|
||||
103
backend/scripts/falukant-town-product-worth-stats.mjs
Normal file
103
backend/scripts/falukant-town-product-worth-stats.mjs
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -230,11 +253,10 @@ async function calcRegionalSellPrice(product, knowledgeFactor, regionId, worthPe
|
||||
throw new Error(`Product ${product.id} has no sellCost defined`);
|
||||
}
|
||||
|
||||
// Basispreis basierend auf regionalem worthPercent
|
||||
const basePrice = product.sellCost * (worthPercent / 100);
|
||||
const w = effectiveWorthPercent(worthPercent);
|
||||
const basePrice = product.sellCost * (w / 100);
|
||||
|
||||
// Dann Knowledge-Faktor anwenden
|
||||
const min = basePrice * 0.6;
|
||||
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');
|
||||
|
||||
38
backend/sql/balance_carrot_product.sql
Normal file
38
backend/sql/balance_carrot_product.sql
Normal 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';
|
||||
31
backend/sql/diagnostics/falukant_moneyflow_by_activity.sql
Normal file
31
backend/sql/diagnostics/falukant_moneyflow_by_activity.sql
Normal 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;
|
||||
14
backend/sql/diagnostics/falukant_moneyflow_window_totals.sql
Normal file
14
backend/sql/diagnostics/falukant_moneyflow_window_totals.sql
Normal 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__;
|
||||
@@ -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;
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
|
||||
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: [
|
||||
|
||||
Reference in New Issue
Block a user