Compare commits

...

23 Commits

Author SHA1 Message Date
Torsten Schulz (local)
38f23cc6ae Enhance getFalukantUserOrFail and createParty methods in FalukantService to support transaction options
- Updated getFalukantUserOrFail to accept an options parameter for transaction handling.
- Refactored createParty to utilize transaction support, ensuring atomic operations for party creation and related financial transactions.
- Improved error handling for party creation, including checks for existing parties within a 24-hour window and validation of selected options.
2025-12-20 23:30:10 +01:00
Torsten Schulz (local)
6cf8fa8a9c Add reputation attribute to FalukantCharacter model and update related services and views
- Introduced a new 'reputation' attribute in the FalukantCharacter model with a default value and validation.
- Updated FalukantService to include 'reputation' in character attributes for API responses.
- Enhanced ReputationView component to display current reputation and load it from the API.
- Added translations for reputation in both German and English locales.
2025-12-20 23:00:31 +01:00
Torsten Schulz (local)
f9ea4715d7 Refactor BranchView component to replace JavaScript alerts with a message dialog for success and error notifications. This improves user experience by providing a more integrated feedback mechanism within the UI. 2025-12-20 22:14:39 +01:00
Torsten Schulz (local)
b34b374f76 Refactor sellAllProducts method in FalukantService to ensure atomic transactions for selling products, updating inventory, and handling financial transactions. Implement batch processing for sell items and enhance error handling for inventory deletions. Update updateFalukantUserMoney function to support transactions, improving consistency and reliability in financial operations. 2025-12-20 22:04:29 +01:00
Torsten Schulz (local)
83d1168f26 Refactor speedLabel method in SaleSection component to move it from computed properties to methods for better compatibility with Vue3. This change ensures the function is callable and maintains its intended functionality. 2025-12-20 21:32:53 +01:00
Torsten Schulz (local)
91009f52cd Refactor SaleSection component to utilize direct property assignment for betterPrices, enhancing reactivity in Vue3. Update inventory mapping to ensure betterPrices is always an array. 2025-12-20 21:28:01 +01:00
Torsten Schulz (local)
c6dfca7052 Enhance SaleSection component to improve UI responsiveness during sales. Reset selling state immediately after sell actions and update inventory handling to ensure user feedback is timely and accurate. 2025-12-20 21:09:10 +01:00
Torsten Schulz (local)
aaeaeeed24 Add request and SQL performance logging features to backend
- Implement request timing middleware in app.js to log slow requests and all requests based on environment variables.
- Enhance sequelize.js with optional SQL query timing and logging capabilities, allowing for performance monitoring of database queries.
2025-12-20 16:35:30 +01:00
Torsten Schulz (local)
c5804f764c Optimize getInventory method in FalukantService by replacing nested includes with a single SQL query for improved performance. Add error handling for invalid branchId input. 2025-12-20 16:13:33 +01:00
Torsten Schulz (local)
fbe0d1bcd1 Add error handling for missing branches in sell batch processing in FalukantService. Ensure that missing branch IDs trigger an error to prevent accounting mismatches. 2025-12-20 16:01:18 +01:00
Torsten Schulz (local)
2fb440f033 Implement synchronous price calculation for batch operations in FalukantService, optimizing performance by reducing database queries. Update inventory handling to batch delete items and enhance revenue calculations. Fix translation formatting in German locale for sellAllSuccess message. 2025-12-20 15:37:16 +01:00
Torsten Schulz (local)
a8a136a9ce Enhance SaleSection component to manage selling state with improved user feedback. Disable buttons during selling, show status messages for sellAll actions, and update translations for new statuses. 2025-12-20 15:35:20 +01:00
Torsten Schulz (local)
fcbb903839 Backend error fixed 2025-12-20 15:06:43 +01:00
Torsten Schulz (local)
ac45a2ba26 Refactor SQL joins in calcRegionalSellPrice function of FalukantService to use updated region type table for improved clarity and consistency in tax calculations. 2025-12-20 15:03:03 +01:00
Torsten Schulz (local)
afe15dd4f5 Refactor tax calculation in calcRegionalSellPrice function of FalukantService to convert exemptTypes Set to PostgreSQL array string for improved query performance and clarity. 2025-12-20 14:54:32 +01:00
Torsten Schulz (local)
e3df88bea0 Enhance getCumulativeTaxPercentWithExemptions function in FalukantService to first retrieve the character associated with the userId, ensuring accurate filtering of political offices by characterId for regional tax calculations. 2025-12-20 11:09:03 +01:00
Torsten Schulz (local)
c69a414f78 Fix cumulative tax calculation in FalukantService by using falukantUser.id instead of user.id for accurate regional tax assessments. 2025-12-20 11:04:21 +01:00
Torsten Schulz (local)
d08022ab94 Merge branch 'main' of ssh://tsschulz.de:2222/torsten/yourpart3 2025-12-20 10:54:58 +01:00
Torsten Schulz (local)
66e6fab663 Refactor getCumulativeTaxPercentWithExemptions function in falukantService.js to filter political offices by userId through the FalukantCharacter model. Update query structure to enhance clarity and ensure accurate data retrieval for regional tax calculations. 2025-12-20 10:48:56 +01:00
4da572822e Merge pull request 'Update dependency nodemon to v3.1.11' (#3) from renovate/nodemon-3.x-lockfile into main
Reviewed-on: #3
2025-12-19 16:16:21 +01:00
ee23bb3ba3 Merge pull request 'Replace dependency npm-run-all with npm-run-all2 ^5.0.0' (#2) from renovate/npm-run-all-replacement into main
Reviewed-on: #2
2025-12-19 16:16:12 +01:00
d002e340dd Update dependency nodemon to v3.1.11 2025-12-19 16:14:33 +01:00
0e1d87ddab Replace dependency npm-run-all with npm-run-all2 ^5.0.0 2025-12-19 16:14:29 +01:00
14 changed files with 1662 additions and 1550 deletions

230
PERFORMANCE_ANALYSIS.md Normal file
View File

@@ -0,0 +1,230 @@
# Backend Performance-Analyse: Sell-Funktionen
## Identifizierte Performance-Probleme
### 1. **N+1 Query Problem in `sellAllProducts()`**
**Problem:**
Die Funktion `sellAllProducts()` macht für jedes Inventory-Item mehrere separate Datenbankabfragen:
1. **Erste Schleife (Zeile 1702-1711):**
- `calcRegionalSellPrice()` → macht `TownProductWorth.findOne()` für jedes Item
- `getCumulativeTaxPercentWithExemptions()` → macht mehrere Queries pro Item:
- `FalukantCharacter.findOne()`
- `PoliticalOffice.findAll()` mit Includes
- Rekursive SQL-Query für Steuerberechnung
- `addSellItem()` → macht `Branch.findOne()` und `DaySell.findOne()`/`create()` für jedes Item
2. **Zweite Schleife (Zeile 1714-1724):**
- `RegionData.findOne()` für jedes Item
- `getCumulativeTaxPercent()` → rekursive SQL-Query für jedes Item
- `calcRegionalSellPrice()` → erneut `TownProductWorth.findOne()` für jedes Item
**Beispiel:** Bei 10 Items werden gemacht:
- 10x `TownProductWorth.findOne()` (2x pro Item)
- 10x `RegionData.findOne()`
- 10x `getCumulativeTaxPercentWithExemptions()` (mit mehreren Queries)
- 10x `getCumulativeTaxPercent()` (rekursive SQL)
- 10x `addSellItem()` (mit 2 Queries pro Item)
- = **~70+ Datenbankabfragen für 10 Items**
### 2. **Ineffiziente `addSellItem()` Implementierung**
**Problem:**
- Wird für jedes Item einzeln aufgerufen
- Macht `Branch.findOne()` für jedes Item (könnte gecacht werden)
- `DaySell.findOne()` und `create()`/`update()` für jedes Item
**Lösung:** Batch-Operation implementieren, die alle DaySell Einträge auf einmal verarbeitet.
### 3. **Doppelte Berechnungen in `sellAllProducts()`**
**Problem:**
- Preis wird zweimal berechnet (Zeile 1705 und 1718)
- Steuer wird zweimal berechnet (Zeile 1706 und 1717)
- `calcRegionalSellPrice()` wird zweimal aufgerufen mit denselben Parametern
### 4. **Fehlende Indizes**
**Potenzielle fehlende Indizes:**
- `falukant_data.town_product_worth(product_id, region_id)` - sollte unique sein
- `falukant_data.inventory(stock_id, product_id, quality)` - für schnelle Lookups
- `falukant_data.knowledge(character_id, product_id)` - für Knowledge-Lookups
- `falukant_data.political_office(character_id)` - für Steuerbefreiungen
### 5. **Ineffiziente `getCumulativeTaxPercentWithExemptions()`**
**Problem:**
- Lädt alle PoliticalOffices jedes Mal neu, auch wenn sich nichts geändert hat
- Macht komplexe rekursive SQL-Query für jedes Item separat
- Könnte gecacht werden (z.B. pro User+Region Kombination)
## Empfohlene Optimierungen
### 1. **Batch-Loading für `sellAllProducts()`**
```javascript
async sellAllProducts(hashedUserId, branchId) {
// ... existing code ...
// Batch-Load alle benötigten Daten VOR den Schleifen
const regionIds = [...new Set(inventory.map(item => item.stock.branch.regionId))];
const productIds = [...new Set(inventory.map(item => item.productType.id))];
// 1. Lade alle TownProductWorth Einträge auf einmal
const townWorths = await TownProductWorth.findAll({
where: {
productId: { [Op.in]: productIds },
regionId: { [Op.in]: regionIds }
}
});
const worthMap = new Map();
townWorths.forEach(tw => {
worthMap.set(`${tw.productId}-${tw.regionId}`, tw.worthPercent);
});
// 2. Lade alle RegionData auf einmal
const regions = await RegionData.findAll({
where: { id: { [Op.in]: regionIds } }
});
const regionMap = new Map(regions.map(r => [r.id, r]));
// 3. Berechne Steuern für alle Regionen auf einmal
const taxMap = new Map();
for (const regionId of regionIds) {
const tax = await getCumulativeTaxPercentWithExemptions(falukantUser.id, regionId);
taxMap.set(regionId, tax);
}
// 4. Berechne Preise und Steuern in einer Schleife
const sellItems = [];
for (const item of inventory) {
const regionId = item.stock.branch.regionId;
const worthPercent = worthMap.get(`${item.productType.id}-${regionId}`) || 50;
const knowledgeVal = item.productType.knowledges[0]?.knowledge || 0;
const pricePerUnit = calcRegionalSellPrice(item.productType, knowledgeVal, regionId, worthPercent);
const cumulativeTax = taxMap.get(regionId);
// ... rest of calculation ...
sellItems.push({ branchId: item.stock.branch.id, productId: item.productType.id, quantity: item.quantity });
}
// 5. Batch-Update DaySell Einträge
await this.addSellItemsBatch(sellItems);
// ... rest of code ...
}
```
### 2. **Batch-Operation für `addSellItem()`**
```javascript
async addSellItemsBatch(sellItems) {
// Gruppiere nach (regionId, productId, sellerId)
const grouped = new Map();
for (const item of sellItems) {
const branch = await Branch.findByPk(item.branchId);
if (!branch) continue;
const key = `${branch.regionId}-${item.productId}-${item.sellerId}`;
if (!grouped.has(key)) {
grouped.set(key, {
regionId: branch.regionId,
productId: item.productId,
sellerId: item.sellerId,
quantity: 0
});
}
grouped.get(key).quantity += item.quantity;
}
// Batch-Update oder Create
for (const [key, data] of grouped) {
const [daySell, created] = await DaySell.findOrCreate({
where: {
regionId: data.regionId,
productId: data.productId,
sellerId: data.sellerId
},
defaults: { quantity: data.quantity }
});
if (!created) {
daySell.quantity += data.quantity;
await daySell.save();
}
}
}
```
### 3. **Caching für `getCumulativeTaxPercentWithExemptions()`**
```javascript
// Cache für Steuerberechnungen (z.B. 5 Minuten)
const taxCache = new Map();
const CACHE_TTL = 5 * 60 * 1000; // 5 Minuten
async function getCumulativeTaxPercentWithExemptions(userId, regionId) {
const cacheKey = `${userId}-${regionId}`;
const cached = taxCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.value;
}
// ... existing calculation ...
taxCache.set(cacheKey, { value: tax, timestamp: Date.now() });
return tax;
}
```
### 4. **Optimierte `calcRegionalSellPrice()`**
```javascript
async function calcRegionalSellPrice(product, knowledgeFactor, regionId, worthPercent = null) {
// Wenn worthPercent nicht übergeben wurde UND wir es nicht aus dem Cache haben,
// dann hole es aus der DB
if (worthPercent === null) {
const townWorth = await TownProductWorth.findOne({
where: { productId: product.id, regionId: regionId }
});
worthPercent = townWorth?.worthPercent || 50;
}
// ... rest of calculation ...
}
```
### 5. **Datenbank-Indizes hinzufügen**
```sql
-- Index für town_product_worth (sollte unique sein)
CREATE UNIQUE INDEX IF NOT EXISTS idx_town_product_worth_product_region
ON falukant_data.town_product_worth(product_id, region_id);
-- Index für inventory Lookups
CREATE INDEX IF NOT EXISTS idx_inventory_stock_product_quality
ON falukant_data.inventory(stock_id, product_id, quality);
-- Index für knowledge Lookups
CREATE INDEX IF NOT EXISTS idx_knowledge_character_product
ON falukant_data.knowledge(character_id, product_id);
-- Index für political_office Lookups
CREATE INDEX IF NOT EXISTS idx_political_office_character
ON falukant_data.political_office(character_id);
```
## Geschätzter Performance-Gewinn
- **Vorher:** ~70+ Queries für 10 Items
- **Nachher:** ~15-20 Queries für 10 Items (Batch-Loading + Caching)
- **Geschätzte Verbesserung:** 70-80% weniger Datenbankabfragen
## Priorität
1. **Hoch:** Batch-Loading für `sellAllProducts()` (größter Impact)
2. **Hoch:** Batch-Operation für `addSellItem()`
3. **Mittel:** Caching für Steuerberechnungen
4. **Mittel:** Datenbank-Indizes
5. **Niedrig:** Doppelte Berechnungen entfernen

601
SELL_OVERVIEW.md Normal file
View File

@@ -0,0 +1,601 @@
# Übersicht: Sell-Funktionen und verwendete Models/Tabellen
## Sell-Funktionen in `falukantService.js`
### 1. `sellProduct(hashedUserId, branchId, productId, quality, quantity)`
Verkauft ein einzelnes Produkt mit bestimmter Qualität.
**Ablauf:**
1. Lädt User, Branch, Character, Stock
2. Lädt Inventory mit ProductType und Knowledge
3. Berechnet Preis pro Einheit mit `calcRegionalSellPrice()`
4. Berechnet kumulative Steuer mit politischen Befreiungen
5. Passt Preis an (Inflation basierend auf Steuer)
6. Berechnet Revenue, Tax, Net
7. Aktualisiert Geld für Verkäufer und Treasury
8. Entfernt verkaufte Items aus Inventory
9. Erstellt/aktualisiert DaySell Eintrag
10. Sendet Socket-Notifications
**Verwendete Models/Tabellen:**
- `FalukantUser` (`falukant_data.falukant_user`)
- `Branch` (`falukant_data.branch`)
- `FalukantCharacter` (`falukant_data.character`)
- `FalukantStock` (`falukant_data.stock`)
- `Inventory` (`falukant_data.inventory`)
- `ProductType` (`falukant_type.product`)
- `Knowledge` (`falukant_data.knowledge`)
- `TownProductWorth` (`falukant_data.town_product_worth`)
- `RegionData` (`falukant_data.region`)
- `RegionType` (`falukant_type.region`)
- `PoliticalOffice` (`falukant_data.political_office`)
- `PoliticalOfficeType` (`falukant_type.political_office_type`)
- `DaySell` (`falukant_log.day_sell`)
- `MoneyFlow` (via `updateFalukantUserMoney`)
### 2. `sellAllProducts(hashedUserId, branchId)`
Verkauft alle Produkte eines Branches.
**Ablauf:**
1. Lädt User, Branch mit Stocks
2. Lädt alle Inventory Items mit ProductType, Knowledge, Stock, Branch
3. Für jedes Item:
- Berechnet Preis pro Einheit
- Berechnet kumulative Steuer
- Passt Preis an
- Erstellt/aktualisiert DaySell Eintrag
4. Berechnet Gesamt-Tax pro Region
5. Aktualisiert Geld für Verkäufer und Treasury
6. Löscht alle Inventory Items
7. Sendet Socket-Notifications
**Verwendete Models/Tabellen:**
- `FalukantUser` (`falukant_data.falukant_user`)
- `Branch` (`falukant_data.branch`)
- `FalukantStock` (`falukant_data.stock`)
- `FalukantStockType` (`falukant_type.stock`)
- `FalukantCharacter` (`falukant_data.character`)
- `Inventory` (`falukant_data.inventory`)
- `ProductType` (`falukant_type.product`)
- `Knowledge` (`falukant_data.knowledge`)
- `TownProductWorth` (`falukant_data.town_product_worth`)
- `RegionData` (`falukant_data.region`)
- `RegionType` (`falukant_type.region`)
- `PoliticalOffice` (`falukant_data.political_office`)
- `PoliticalOfficeType` (`falukant_type.political_office_type`)
- `DaySell` (`falukant_log.day_sell`)
- `MoneyFlow` (via `updateFalukantUserMoney`)
### 3. `addSellItem(branchId, userId, productId, quantity)`
Erstellt oder aktualisiert einen DaySell Eintrag für einen Verkauf.
**Ablauf:**
1. Lädt Branch
2. Sucht nach existierendem DaySell Eintrag
3. Erstellt neuen oder aktualisiert existierenden Eintrag
**Verwendete Models/Tabellen:**
- `Branch` (`falukant_data.branch`)
- `DaySell` (`falukant_log.day_sell`)
## Hilfsfunktionen
### `calcRegionalSellPrice(product, knowledgeFactor, regionId, worthPercent = null)`
Berechnet den Verkaufspreis eines Produkts basierend auf:
- Basispreis (`product.sellCost`)
- Regionalem Worth-Percent (`town_product_worth.worth_percent`)
- Knowledge-Faktor (0-100)
**Verwendete Models/Tabellen:**
- `ProductType` (`falukant_type.product`)
- `TownProductWorth` (`falukant_data.town_product_worth`)
### `getCumulativeTaxPercent(regionId)`
Berechnet die kumulative Steuer für eine Region und alle Vorfahren (rekursiv).
**SQL Query:**
```sql
WITH RECURSIVE ancestors AS (
SELECT id, parent_id, tax_percent
FROM falukant_data.region r
WHERE id = :id
UNION ALL
SELECT reg.id, reg.parent_id, reg.tax_percent
FROM falukant_data.region reg
JOIN ancestors a ON reg.id = a.parent_id
)
SELECT COALESCE(SUM(tax_percent),0) AS total FROM ancestors;
```
**Verwendete Tabellen:**
- `falukant_data.region`
### `getCumulativeTaxPercentWithExemptions(userId, regionId)`
Berechnet die kumulative Steuer mit politischen Befreiungen.
**Ablauf:**
1. Lädt Character des Users
2. Lädt alle PoliticalOffices des Characters
3. Bestimmt befreite Region-Typen basierend auf Ämtern
4. Berechnet kumulative Steuer, aber schließt befreite Region-Typen aus
**SQL Query:**
```sql
WITH RECURSIVE ancestors AS (
SELECT r.id, r.parent_id, r.tax_percent, rt.label_tr as region_type
FROM falukant_data.region r
JOIN falukant_type.region rt ON rt.id = r.region_type_id
WHERE r.id = :id
UNION ALL
SELECT reg.id, reg.parent_id, reg.tax_percent, rt2.label_tr
FROM falukant_data.region reg
JOIN falukant_type.region rt2 ON rt2.id = reg.region_type_id
JOIN ancestors a ON reg.id = a.parent_id
)
SELECT COALESCE(SUM(CASE WHEN ARRAY[...] && ARRAY[region_type]::text[] THEN 0 ELSE tax_percent END),0) AS total FROM ancestors;
```
**Verwendete Models/Tabellen:**
- `FalukantCharacter` (`falukant_data.character`)
- `PoliticalOffice` (`falukant_data.political_office`)
- `PoliticalOfficeType` (`falukant_type.political_office_type`)
- `RegionData` (`falukant_data.region`)
- `RegionType` (`falukant_type.region`)
**Politische Steuerbefreiungen:**
```javascript
const POLITICAL_TAX_EXEMPTIONS = {
'council': ['city'],
'taxman': ['city', 'county'],
'treasurerer': ['city', 'county', 'shire'],
'super-state-administrator': ['city', 'county', 'shire', 'markgrave', 'duchy'],
'chancellor': ['city','county','shire','markgrave','duchy'] // = alle Typen
};
```
## Model-Definitionen
### Inventory (`falukant_data.inventory`)
```javascript
// backend/models/falukant/data/inventory.js
- id
- stockId (FK zu falukant_data.stock)
- productId (FK zu falukant_type.product)
- quantity
- quality
- producedAt
```
### DaySell (`falukant_log.day_sell`)
```javascript
// backend/models/falukant/log/daysell.js
- id
- regionId (FK zu falukant_data.region)
- productId (FK zu falukant_type.product)
- sellerId (FK zu falukant_data.falukant_user)
- quantity
- createdAt
- updatedAt
```
### TownProductWorth (`falukant_data.town_product_worth`)
```javascript
// backend/models/falukant/data/town_product_worth.js
- id
- productId (FK zu falukant_type.product)
- regionId (FK zu falukant_data.region)
- worthPercent (0-100)
```
### Knowledge (`falukant_data.knowledge`)
```javascript
// backend/models/falukant/data/product_knowledge.js
- id
- productId (FK zu falukant_type.product)
- characterId (FK zu falukant_data.character)
- knowledge (0-99)
```
## Wichtige SQL-Queries
### 1. Inventory mit ProductType und Knowledge laden
```javascript
Inventory.findAll({
where: { quality },
include: [
{
model: ProductType,
as: 'productType',
required: true,
where: { id: productId },
include: [
{
model: Knowledge,
as: 'knowledges',
required: false,
where: { characterId: character.id }
}
]
}
]
})
```
### 2. Kumulative Steuer mit Befreiungen berechnen
Siehe `getCumulativeTaxPercentWithExemptions()` oben.
## Preisberechnung
### Formel für `calcRegionalSellPrice`:
1. Basispreis = `product.sellCost * (worthPercent / 100)`
2. Min = `basePrice * 0.6`
3. Max = `basePrice`
4. Preis = `min + (max - min) * (knowledgeFactor / 100)`
### Steueranpassung:
1. `inflationFactor = cumulativeTax >= 100 ? 1 : (1 / (1 - cumulativeTax / 100))`
2. `adjustedPricePerUnit = pricePerUnit * inflationFactor`
3. `revenue = quantity * adjustedPricePerUnit`
4. `taxValue = revenue * cumulativeTax / 100`
5. `net = revenue - taxValue`
## Vollständige Code-Snippets
### `calcRegionalSellPrice()`
```javascript
async function calcRegionalSellPrice(product, knowledgeFactor, regionId, worthPercent = null) {
if (worthPercent === null) {
const townWorth = await TownProductWorth.findOne({
where: { productId: product.id, regionId: regionId }
});
worthPercent = townWorth?.worthPercent || 50; // Default 50% wenn nicht gefunden
}
// Basispreis basierend auf regionalem worthPercent
const basePrice = product.sellCost * (worthPercent / 100);
// Dann Knowledge-Faktor anwenden
const min = basePrice * 0.6;
const max = basePrice;
return min + (max - min) * (knowledgeFactor / 100);
}
```
### `getCumulativeTaxPercent()`
```javascript
async function getCumulativeTaxPercent(regionId) {
if (!regionId) return 0;
const rows = await sequelize.query(
`WITH RECURSIVE ancestors AS (
SELECT id, parent_id, tax_percent
FROM falukant_data.region r
WHERE id = :id
UNION ALL
SELECT reg.id, reg.parent_id, reg.tax_percent
FROM falukant_data.region reg
JOIN ancestors a ON reg.id = a.parent_id
)
SELECT COALESCE(SUM(tax_percent),0) AS total FROM ancestors;`,
{
replacements: { id: regionId },
type: sequelize.QueryTypes.SELECT
}
);
const val = rows?.[0]?.total ?? 0;
return parseFloat(val) || 0;
}
```
### `getCumulativeTaxPercentWithExemptions()` (vereinfacht)
```javascript
async function getCumulativeTaxPercentWithExemptions(userId, regionId) {
if (!regionId) return 0;
// Character finden
const character = await FalukantCharacter.findOne({
where: { userId },
attributes: ['id']
});
if (!character) return 0;
// Politische Ämter laden
const offices = await PoliticalOffice.findAll({
where: { characterId: character.id },
include: [
{ model: PoliticalOfficeType, as: 'type', attributes: ['name'] },
{
model: RegionData,
as: 'region',
include: [{
model: RegionType,
as: 'regionType',
attributes: ['labelTr']
}]
}
]
});
// Befreite Region-Typen bestimmen
const exemptTypes = new Set();
let hasChancellor = false;
for (const o of offices) {
const name = o.type?.name;
if (!name) continue;
if (name === 'chancellor') { hasChancellor = true; break; }
const allowed = POLITICAL_TAX_EXEMPTIONS[name];
if (allowed && Array.isArray(allowed)) {
for (const t of allowed) exemptTypes.add(t);
}
}
if (hasChancellor) return 0;
// SQL Query mit Befreiungen
const exemptTypesArray = Array.from(exemptTypes);
const exemptTypesString = exemptTypesArray.length > 0
? `ARRAY[${exemptTypesArray.map(t => `'${t.replace(/'/g, "''")}'`).join(',')}]`
: `ARRAY[]::text[]`;
const rows = await sequelize.query(
`WITH RECURSIVE ancestors AS (
SELECT r.id, r.parent_id, r.tax_percent, rt.label_tr as region_type
FROM falukant_data.region r
JOIN falukant_type.region rt ON rt.id = r.region_type_id
WHERE r.id = :id
UNION ALL
SELECT reg.id, reg.parent_id, reg.tax_percent, rt2.label_tr
FROM falukant_data.region reg
JOIN falukant_type.region rt2 ON rt2.id = reg.region_type_id
JOIN ancestors a ON reg.id = a.parent_id
)
SELECT COALESCE(SUM(CASE WHEN ${exemptTypesString} && ARRAY[region_type]::text[] THEN 0 ELSE tax_percent END),0) AS total FROM ancestors;`,
{
replacements: { id: regionId },
type: sequelize.QueryTypes.SELECT
}
);
const val = rows?.[0]?.total ?? 0;
return parseFloat(val) || 0;
}
```
### `sellProduct()` (Kern-Logik)
```javascript
async sellProduct(hashedUserId, branchId, productId, quality, quantity) {
const user = await getFalukantUserOrFail(hashedUserId);
const branch = await getBranchOrFail(user.id, branchId);
const character = await FalukantCharacter.findOne({ where: { userId: user.id } });
if (!character) throw new Error('No character found for user');
const stock = await FalukantStock.findOne({ where: { branchId: branch.id } });
if (!stock) throw new Error('Stock not found');
// Inventory laden
const inventory = await Inventory.findAll({
where: { quality },
include: [
{
model: ProductType,
as: 'productType',
required: true,
where: { id: productId },
include: [
{
model: Knowledge,
as: 'knowledges',
required: false,
where: { characterId: character.id }
}
]
}
]
});
if (!inventory.length) throw new Error('No inventory found');
const available = inventory.reduce((sum, i) => sum + i.quantity, 0);
if (available < quantity) throw new Error('Not enough inventory available');
const item = inventory[0].productType;
const knowledgeVal = item.knowledges?.[0]?.knowledge || 0;
const pricePerUnit = await calcRegionalSellPrice(item, knowledgeVal, branch.regionId);
// Steuer berechnen
const cumulativeTax = await getCumulativeTaxPercentWithExemptions(user.id, branch.regionId);
const inflationFactor = cumulativeTax >= 100 ? 1 : (1 / (1 - cumulativeTax / 100));
const adjustedPricePerUnit = Math.round(pricePerUnit * inflationFactor * 100) / 100;
const revenue = quantity * adjustedPricePerUnit;
// Tax und Net berechnen
const taxValue = Math.round((revenue * cumulativeTax / 100) * 100) / 100;
const net = Math.round((revenue - taxValue) * 100) / 100;
// Geld aktualisieren
const moneyResult = await updateFalukantUserMoney(user.id, net, `Product sale (net)`, user.id);
if (!moneyResult.success) throw new Error('Failed to update money for seller');
// Steuer an Treasury buchen
const treasuryId = process.env.TREASURY_FALUKANT_USER_ID;
if (treasuryId && taxValue > 0) {
const taxResult = await updateFalukantUserMoney(parseInt(treasuryId, 10), taxValue, `Sales tax (${cumulativeTax}%)`, user.id);
if (!taxResult.success) throw new Error('Failed to update money for treasury');
}
// Inventory aktualisieren
let remaining = quantity;
for (const inv of inventory) {
if (inv.quantity <= remaining) {
remaining -= inv.quantity;
await inv.destroy();
} else {
await inv.update({ quantity: inv.quantity - remaining });
remaining = 0;
break;
}
}
// DaySell Eintrag erstellen/aktualisieren
await this.addSellItem(branchId, user.id, productId, quantity);
// Notifications senden
notifyUser(user.user.hashedId, 'falukantUpdateStatus', {});
notifyUser(user.user.hashedId, 'falukantBranchUpdate', { branchId: branch.id });
return { success: true };
}
```
### `addSellItem()`
```javascript
async addSellItem(branchId, userId, productId, quantity) {
const branch = await Branch.findOne({
where: { id: branchId },
});
const daySell = await DaySell.findOne({
where: {
regionId: branch.regionId,
productId: productId,
sellerId: userId,
}
});
if (daySell) {
daySell.quantity += quantity;
await daySell.save();
} else {
await DaySell.create({
regionId: branch.regionId,
productId: productId,
sellerId: userId,
quantity: quantity,
});
}
}
```
## Wichtige Hinweise
1. **Inventory wird nach Verkauf gelöscht/aktualisiert**: Items werden aus der Inventory entfernt oder die Menge reduziert.
2. **DaySell wird aggregiert**: Wenn bereits ein DaySell Eintrag für Region/Product/Seller existiert, wird die Menge addiert.
3. **Steuer wird an Treasury gebucht**: Wenn `TREASURY_FALUKANT_USER_ID` gesetzt ist, wird die Steuer an diesen User gebucht.
4. **Socket-Notifications**: Nach jedem Verkauf werden `falukantUpdateStatus` und `falukantBranchUpdate` Events gesendet.
5. **Politische Befreiungen**: Bestimmte politische Ämter befreien von Steuern in bestimmten Region-Typen. Chancellor befreit von allen Steuern.
6. **Preis-Inflation**: Der Preis wird basierend auf der Steuer inflatiert, damit der Netto-Betrag für den Verkäufer gleich bleibt.
## Tabellenübersicht
### `falukant_data.inventory`
- `id` (PK)
- `stock_id` (FK zu `falukant_data.stock`)
- `product_id` (FK zu `falukant_type.product`)
- `quantity` (INTEGER)
- `quality` (INTEGER)
- `produced_at` (DATE)
### `falukant_log.sell` (DaySell)
- `id` (PK)
- `region_id` (FK zu `falukant_data.region`)
- `product_id` (FK zu `falukant_type.product`)
- `seller_id` (FK zu `falukant_data.falukant_user`)
- `quantity` (INTEGER)
- `sell_timestamp` (DATE)
- **Unique Index**: `(seller_id, product_id, region_id)`
### `falukant_data.town_product_worth`
- `id` (PK)
- `product_id` (FK zu `falukant_type.product`)
- `region_id` (FK zu `falukant_data.region`)
- `worth_percent` (INTEGER, 0-100)
### `falukant_data.knowledge`
- `id` (PK)
- `product_id` (FK zu `falukant_type.product`)
- `character_id` (FK zu `falukant_data.character`)
- `knowledge` (INTEGER, 0-99)
### `falukant_data.political_office`
- `id` (PK)
- `office_type_id` (FK zu `falukant_type.political_office_type`)
- `character_id` (FK zu `falukant_data.character`)
- `region_id` (FK zu `falukant_data.region`)
- `created_at`, `updated_at`
### `falukant_type.political_office_type`
- `id` (PK)
- `name` (STRING) - z.B. 'council', 'taxman', 'treasurerer', 'super-state-administrator', 'chancellor'
- `seats_per_region` (INTEGER)
- `region_type` (STRING)
- `term_length` (INTEGER)
### `falukant_data.region`
- `id` (PK)
- `name` (STRING)
- `region_type_id` (FK zu `falukant_type.region`)
- `parent_id` (FK zu `falukant_data.region`, nullable)
- `map` (JSONB)
- `tax_percent` (DECIMAL)
### `falukant_type.region`
- `id` (PK)
- `label_tr` (STRING) - z.B. 'city', 'county', 'shire', 'markgrave', 'duchy'
- `parent_id` (FK zu `falukant_type.region`, nullable)
### `falukant_data.falukant_user`
- `id` (PK)
- `user_id` (FK zu `community.user`)
- `money` (DECIMAL)
- `credit_amount`, `today_credit_taken`, `credit_interest_rate`
- `certificate`
- `main_branch_region_id`
- `last_nobility_advance_at`
- `created_at`, `updated_at`
### `falukant_data.character`
- `id` (PK)
- `user_id` (FK zu `falukant_data.falukant_user`)
- `region_id` (FK zu `falukant_data.region`)
- `first_name`, `last_name`
- `birthdate`, `gender`, `health`
- `title_of_nobility` (FK zu `falukant_type.title_of_nobility`)
- `mood_id` (FK zu `falukant_type.mood`)
- `created_at`, `updated_at`
### `falukant_data.branch`
- `id` (PK)
- `branch_type_id` (FK zu `falukant_type.branch`)
- `region_id` (FK zu `falukant_data.region`)
- `falukant_user_id` (FK zu `falukant_data.falukant_user`)
### `falukant_data.stock`
- `id` (PK)
- `branch_id` (FK zu `falukant_data.branch`)
- `stock_type_id` (FK zu `falukant_type.stock`)
- `quantity` (INTEGER)
- `product_quality` (INTEGER, nullable)
### `falukant_type.product`
- `id` (PK)
- `label_tr` (STRING, unique)
- `category` (INTEGER)
- `production_time` (INTEGER)
- `sell_cost` (INTEGER)
## Dateipfade
- **Service**: `backend/services/falukantService.js`
- **Models**:
- `backend/models/falukant/data/inventory.js`
- `backend/models/falukant/log/daysell.js`
- `backend/models/falukant/data/town_product_worth.js`
- `backend/models/falukant/data/product_knowledge.js`
- `backend/models/falukant/data/political_office.js`
- `backend/models/falukant/type/political_office_type.js`
- `backend/models/falukant/data/region.js`
- `backend/models/falukant/type/region.js`
- `backend/models/falukant/data/character.js`
- `backend/models/falukant/data/user.js`
- `backend/models/falukant/data/branch.js`
- `backend/models/falukant/data/stock.js`
- `backend/models/falukant/type/product.js`

View File

@@ -1,6 +1,7 @@
import express from 'express';
import path from 'path';
import { fileURLToPath } from 'url';
import crypto from 'crypto';
import chatRouter from './routers/chatRouter.js';
import authRouter from './routers/authRouter.js';
import navigationRouter from './routers/navigationRouter.js';
@@ -25,6 +26,25 @@ const __dirname = path.dirname(__filename);
const app = express();
// Request-Timing (aktivierbar per ENV)
// - LOG_SLOW_REQ_MS=200: Logge Requests, die länger dauern als X ms (Default 500)
// - LOG_ALL_REQ=1: Logge alle Requests
const LOG_ALL_REQ = process.env.LOG_ALL_REQ === '1';
const LOG_SLOW_REQ_MS = Number.parseInt(process.env.LOG_SLOW_REQ_MS || '500', 10);
app.use((req, res, next) => {
const reqId = req.headers['x-request-id'] || (crypto.randomUUID ? crypto.randomUUID() : crypto.randomBytes(8).toString('hex'));
req.reqId = reqId;
res.setHeader('x-request-id', reqId);
const t0 = Date.now();
res.on('finish', () => {
const ms = Date.now() - t0;
if (LOG_ALL_REQ || ms >= LOG_SLOW_REQ_MS) {
console.log(`⏱️ REQ ${ms}ms ${res.statusCode} ${req.method} ${req.originalUrl} rid=${reqId}`);
}
});
next();
});
const corsOptions = {
origin: ['http://localhost:3000', 'http://localhost:5173', 'http://127.0.0.1:3000', 'http://127.0.0.1:5173'],
methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'],

View File

@@ -0,0 +1,79 @@
"use strict";
module.exports = {
async up(queryInterface, Sequelize) {
// falukant_data.character.reputation (integer, default random 20..80)
// Wichtig: Schema explizit angeben
// Vorgehen:
// - Spalte anlegen (falls noch nicht vorhanden)
// - bestehende Zeilen initialisieren (random 20..80)
// - DEFAULT setzen (random 20..80)
// - NOT NULL + CHECK 0..100 erzwingen
await queryInterface.sequelize.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'falukant_data'
AND table_name = 'character'
AND column_name = 'reputation'
) THEN
ALTER TABLE falukant_data."character"
ADD COLUMN reputation integer;
END IF;
END$$;
`);
// Backfill: nur NULLs initialisieren (damit bestehende Werte nicht überschrieben werden)
await queryInterface.sequelize.query(`
UPDATE falukant_data."character"
SET reputation = (floor(random()*61)+20)::int
WHERE reputation IS NULL;
`);
// DEFAULT + NOT NULL (nach Backfill)
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data."character"
ALTER COLUMN reputation SET DEFAULT (floor(random()*61)+20)::int;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data."character"
ALTER COLUMN reputation SET NOT NULL;
`);
// Enforce 0..100 at DB level (percent)
// (IF NOT EXISTS pattern, because deployments can be re-run)
await queryInterface.sequelize.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE c.conname = 'character_reputation_0_100_chk'
AND n.nspname = 'falukant_data'
AND t.relname = 'character'
) THEN
ALTER TABLE falukant_data."character"
ADD CONSTRAINT character_reputation_0_100_chk
CHECK (reputation >= 0 AND reputation <= 100);
END IF;
END$$;
`);
},
async down(queryInterface, Sequelize) {
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data."character"
DROP CONSTRAINT IF EXISTS character_reputation_0_100_chk;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data."character"
DROP COLUMN IF EXISTS reputation;
`);
},
};

View File

@@ -34,6 +34,18 @@ FalukantCharacter.init(
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 1}
,
reputation: {
type: DataTypes.INTEGER,
allowNull: false,
// Initialisierung: zufällig 20..80 (Prozent)
// DB-seitig per DEFAULT umgesetzt, damit es auch ohne App-Logic gilt.
defaultValue: sequelize.literal('(floor(random()*61)+20)'),
validate: {
min: 0,
max: 100
}
}
},
{
sequelize,

View File

@@ -72,9 +72,10 @@ function calcAge(birthdate) {
return differenceInDays(now, b);
}
async function getFalukantUserOrFail(hashedId) {
async function getFalukantUserOrFail(hashedId, options = {}) {
const user = await FalukantUser.findOne({
include: [{ model: User, as: 'user', attributes: ['username', 'hashedId'], where: { hashedId } }]
include: [{ model: User, as: 'user', attributes: ['username', 'hashedId'], where: { hashedId } }],
transaction: options.transaction
});
if (!user) throw new Error('User not found');
return user;
@@ -92,6 +93,14 @@ function calcSellPrice(product, knowledgeFactor = 0) {
return min + (max - min) * (knowledgeFactor / 100);
}
// Synchron version für Batch-Operationen (ohne DB-Query)
function calcRegionalSellPriceSync(product, knowledgeFactor, worthPercent = 50) {
const basePrice = product.sellCost * (worthPercent / 100);
const min = basePrice * 0.6;
const max = basePrice;
return min + (max - min) * (knowledgeFactor / 100);
}
async function calcRegionalSellPrice(product, knowledgeFactor, regionId, worthPercent = null) {
// Wenn worthPercent nicht übergeben wurde, hole es aus der Datenbank
if (worthPercent === null) {
@@ -147,9 +156,34 @@ async function calcRegionalSellPrice(product, knowledgeFactor, regionId, worthPe
async function getCumulativeTaxPercentWithExemptions(userId, regionId) {
if (!regionId) return 0;
// fetch user's political offices (active) and their region types
const offices = await PoliticalOffice.findAll({
// PoliticalOffice hat keine userId Spalte, daher müssen wir zuerst den Character finden
const character = await FalukantCharacter.findOne({
where: { userId },
include: [{ model: PoliticalOfficeType, as: 'type', attributes: ['name'] }, { model: RegionData, as: 'region', include: [{ model: RegionType, as: 'regionType', attributes: ['labelTr'] }] }]
attributes: ['id']
});
if (!character) {
// Wenn kein Character existiert, gibt es keine politischen Ämter
return 0;
}
const offices = await PoliticalOffice.findAll({
where: { characterId: character.id },
include: [
{
model: PoliticalOfficeType,
as: 'type',
attributes: ['name']
},
{
model: RegionData,
as: 'region',
include: [{
model: RegionType,
as: 'regionType',
attributes: ['labelTr']
}]
}
]
});
// build set of exempt region type labels from user's offices
@@ -169,21 +203,27 @@ async function calcRegionalSellPrice(product, knowledgeFactor, regionId, worthPe
if (hasChancellor) return 0;
// Now compute cumulative tax but exclude regions whose regionType.labelTr is in exemptTypes
// Konvertiere exemptTypes Set zu einem PostgreSQL-Array-String
const exemptTypesArray = Array.from(exemptTypes);
const exemptTypesString = exemptTypesArray.length > 0
? `ARRAY[${exemptTypesArray.map(t => `'${t.replace(/'/g, "''")}'`).join(',')}]`
: `ARRAY[]::text[]`;
const rows = await sequelize.query(
`WITH RECURSIVE ancestors AS (
SELECT r.id, r.parent_id, r.tax_percent, rt.label_tr as region_type
FROM falukant_data.region r
JOIN falukant_type.region_type rt ON rt.id = r.region_type_id
JOIN falukant_type.region rt ON rt.id = r.region_type_id
WHERE r.id = :id
UNION ALL
SELECT reg.id, reg.parent_id, reg.tax_percent, rt2.label_tr
FROM falukant_data.region reg
JOIN falukant_type.region_type rt2 ON rt2.id = reg.region_type_id
JOIN falukant_type.region rt2 ON rt2.id = reg.region_type_id
JOIN ancestors a ON reg.id = a.parent_id
)
SELECT COALESCE(SUM(CASE WHEN :exempt_types::text[] && ARRAY[region_type] THEN 0 ELSE tax_percent END),0) AS total FROM ancestors;`,
SELECT COALESCE(SUM(CASE WHEN ${exemptTypesString} && ARRAY[region_type]::text[] THEN 0 ELSE tax_percent END),0) AS total FROM ancestors;`,
{
replacements: { id: regionId, exempt_types: Array.from(exemptTypes) },
replacements: { id: regionId },
type: sequelize.QueryTypes.SELECT
}
);
@@ -333,7 +373,7 @@ class FalukantService extends BaseService {
{ model: TitleOfNobility, as: 'nobleTitle', attributes: ['labelTr', 'id'] },
{ model: CharacterTrait, as: 'traits', attributes: ['id', 'tr'] }
],
attributes: ['id', 'birthdate', 'gender', 'moodId', 'health']
attributes: ['id', 'birthdate', 'gender', 'moodId', 'health', 'reputation']
},
{
model: UserHouse,
@@ -457,7 +497,7 @@ class FalukantService extends BaseService {
{
model: FalukantCharacter,
as: 'character',
attributes: ['birthdate', 'health'],
attributes: ['birthdate', 'health', 'reputation'],
include: [
{
model: Relationship,
@@ -1508,46 +1548,62 @@ class FalukantService extends BaseService {
}
async getInventory(hashedUserId, branchId) {
// PERFORMANCE: Diese Route war langsam wegen doppelter/verschachtelter Includes (Branch->Stocks->Region + Inventory->Stock->Branch->Region).
// Wir holen stattdessen genau die benötigten Felder in EINER aggregierenden SQL-Query.
const u = await getFalukantUserOrFail(hashedUserId);
const f = branchId ? { id: branchId, falukantUserId: u.id } : { falukantUserId: u.id };
const br = await Branch.findAll({
where: f,
include: [
{ model: FalukantStock, as: 'stocks', include: [{ model: FalukantStockType, as: 'stockType' }] },
{ model: RegionData, as: 'region', include: [{ model: RegionType, as: 'regionType' }] }
]
});
const stockIds = br.flatMap(b => b.stocks.map(s => s.id));
const inv = await Inventory.findAll({
where: { stockId: stockIds },
include: [
{
model: FalukantStock,
as: 'stock',
include: [
{
model: Branch,
as: 'branch',
include: [{ model: RegionData, as: 'region', include: [{ model: RegionType, as: 'regionType' }] }]
},
{ model: FalukantStockType, as: 'stockType' }
]
},
{ model: ProductType, as: 'productType' }
]
});
const grouped = inv.reduce((acc, i) => {
const r = i.stock.branch.region;
const k = `${r.id}-${i.productType.id}-${i.quality}`;
acc[k] = acc[k] || { region: r, product: i.productType, quality: i.quality, totalQuantity: 0 };
acc[k].totalQuantity += i.quantity;
return acc;
}, {});
return Object.values(grouped).sort((a, b) => {
if (a.region.id !== b.region.id) return a.region.id - b.region.id;
if (a.product.id !== b.product.id) return a.product.id - b.product.id;
return a.quality - b.quality;
});
const branchIdInt = branchId == null ? null : parseInt(branchId, 10);
if (branchId != null && Number.isNaN(branchIdInt)) {
throw new Error('Invalid branchId');
}
const rows = await sequelize.query(
`
SELECT
r.id AS region_id,
r.name AS region_name,
rt.id AS region_type_id,
rt.label_tr AS region_type_label_tr,
p.id AS product_id,
p.label_tr AS product_label_tr,
p.sell_cost AS product_sell_cost,
i.quality AS quality,
SUM(i.quantity)::int AS total_quantity
FROM falukant_data.inventory i
JOIN falukant_data.stock s ON s.id = i.stock_id
JOIN falukant_data.branch b ON b.id = s.branch_id
JOIN falukant_data.region r ON r.id = b.region_id
LEFT JOIN falukant_type.region rt ON rt.id = r.region_type_id
JOIN falukant_type.product p ON p.id = i.product_id
WHERE b.falukant_user_id = :falukantUserId
AND (:branchId::int IS NULL OR b.id = :branchId::int)
GROUP BY
r.id, r.name, rt.id, rt.label_tr,
p.id, p.label_tr, p.sell_cost,
i.quality
ORDER BY r.id, p.id, i.quality
`,
{
replacements: { falukantUserId: u.id, branchId: branchIdInt },
type: sequelize.QueryTypes.SELECT
}
);
return (rows || []).map(r => ({
region: {
id: r.region_id,
name: r.region_name,
regionType: r.region_type_id
? { id: r.region_type_id, labelTr: r.region_type_label_tr }
: null
},
product: {
id: r.product_id,
labelTr: r.product_label_tr,
sellCost: r.product_sell_cost
},
quality: r.quality,
totalQuantity: r.total_quantity
}));
}
async sellProduct(hashedUserId, branchId, productId, quality, quantity) {
@@ -1624,96 +1680,148 @@ class FalukantService extends BaseService {
}
async sellAllProducts(hashedUserId, branchId) {
const falukantUser = await getFalukantUserOrFail(hashedUserId);
const branch = await Branch.findOne({
where: { id: branchId, falukantUserId: falukantUser.id },
include: [{ model: FalukantStock, as: 'stocks' }]
});
if (!branch) throw new Error('Branch not found');
const stockIds = branch.stocks.map(s => s.id);
const character = await FalukantCharacter.findOne({ where: { userId: falukantUser.id } });
if (!character) throw new Error('No character for user');
const inventory = await Inventory.findAll({
where: { stockId: stockIds },
include: [
{
model: ProductType,
as: 'productType',
include: [
{
model: Knowledge,
as: 'knowledges',
required: false,
where: {
characterId: character.id
// Konsistenz-Garantie: Verkauf, DaySell-Log, Geldbuchung und Inventory-Löschung müssen atomar sein.
// Sonst kann es (wie beobachtet) zu "teilweise verkauft/gelöscht" kommen.
return await sequelize.transaction(async (t) => {
const falukantUser = await getFalukantUserOrFail(hashedUserId, { transaction: t });
const branch = await Branch.findOne({
where: { id: branchId, falukantUserId: falukantUser.id },
include: [{ model: FalukantStock, as: 'stocks' }],
transaction: t
});
if (!branch) throw new Error('Branch not found');
const stockIds = branch.stocks.map(s => s.id);
const character = await FalukantCharacter.findOne({ where: { userId: falukantUser.id }, transaction: t });
if (!character) throw new Error('No character for user');
const inventory = await Inventory.findAll({
where: { stockId: stockIds },
include: [
{
model: ProductType,
as: 'productType',
include: [
{
model: Knowledge,
as: 'knowledges',
required: false,
where: {
characterId: character.id
}
}
}
]
]
},
{
model: FalukantStock,
as: 'stock',
include: [
{
model: Branch,
as: 'branch'
},
{
model: FalukantStockType,
as: 'stockType'
}
]
}
],
transaction: t
});
if (!inventory.length) return { success: true, revenue: 0 };
// PERFORMANCE OPTIMIZATION: Batch-Load alle benötigten Daten
const regionIds = [...new Set(inventory.map(item => item.stock.branch.regionId))];
const productIds = [...new Set(inventory.map(item => item.productType.id))];
// 1. Batch-Load alle TownProductWorth Einträge
const townWorths = await TownProductWorth.findAll({
where: {
productId: { [Op.in]: productIds },
regionId: { [Op.in]: regionIds }
},
{
model: FalukantStock,
as: 'stock',
include: [
{
model: Branch,
as: 'branch'
},
{
model: FalukantStockType,
as: 'stockType'
}
]
}
]
transaction: t
});
const worthMap = new Map();
townWorths.forEach(tw => {
worthMap.set(`${tw.productId}-${tw.regionId}`, tw.worthPercent);
});
// 2/3. Batch-Berechne Steuern für alle Regionen
const taxMap = new Map();
const uniqueRegionIds = [...new Set(regionIds)];
for (const regionId of uniqueRegionIds) {
const tax = await getCumulativeTaxPercentWithExemptions(falukantUser.id, regionId);
taxMap.set(regionId, tax);
}
// 4. Berechne Preise, Steuern und Einnahmen in EINER Schleife
let total = 0;
let totalTax = 0;
const sellItems = [];
for (const item of inventory) {
const knowledgeVal = item.productType.knowledges[0]?.knowledge || 0;
const regionId = item.stock.branch.regionId;
const worthPercent = worthMap.get(`${item.productType.id}-${regionId}`) || 50;
const pricePerUnit = calcRegionalSellPriceSync(item.productType, knowledgeVal, worthPercent);
const cumulativeTax = taxMap.get(regionId);
const inflationFactor = cumulativeTax >= 100 ? 1 : (1 / (1 - cumulativeTax / 100));
const adjustedPricePerUnit = Math.round(pricePerUnit * inflationFactor * 100) / 100;
const itemRevenue = item.quantity * adjustedPricePerUnit;
const itemTax = Math.round((itemRevenue * cumulativeTax / 100) * 100) / 100;
total += itemRevenue;
totalTax += itemTax;
sellItems.push({
branchId: item.stock.branch.id,
productId: item.productType.id,
quantity: item.quantity
});
}
const totalNet = Math.round((total - totalTax) * 100) / 100;
// 5. Batch-Update DaySell Einträge (innerhalb Transaktion)
await this.addSellItemsBatch(sellItems, falukantUser.id, t);
// 6. Inventory löschen (innerhalb Transaktion) und sicherstellen, dass alles weg ist
const inventoryIds = inventory.map(item => item.id).filter(Boolean);
const expected = inventoryIds.length;
const deleted = await Inventory.destroy({ where: { id: { [Op.in]: inventoryIds } }, transaction: t });
if (deleted !== expected) {
throw new Error(`Inventory delete mismatch: expected ${expected}, deleted ${deleted}`);
}
// 7. Geld buchen (innerhalb Transaktion)
const moneyResult = await updateFalukantUserMoney(
falukantUser.id,
totalNet,
'Sell all products (net)',
falukantUser.id,
t
);
if (!moneyResult.success) throw new Error('Failed to update money for seller');
const treasuryId = process.env.TREASURY_FALUKANT_USER_ID;
if (treasuryId && totalTax > 0) {
const taxResult = await updateFalukantUserMoney(
parseInt(treasuryId, 10),
Math.round(totalTax * 100) / 100,
`Sales tax (aggregate)`,
falukantUser.id,
t
);
if (!taxResult.success) throw new Error('Failed to update money for treasury');
}
console.log('[FalukantService.sellAllProducts] sold items', expected, 'deleted', deleted, 'revenue', total);
notifyUser(falukantUser.user.hashedId, 'falukantUpdateStatus', {});
notifyUser(falukantUser.user.hashedId, 'falukantBranchUpdate', { branchId });
return { success: true, revenue: total };
});
if (!inventory.length) return { success: true, revenue: 0 };
let total = 0;
for (const item of inventory) {
const knowledgeVal = item.productType.knowledges[0]?.knowledge || 0;
const regionId = item.stock.branch.regionId;
const pricePerUnit = await calcRegionalSellPrice(item.productType, knowledgeVal, regionId);
const cumulativeTax = await getCumulativeTaxPercentWithExemptions(user.id, regionId);
const inflationFactor = cumulativeTax >= 100 ? 1 : (1 / (1 - cumulativeTax / 100));
const adjustedPricePerUnit = Math.round(pricePerUnit * inflationFactor * 100) / 100;
total += item.quantity * adjustedPricePerUnit;
await this.addSellItem(item.stock.branch.id, falukantUser.id, item.productType.id, item.quantity);
}
// compute tax per region (using cumulative tax per region) and aggregate
let totalTax = 0;
for (const item of inventory) {
const regionId = item.stock.branch.regionId;
const region = await RegionData.findOne({ where: { id: regionId } });
const cumulativeTax = await getCumulativeTaxPercent(regionId);
const pricePerUnit = await calcRegionalSellPrice(item.productType, item.productType.knowledges?.[0]?.knowledge || 0, regionId);
const inflationFactor = cumulativeTax >= 100 ? 1 : (1 / (1 - cumulativeTax / 100));
const adjustedPricePerUnit = Math.round(pricePerUnit * inflationFactor * 100) / 100;
const itemRevenue = item.quantity * adjustedPricePerUnit;
const itemTax = Math.round((itemRevenue * cumulativeTax / 100) * 100) / 100;
totalTax += itemTax;
}
const totalNet = Math.round((total - totalTax) * 100) / 100;
const moneyResult = await updateFalukantUserMoney(
falukantUser.id,
totalNet,
'Sell all products (net)',
falukantUser.id
);
if (!moneyResult.success) throw new Error('Failed to update money for seller');
const treasuryId = process.env.TREASURY_FALUKANT_USER_ID;
if (treasuryId && totalTax > 0) {
const taxResult = await updateFalukantUserMoney(parseInt(treasuryId, 10), Math.round(totalTax * 100) / 100, `Sales tax (aggregate)`, falukantUser.id);
if (!taxResult.success) throw new Error('Failed to update money for treasury');
}
for (const item of inventory) {
await Inventory.destroy({ where: { id: item.id } });
}
console.log('[FalukantService.sellAllProducts] emitting events for user', falukantUser.user.hashedId, 'branch', branchId, 'revenue', total, 'items', inventory.length);
notifyUser(falukantUser.user.hashedId, 'falukantUpdateStatus', {});
notifyUser(falukantUser.user.hashedId, 'falukantBranchUpdate', { branchId });
return { success: true, revenue: total };
}
async addSellItem(branchId, userId, productId, quantity) {
@@ -1741,6 +1849,70 @@ class FalukantService extends BaseService {
}
}
async addSellItemsBatch(sellItems, userId, transaction = null) {
if (!sellItems || sellItems.length === 0) return;
// Lade alle benötigten Branches auf einmal
const branchIds = [...new Set(sellItems.map(item => item.branchId))];
const branches = await Branch.findAll({
where: { id: { [Op.in]: branchIds } },
attributes: ['id', 'regionId'],
transaction: transaction || undefined
});
const branchMap = new Map(branches.map(b => [b.id, b]));
// WICHTIG: Wie bei addSellItem muss ein fehlender Branch ein harter Fehler sein,
// sonst entsteht ein Accounting-/Audit-Mismatch (Geld/Steuern werden gebucht, aber Sell-Logs fehlen).
const missingBranchIds = branchIds.filter(id => !branchMap.has(id));
if (missingBranchIds.length > 0) {
throw new Error(
`Branch not found for sell batch (missing branchIds: ${missingBranchIds.join(', ')})`
);
}
// Gruppiere nach (regionId, productId, sellerId)
const grouped = new Map();
for (const item of sellItems) {
const branch = branchMap.get(item.branchId);
// sollte durch missingBranchIds Check oben nie passieren, aber defensiv:
if (!branch) {
throw new Error(`Branch not found for sell batch (branchId: ${item.branchId})`);
}
const key = `${branch.regionId}-${item.productId}-${userId}`;
if (!grouped.has(key)) {
grouped.set(key, {
regionId: branch.regionId,
productId: item.productId,
sellerId: userId,
quantity: 0
});
}
grouped.get(key).quantity += item.quantity;
}
// Batch-Update oder Create für alle gruppierten Einträge
const promises = [];
for (const [key, data] of grouped) {
promises.push(
DaySell.findOrCreate({
where: {
regionId: data.regionId,
productId: data.productId,
sellerId: data.sellerId
},
defaults: { quantity: data.quantity },
transaction: transaction || undefined
}).then(([daySell, created]) => {
if (!created) {
daySell.quantity += data.quantity;
return daySell.save({ transaction: transaction || undefined });
}
})
);
}
await Promise.all(promises);
}
// Return tax summary for a branch: total cumulative tax and breakdown per region (region -> parent chain)
async getBranchTaxes(hashedUserId, branchId) {
const user = await getFalukantUserOrFail(hashedUserId);
@@ -3110,70 +3282,124 @@ class FalukantService extends BaseService {
}
async createParty(hashedUserId, partyTypeId, musicId, banquetteId, nobilityIds = [], servantRatio) {
const falukantUser = await getFalukantUserOrFail(hashedUserId);
const since = new Date(Date.now() - 24 * 3600 * 1000);
const already = await Party.findOne({
where: {
falukantUserId: falukantUser.id,
// Reputation-Logik: Party steigert Reputation um 1..5 (bestes Fest 5, kleinstes 1).
// Wir leiten "Ausstattung" aus den Party-Kosten ab (linear zwischen min/max möglicher Konfiguration),
// und deckeln Reputation bei 100.
return await sequelize.transaction(async (t) => {
const falukantUser = await getFalukantUserOrFail(hashedUserId);
const since = new Date(Date.now() - 24 * 3600 * 1000);
const already = await Party.findOne({
where: {
falukantUserId: falukantUser.id,
partyTypeId,
createdAt: { [Op.gte]: since },
},
attributes: ['id'],
transaction: t
});
if (already) {
throw new Error('Diese Party wurde bereits innerhalb der letzten 24 Stunden bestellt');
}
const [ptype, music, banquette] = await Promise.all([
PartyType.findByPk(partyTypeId, { transaction: t }),
MusicType.findByPk(musicId, { transaction: t }),
BanquetteType.findByPk(banquetteId, { transaction: t }),
]);
if (!ptype || !music || !banquette) {
throw new Error('Ungültige Party-, Musik- oder Bankett-Auswahl');
}
const nobilities = nobilityIds && nobilityIds.length
? await TitleOfNobility.findAll({ where: { id: { [Op.in]: nobilityIds } }, transaction: t })
: [];
// Prüfe, ob alle angegebenen IDs gefunden wurden
if (nobilityIds && nobilityIds.length > 0 && nobilities.length !== nobilityIds.length) {
throw new Error('Einige ausgewählte Adelstitel existieren nicht');
}
let cost = (ptype.cost || 0) + (music.cost || 0) + (banquette.cost || 0);
cost += (50 / servantRatio - 1) * 1000;
const nobilityCost = nobilities.reduce((sum, n) => sum + ((n.id ^ 5) * 1000), 0);
cost += nobilityCost;
if (Number(falukantUser.money) < cost) {
throw new Error('Nicht genügend Guthaben für diese Party');
}
// min/max mögliche Kosten für die Skalierung (nur für Reputation; Party-Preis bleibt wie berechnet)
const [allPartyTypes, allMusicTypes, allBanquetteTypes, allNobilityTitles] = await Promise.all([
PartyType.findAll({ attributes: ['cost'], transaction: t }),
MusicType.findAll({ attributes: ['cost'], transaction: t }),
BanquetteType.findAll({ attributes: ['cost'], transaction: t }),
TitleOfNobility.findAll({ attributes: ['id'], transaction: t }),
]);
const minParty = allPartyTypes.length ? Math.min(...allPartyTypes.map(x => Number(x.cost || 0))) : 0;
const maxParty = allPartyTypes.length ? Math.max(...allPartyTypes.map(x => Number(x.cost || 0))) : 0;
const minMusic = allMusicTypes.length ? Math.min(...allMusicTypes.map(x => Number(x.cost || 0))) : 0;
const maxMusic = allMusicTypes.length ? Math.max(...allMusicTypes.map(x => Number(x.cost || 0))) : 0;
const minBanq = allBanquetteTypes.length ? Math.min(...allBanquetteTypes.map(x => Number(x.cost || 0))) : 0;
const maxBanq = allBanquetteTypes.length ? Math.max(...allBanquetteTypes.map(x => Number(x.cost || 0))) : 0;
const servantsMin = 0; // servantRatio=50 => (50/50 - 1)*1000 = 0
const servantsMax = (50 / 1 - 1) * 1000; // servantRatio=1 => 49k
const nobilityMax = (allNobilityTitles || []).reduce((sum, n) => sum + ((Number(n.id) ^ 5) * 1000), 0);
const minCostPossible = (minParty || 0) + (minMusic || 0) + (minBanq || 0) + servantsMin;
const maxCostPossible = (maxParty || 0) + (maxMusic || 0) + (maxBanq || 0) + servantsMax + (nobilityMax || 0);
const denom = Math.max(1, (maxCostPossible - minCostPossible));
const score = Math.min(1, Math.max(0, (cost - minCostPossible) / denom));
const reputationGain = 1 + Math.round(score * 4); // 1..5
const character = await FalukantCharacter.findOne({
where: { userId: falukantUser.id },
attributes: ['id', 'reputation'],
transaction: t
});
if (!character) throw new Error('No character for user');
// Geld abziehen
const moneyResult = await updateFalukantUserMoney(
falukantUser.id,
-cost,
'partyOrder',
falukantUser.id,
t
);
if (!moneyResult.success) {
throw new Error('Geld konnte nicht abgezogen werden');
}
const party = await Party.create({
partyTypeId,
createdAt: { [Op.gte]: since },
},
attributes: ['id']
falukantUserId: falukantUser.id,
musicTypeId: musicId,
banquetteTypeId: banquetteId,
servantRatio,
cost: cost
}, { transaction: t });
if (nobilities.length > 0) {
await party.addInvitedNobilities(nobilities, { transaction: t });
}
// Reputation erhöhen (0..100)
await character.update(
{ reputation: Sequelize.literal(`LEAST(100, COALESCE(reputation,0) + ${reputationGain})`) },
{ transaction: t }
);
const user = await User.findByPk(falukantUser.userId, { transaction: t });
notifyUser(user.hashedId, 'falukantPartyUpdate', {
partyId: party.id,
cost,
reputationGain,
});
// Statusbar kann sich damit ebenfalls aktualisieren
notifyUser(user.hashedId, 'falukantUpdateStatus', {});
return { success: true, reputationGain };
});
if (already) {
throw new Error('Diese Party wurde bereits innerhalb der letzten 24 Stunden bestellt');
}
const [ptype, music, banquette] = await Promise.all([
PartyType.findByPk(partyTypeId),
MusicType.findByPk(musicId),
BanquetteType.findByPk(banquetteId),
]);
if (!ptype || !music || !banquette) {
throw new Error('Ungültige Party-, Musik- oder Bankett-Auswahl');
}
const nobilities = nobilityIds && nobilityIds.length
? await TitleOfNobility.findAll({ where: { id: { [Op.in]: nobilityIds } } })
: [];
// Prüfe, ob alle angegebenen IDs gefunden wurden
if (nobilityIds && nobilityIds.length > 0 && nobilities.length !== nobilityIds.length) {
throw new Error('Einige ausgewählte Adelstitel existieren nicht');
}
let cost = (ptype.cost || 0) + (music.cost || 0) + (banquette.cost || 0);
cost += (50 / servantRatio - 1) * 1000;
const nobilityCost = nobilities.reduce((sum, n) => sum + ((n.id ^ 5) * 1000), 0);
cost += nobilityCost;
if (Number(falukantUser.money) < cost) {
throw new Error('Nicht genügend Guthaben für diese Party');
}
const moneyResult = await updateFalukantUserMoney(
falukantUser.id,
-cost,
'partyOrder',
falukantUser.id
);
if (!moneyResult.success) {
throw new Error('Geld konnte nicht abgezogen werden');
}
const party = await Party.create({
partyTypeId,
falukantUserId: falukantUser.id,
musicTypeId: musicId,
banquetteTypeId: banquetteId,
servantRatio,
cost: cost
});
if (nobilities.length > 0) {
// Verwende die bereits geladenen Objekte
await party.addInvitedNobilities(nobilities);
}
const user = await User.findByPk(falukantUser.userId);
notifyUser(user.hashedId, 'falukantPartyUpdate', {
partyId: party.id,
cost,
});
return { 'success': true };
}
async getParties(hashedUserId) {

View File

@@ -3,6 +3,25 @@ import dotenv from 'dotenv';
dotenv.config();
// Optionales Performance-Logging (aktivierbar per ENV)
// - SQL_BENCHMARK=1: Sequelize liefert Query-Timing (ms) an logger
// - SQL_SLOW_MS=200: Logge nur Queries ab dieser Dauer (wenn SQL_LOG_ALL nicht gesetzt)
// - SQL_LOG_ALL=1: Logge alle Queries (auch ohne benchmark)
const SQL_BENCHMARK = process.env.SQL_BENCHMARK === '1';
const SQL_LOG_ALL = process.env.SQL_LOG_ALL === '1';
const SQL_SLOW_MS = Number.parseInt(process.env.SQL_SLOW_MS || '200', 10);
const sqlLogger = (sql, timing) => {
// Sequelize ruft logging(sql) oder logging(sql, timing) abhängig von benchmark auf.
if (!SQL_BENCHMARK) {
if (SQL_LOG_ALL) console.log(sql);
return;
}
const ms = typeof timing === 'number' ? timing : 0;
if (SQL_LOG_ALL || ms >= SQL_SLOW_MS) {
console.log(`🛢️ SQL ${ms}ms: ${sql}`);
}
};
// Validiere Umgebungsvariablen
const dbName = process.env.DB_NAME;
const dbUser = process.env.DB_USER;
@@ -26,6 +45,8 @@ const sequelize = new Sequelize(dbName, dbUser, dbPass, {
timestamps: false,
underscored: true // WICHTIG: Alle Datenbankfelder im snake_case Format
},
benchmark: SQL_BENCHMARK,
logging: sqlLogger,
});
const createSchemas = async () => {
@@ -380,7 +401,7 @@ const updateSchema = async (models) => {
console.log('✅ Datenbankschema aktualisiert');
};
async function updateFalukantUserMoney(falukantUserId, moneyChange, activity, changedBy = null) {
async function updateFalukantUserMoney(falukantUserId, moneyChange, activity, changedBy = null, transaction = null) {
try {
const result = await sequelize.query(
`SELECT falukant_data.update_money(
@@ -397,6 +418,7 @@ async function updateFalukantUserMoney(falukantUserId, moneyChange, activity, ch
changedBy,
},
type: sequelize.QueryTypes.SELECT,
transaction: transaction || undefined,
}
);
return {

View File

@@ -20,8 +20,10 @@
<td>{{ item.quality }}</td>
<td>{{ item.totalQuantity }}</td>
<td>
<input type="number" v-model.number="item.sellQuantity" :min="1" :max="item.totalQuantity" />
<button @click="sellItem(index)">{{ $t('falukant.branch.sale.sellButton') }}</button>
<input type="number" v-model.number="item.sellQuantity" :min="1" :max="item.totalQuantity" :disabled="sellingItemIndex === index" />
<button @click="sellItem(index)" :disabled="sellingItemIndex === index || sellingAll">
{{ sellingItemIndex === index ? $t('falukant.branch.sale.selling') : $t('falukant.branch.sale.sellButton') }}
</button>
</td>
<td>
<div v-if="item.betterPrices && item.betterPrices.length > 0" class="price-cities">
@@ -36,7 +38,12 @@
</tr>
</tbody>
</table>
<button @click="sellAll">{{ $t('falukant.branch.sale.sellAllButton') }}</button>
<button @click="sellAll" :disabled="sellingAll || sellingItemIndex !== null">
{{ sellingAll ? $t('falukant.branch.sale.selling') : $t('falukant.branch.sale.sellAllButton') }}
</button>
<div v-if="sellAllStatus" class="sell-all-status" :class="sellAllStatus.type">
{{ sellAllStatus.message }}
</div>
</div>
<div v-else>
<p>{{ $t('falukant.branch.sale.noInventory') }}</p>
@@ -183,6 +190,9 @@
data() {
return {
inventory: [],
sellingItemIndex: null,
sellingAll: false,
sellAllStatus: null,
transportForm: {
sourceKey: null,
vehicleTypeId: null,
@@ -251,13 +261,6 @@
return new Date(a.eta).getTime() - new Date(b.eta).getTime();
});
},
speedLabel(value) {
const key = value == null ? 'unknown' : String(value);
const tKey = `falukant.branch.transport.speed.${key}`;
const translated = this.$t(tKey);
if (!translated || translated === tKey) return value;
return translated;
},
},
async mounted() {
await this.loadInventory();
@@ -274,12 +277,22 @@
}
},
methods: {
speedLabel(value) {
// Muss in methods liegen (Vue3): in computed wäre es ein Getter und keine aufrufbare Funktion.
const key = value == null ? 'unknown' : String(value);
const tKey = `falukant.branch.transport.speed.${key}`;
const translated = this.$t(tKey);
if (!translated || translated === tKey) return value;
return translated;
},
async loadInventory() {
try {
const response = await apiClient.get(`/api/falukant/inventory/${this.branchId}`);
this.inventory = response.data.map(item => ({
...item,
sellQuantity: item.totalQuantity,
// Vue3: besserPrices direkt als Property setzen (statt this.$set)
betterPrices: Array.isArray(item.betterPrices) ? item.betterPrices : [],
}));
await this.loadPricesForInventory();
} catch (error) {
@@ -300,10 +313,11 @@
currentPrice: currentPrice
}
});
this.$set(item, 'betterPrices', data || []);
// Vue3: direkte Zuweisung ist reaktiv
item.betterPrices = Array.isArray(data) ? data : [];
} catch (error) {
console.error(`Error loading prices for item ${itemKey}:`, error);
this.$set(item, 'betterPrices', []);
item.betterPrices = [];
} finally {
this.loadingPrices.delete(itemKey);
}
@@ -320,23 +334,61 @@
maximumFractionDigits: 2,
}).format(price);
},
sellItem(index) {
async sellItem(index) {
if (this.sellingItemIndex !== null || this.sellingAll) return;
const item = this.inventory[index];
const quantityToSell = item.sellQuantity || item.totalQuantity;
apiClient.post(`/api/falukant/sell`, {
branchId: this.branchId,
productId: item.product.id,
quantity: quantityToSell,
quality: item.quality,
}).catch(() => {
this.sellingItemIndex = index;
try {
await apiClient.post(`/api/falukant/sell`, {
branchId: this.branchId,
productId: item.product.id,
quantity: quantityToSell,
quality: item.quality,
});
// UI sofort freigeben (Label/Disabled zurücksetzen), dann Inventory refreshen
this.sellingItemIndex = null;
await this.loadInventory();
} catch (error) {
alert(this.$t('falukant.branch.sale.sellError'));
});
} finally {
this.sellingItemIndex = null;
}
},
sellAll() {
apiClient.post(`/api/falukant/sell/all`, { branchId: this.branchId })
.catch(() => {
alert(this.$t('falukant.branch.sale.sellAllError'));
});
async sellAll() {
if (this.sellingAll || this.sellingItemIndex !== null) return;
this.sellingAll = true;
this.sellAllStatus = null;
try {
const response = await apiClient.post(`/api/falukant/sell/all`, { branchId: this.branchId });
const revenue = response.data?.revenue || 0;
// UI sofort freigeben + Status setzen, danach Inventory refreshen
this.sellingAll = false;
this.sellAllStatus = {
type: 'success',
message: this.$t('falukant.branch.sale.sellAllSuccess', { revenue: this.formatMoney(revenue) })
};
// Inventory neu laden nach erfolgreichem Verkauf
await this.loadInventory();
} catch (error) {
// UI sofort freigeben + Fehlerstatus setzen
this.sellingAll = false;
this.sellAllStatus = {
type: 'error',
message: this.$t('falukant.branch.sale.sellAllError')
};
} finally {
// Falls noch nicht freigegeben (z.B. wenn ein unerwarteter Fehler vor Response passiert)
this.sellingAll = false;
// Status nach 5 Sekunden löschen
setTimeout(() => {
this.sellAllStatus = null;
}, 5000);
}
},
inventoryOptions() {
return this.inventory.map((item, index) => ({
@@ -590,5 +642,19 @@
color: #999;
font-style: italic;
}
.sell-all-status {
margin-top: 10px;
padding: 8px;
border-radius: 4px;
}
.sell-all-status.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.sell-all-status.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
</style>

View File

@@ -260,6 +260,10 @@
"sell": "Verkauf",
"sellButton": "Verkaufen",
"sellAllButton": "Alles verkaufen",
"selling": "Verkauf läuft...",
"sellError": "Fehler beim Verkauf des Produkts.",
"sellAllError": "Fehler beim Verkauf aller Produkte.",
"sellAllSuccess": "Alle Produkte wurden erfolgreich verkauft. Einnahmen: {revenue}",
"transportTitle": "Transport anlegen",
"transportSource": "Artikel",
"transportSourcePlaceholder": "Artikel wählen",
@@ -738,7 +742,8 @@
"reputation": {
"title": "Reputation",
"overview": {
"title": "Übersicht"
"title": "Übersicht",
"current": "Deine aktuelle Reputation"
},
"party": {
"title": "Feste",

View File

@@ -198,6 +198,16 @@
"nobility": {
"cooldown": "You can only advance again on {date}."
},
"reputation": {
"title": "Reputation",
"overview": {
"title": "Overview",
"current": "Your current reputation"
},
"party": {
"title": "Parties"
}
},
"branchProduction": {
"storageAvailable": "Free storage"
},

View File

@@ -1014,12 +1014,15 @@ export default {
});
await this.loadVehicles();
this.closeRepairAllVehiclesDialog();
alert(this.$t('falukant.branch.transport.repairAllSuccess'));
// Statt JS-alert: Dialog schließen und MessageDialog anzeigen
this.$root.$refs.messageDialog?.open('tr:falukant.branch.transport.repairAllSuccess');
this.$refs.statusBar?.fetchStatus();
} catch (error) {
console.error('Error repairing all vehicles:', error);
const errorMessage = error.response?.data?.message || this.$t('falukant.branch.transport.repairAllError');
alert(errorMessage);
// Bestätigungsdialog ebenfalls schließen und Fehler im MessageDialog anzeigen
this.closeRepairAllVehiclesDialog();
this.$root.$refs.messageDialog?.open(String(errorMessage), this.$t('error.title'));
}
},
@@ -1076,12 +1079,15 @@ export default {
await apiClient.post(`/api/falukant/vehicles/${this.repairVehicleDialog.vehicle.id}/repair`);
await this.loadVehicles();
this.closeRepairVehicleDialog();
alert(this.$t('falukant.branch.transport.repairSuccess'));
// Statt JS-alert: Dialog schließen und MessageDialog anzeigen
this.$root.$refs.messageDialog?.open('tr:falukant.branch.transport.repairSuccess');
this.$refs.statusBar?.fetchStatus();
} catch (error) {
console.error('Error repairing vehicle:', error);
const errorMessage = error.response?.data?.message || this.$t('falukant.branch.transport.repairError');
alert(errorMessage);
// Bestätigungsdialog ebenfalls schließen und Fehler im MessageDialog anzeigen
this.closeRepairVehicleDialog();
this.$root.$refs.messageDialog?.open(String(errorMessage), this.$t('error.title'));
}
},
},

View File

@@ -12,7 +12,10 @@
<div class="tab-content">
<div v-if="activeTab === 'overview'">
<p>Deine aktuelle Reputation: </p>
<p>
{{ $t('falukant.reputation.overview.current') }}:
<strong>{{ reputationDisplay }}</strong>
</p>
</div>
<div v-else-if="activeTab === 'party'">
@@ -169,7 +172,8 @@ export default {
selectedNobilityIds: [],
servantRatio: 50,
inProgressParties: [],
completedParties: []
completedParties: [],
reputation: null,
}
},
methods: {
@@ -198,6 +202,15 @@ export default {
return partyDate <= twentyFourHoursAgo;
});
},
async loadReputation() {
try {
const { data } = await apiClient.get('/api/falukant/info');
this.reputation = data?.character?.reputation ?? null;
} catch (e) {
console.error('Failed to load reputation', e);
this.reputation = null;
}
},
async loadNobilityTitles() {
this.nobilityTitles = await apiClient.get('/api/falukant/nobility/titels').then(r => r.data)
},
@@ -219,6 +232,10 @@ export default {
}
},
computed: {
reputationDisplay() {
if (this.reputation == null) return '—';
return String(this.reputation);
},
formattedCost() {
const type = this.partyTypes.find(t => t.id === this.newPartyTypeId) || {};
const music = this.musicTypes.find(m => m.id === this.musicId) || {};
@@ -245,6 +262,7 @@ export default {
await this.loadPartyTypes();
await this.loadNobilityTitles();
await this.loadParties();
await this.loadReputation();
}
}
</script>

1451
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,7 @@
"devDependencies": {
"concurrently": "^7.0.0",
"nodemon": "^3.1.10",
"npm-run-all": "^4.1.5"
"npm-run-all2": "^5.0.0"
},
"dependencies": {
"cors": "^2.8.5",