Compare commits
23 Commits
2a4928c1b6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38f23cc6ae | ||
|
|
6cf8fa8a9c | ||
|
|
f9ea4715d7 | ||
|
|
b34b374f76 | ||
|
|
83d1168f26 | ||
|
|
91009f52cd | ||
|
|
c6dfca7052 | ||
|
|
aaeaeeed24 | ||
|
|
c5804f764c | ||
|
|
fbe0d1bcd1 | ||
|
|
2fb440f033 | ||
|
|
a8a136a9ce | ||
|
|
fcbb903839 | ||
|
|
ac45a2ba26 | ||
|
|
afe15dd4f5 | ||
|
|
e3df88bea0 | ||
|
|
c69a414f78 | ||
|
|
d08022ab94 | ||
|
|
66e6fab663 | ||
| 4da572822e | |||
| ee23bb3ba3 | |||
| d002e340dd | |||
| 0e1d87ddab |
230
PERFORMANCE_ANALYSIS.md
Normal file
230
PERFORMANCE_ANALYSIS.md
Normal 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
601
SELL_OVERVIEW.md
Normal 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`
|
||||||
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
import crypto from 'crypto';
|
||||||
import chatRouter from './routers/chatRouter.js';
|
import chatRouter from './routers/chatRouter.js';
|
||||||
import authRouter from './routers/authRouter.js';
|
import authRouter from './routers/authRouter.js';
|
||||||
import navigationRouter from './routers/navigationRouter.js';
|
import navigationRouter from './routers/navigationRouter.js';
|
||||||
@@ -25,6 +26,25 @@ const __dirname = path.dirname(__filename);
|
|||||||
|
|
||||||
const app = express();
|
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 = {
|
const corsOptions = {
|
||||||
origin: ['http://localhost:3000', 'http://localhost:5173', 'http://127.0.0.1:3000', 'http://127.0.0.1:5173'],
|
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'],
|
methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'],
|
||||||
|
|||||||
@@ -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;
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -34,6 +34,18 @@ FalukantCharacter.init(
|
|||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
defaultValue: 1}
|
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,
|
sequelize,
|
||||||
|
|||||||
@@ -72,9 +72,10 @@ function calcAge(birthdate) {
|
|||||||
return differenceInDays(now, b);
|
return differenceInDays(now, b);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getFalukantUserOrFail(hashedId) {
|
async function getFalukantUserOrFail(hashedId, options = {}) {
|
||||||
const user = await FalukantUser.findOne({
|
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');
|
if (!user) throw new Error('User not found');
|
||||||
return user;
|
return user;
|
||||||
@@ -92,6 +93,14 @@ function calcSellPrice(product, knowledgeFactor = 0) {
|
|||||||
return min + (max - min) * (knowledgeFactor / 100);
|
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) {
|
async function calcRegionalSellPrice(product, knowledgeFactor, regionId, worthPercent = null) {
|
||||||
// Wenn worthPercent nicht übergeben wurde, hole es aus der Datenbank
|
// Wenn worthPercent nicht übergeben wurde, hole es aus der Datenbank
|
||||||
if (worthPercent === null) {
|
if (worthPercent === null) {
|
||||||
@@ -147,9 +156,34 @@ async function calcRegionalSellPrice(product, knowledgeFactor, regionId, worthPe
|
|||||||
async function getCumulativeTaxPercentWithExemptions(userId, regionId) {
|
async function getCumulativeTaxPercentWithExemptions(userId, regionId) {
|
||||||
if (!regionId) return 0;
|
if (!regionId) return 0;
|
||||||
// fetch user's political offices (active) and their region types
|
// 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 },
|
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
|
// 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;
|
if (hasChancellor) return 0;
|
||||||
|
|
||||||
// Now compute cumulative tax but exclude regions whose regionType.labelTr is in exemptTypes
|
// 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(
|
const rows = await sequelize.query(
|
||||||
`WITH RECURSIVE ancestors AS (
|
`WITH RECURSIVE ancestors AS (
|
||||||
SELECT r.id, r.parent_id, r.tax_percent, rt.label_tr as region_type
|
SELECT r.id, r.parent_id, r.tax_percent, rt.label_tr as region_type
|
||||||
FROM falukant_data.region r
|
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
|
WHERE r.id = :id
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT reg.id, reg.parent_id, reg.tax_percent, rt2.label_tr
|
SELECT reg.id, reg.parent_id, reg.tax_percent, rt2.label_tr
|
||||||
FROM falukant_data.region reg
|
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
|
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
|
type: sequelize.QueryTypes.SELECT
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -333,7 +373,7 @@ class FalukantService extends BaseService {
|
|||||||
{ model: TitleOfNobility, as: 'nobleTitle', attributes: ['labelTr', 'id'] },
|
{ model: TitleOfNobility, as: 'nobleTitle', attributes: ['labelTr', 'id'] },
|
||||||
{ model: CharacterTrait, as: 'traits', attributes: ['id', 'tr'] }
|
{ model: CharacterTrait, as: 'traits', attributes: ['id', 'tr'] }
|
||||||
],
|
],
|
||||||
attributes: ['id', 'birthdate', 'gender', 'moodId', 'health']
|
attributes: ['id', 'birthdate', 'gender', 'moodId', 'health', 'reputation']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: UserHouse,
|
model: UserHouse,
|
||||||
@@ -457,7 +497,7 @@ class FalukantService extends BaseService {
|
|||||||
{
|
{
|
||||||
model: FalukantCharacter,
|
model: FalukantCharacter,
|
||||||
as: 'character',
|
as: 'character',
|
||||||
attributes: ['birthdate', 'health'],
|
attributes: ['birthdate', 'health', 'reputation'],
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
model: Relationship,
|
model: Relationship,
|
||||||
@@ -1508,46 +1548,62 @@ class FalukantService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getInventory(hashedUserId, branchId) {
|
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 u = await getFalukantUserOrFail(hashedUserId);
|
||||||
const f = branchId ? { id: branchId, falukantUserId: u.id } : { falukantUserId: u.id };
|
const branchIdInt = branchId == null ? null : parseInt(branchId, 10);
|
||||||
const br = await Branch.findAll({
|
if (branchId != null && Number.isNaN(branchIdInt)) {
|
||||||
where: f,
|
throw new Error('Invalid branchId');
|
||||||
include: [
|
}
|
||||||
{ model: FalukantStock, as: 'stocks', include: [{ model: FalukantStockType, as: 'stockType' }] },
|
|
||||||
{ model: RegionData, as: 'region', include: [{ model: RegionType, as: 'regionType' }] }
|
const rows = await sequelize.query(
|
||||||
]
|
`
|
||||||
});
|
SELECT
|
||||||
const stockIds = br.flatMap(b => b.stocks.map(s => s.id));
|
r.id AS region_id,
|
||||||
const inv = await Inventory.findAll({
|
r.name AS region_name,
|
||||||
where: { stockId: stockIds },
|
rt.id AS region_type_id,
|
||||||
include: [
|
rt.label_tr AS region_type_label_tr,
|
||||||
{
|
p.id AS product_id,
|
||||||
model: FalukantStock,
|
p.label_tr AS product_label_tr,
|
||||||
as: 'stock',
|
p.sell_cost AS product_sell_cost,
|
||||||
include: [
|
i.quality AS quality,
|
||||||
{
|
SUM(i.quantity)::int AS total_quantity
|
||||||
model: Branch,
|
FROM falukant_data.inventory i
|
||||||
as: 'branch',
|
JOIN falukant_data.stock s ON s.id = i.stock_id
|
||||||
include: [{ model: RegionData, as: 'region', include: [{ model: RegionType, as: 'regionType' }] }]
|
JOIN falukant_data.branch b ON b.id = s.branch_id
|
||||||
},
|
JOIN falukant_data.region r ON r.id = b.region_id
|
||||||
{ model: FalukantStockType, as: 'stockType' }
|
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
|
||||||
{ model: ProductType, as: 'productType' }
|
AND (:branchId::int IS NULL OR b.id = :branchId::int)
|
||||||
]
|
GROUP BY
|
||||||
});
|
r.id, r.name, rt.id, rt.label_tr,
|
||||||
const grouped = inv.reduce((acc, i) => {
|
p.id, p.label_tr, p.sell_cost,
|
||||||
const r = i.stock.branch.region;
|
i.quality
|
||||||
const k = `${r.id}-${i.productType.id}-${i.quality}`;
|
ORDER BY r.id, p.id, i.quality
|
||||||
acc[k] = acc[k] || { region: r, product: i.productType, quality: i.quality, totalQuantity: 0 };
|
`,
|
||||||
acc[k].totalQuantity += i.quantity;
|
{
|
||||||
return acc;
|
replacements: { falukantUserId: u.id, branchId: branchIdInt },
|
||||||
}, {});
|
type: sequelize.QueryTypes.SELECT
|
||||||
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;
|
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) {
|
async sellProduct(hashedUserId, branchId, productId, quality, quantity) {
|
||||||
@@ -1624,96 +1680,148 @@ class FalukantService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async sellAllProducts(hashedUserId, branchId) {
|
async sellAllProducts(hashedUserId, branchId) {
|
||||||
const falukantUser = await getFalukantUserOrFail(hashedUserId);
|
// Konsistenz-Garantie: Verkauf, DaySell-Log, Geldbuchung und Inventory-Löschung müssen atomar sein.
|
||||||
const branch = await Branch.findOne({
|
// Sonst kann es (wie beobachtet) zu "teilweise verkauft/gelöscht" kommen.
|
||||||
where: { id: branchId, falukantUserId: falukantUser.id },
|
return await sequelize.transaction(async (t) => {
|
||||||
include: [{ model: FalukantStock, as: 'stocks' }]
|
const falukantUser = await getFalukantUserOrFail(hashedUserId, { transaction: t });
|
||||||
});
|
const branch = await Branch.findOne({
|
||||||
if (!branch) throw new Error('Branch not found');
|
where: { id: branchId, falukantUserId: falukantUser.id },
|
||||||
const stockIds = branch.stocks.map(s => s.id);
|
include: [{ model: FalukantStock, as: 'stocks' }],
|
||||||
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({
|
if (!branch) throw new Error('Branch not found');
|
||||||
where: { stockId: stockIds },
|
const stockIds = branch.stocks.map(s => s.id);
|
||||||
include: [
|
const character = await FalukantCharacter.findOne({ where: { userId: falukantUser.id }, transaction: t });
|
||||||
{
|
if (!character) throw new Error('No character for user');
|
||||||
model: ProductType,
|
const inventory = await Inventory.findAll({
|
||||||
as: 'productType',
|
where: { stockId: stockIds },
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
model: Knowledge,
|
model: ProductType,
|
||||||
as: 'knowledges',
|
as: 'productType',
|
||||||
required: false,
|
include: [
|
||||||
where: {
|
{
|
||||||
characterId: character.id
|
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 }
|
||||||
},
|
},
|
||||||
{
|
transaction: t
|
||||||
model: FalukantStock,
|
});
|
||||||
as: 'stock',
|
const worthMap = new Map();
|
||||||
include: [
|
townWorths.forEach(tw => {
|
||||||
{
|
worthMap.set(`${tw.productId}-${tw.regionId}`, tw.worthPercent);
|
||||||
model: Branch,
|
});
|
||||||
as: 'branch'
|
|
||||||
},
|
// 2/3. Batch-Berechne Steuern für alle Regionen
|
||||||
{
|
const taxMap = new Map();
|
||||||
model: FalukantStockType,
|
const uniqueRegionIds = [...new Set(regionIds)];
|
||||||
as: 'stockType'
|
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) {
|
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)
|
// Return tax summary for a branch: total cumulative tax and breakdown per region (region -> parent chain)
|
||||||
async getBranchTaxes(hashedUserId, branchId) {
|
async getBranchTaxes(hashedUserId, branchId) {
|
||||||
const user = await getFalukantUserOrFail(hashedUserId);
|
const user = await getFalukantUserOrFail(hashedUserId);
|
||||||
@@ -3110,70 +3282,124 @@ class FalukantService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createParty(hashedUserId, partyTypeId, musicId, banquetteId, nobilityIds = [], servantRatio) {
|
async createParty(hashedUserId, partyTypeId, musicId, banquetteId, nobilityIds = [], servantRatio) {
|
||||||
const falukantUser = await getFalukantUserOrFail(hashedUserId);
|
// Reputation-Logik: Party steigert Reputation um 1..5 (bestes Fest 5, kleinstes 1).
|
||||||
const since = new Date(Date.now() - 24 * 3600 * 1000);
|
// Wir leiten "Ausstattung" aus den Party-Kosten ab (linear zwischen min/max möglicher Konfiguration),
|
||||||
const already = await Party.findOne({
|
// und deckeln Reputation bei 100.
|
||||||
where: {
|
return await sequelize.transaction(async (t) => {
|
||||||
falukantUserId: falukantUser.id,
|
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,
|
partyTypeId,
|
||||||
createdAt: { [Op.gte]: since },
|
falukantUserId: falukantUser.id,
|
||||||
},
|
musicTypeId: musicId,
|
||||||
attributes: ['id']
|
banquetteTypeId: banquetteId,
|
||||||
});
|
servantRatio,
|
||||||
if (already) {
|
cost: cost
|
||||||
throw new Error('Diese Party wurde bereits innerhalb der letzten 24 Stunden bestellt');
|
}, { transaction: t });
|
||||||
}
|
|
||||||
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 (nobilities.length > 0) {
|
||||||
if (nobilityIds && nobilityIds.length > 0 && nobilities.length !== nobilityIds.length) {
|
await party.addInvitedNobilities(nobilities, { transaction: t });
|
||||||
throw new Error('Einige ausgewählte Adelstitel existieren nicht');
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let cost = (ptype.cost || 0) + (music.cost || 0) + (banquette.cost || 0);
|
// Reputation erhöhen (0..100)
|
||||||
cost += (50 / servantRatio - 1) * 1000;
|
await character.update(
|
||||||
const nobilityCost = nobilities.reduce((sum, n) => sum + ((n.id ^ 5) * 1000), 0);
|
{ reputation: Sequelize.literal(`LEAST(100, COALESCE(reputation,0) + ${reputationGain})`) },
|
||||||
cost += nobilityCost;
|
{ transaction: t }
|
||||||
if (Number(falukantUser.money) < cost) {
|
);
|
||||||
throw new Error('Nicht genügend Guthaben für diese Party');
|
|
||||||
}
|
const user = await User.findByPk(falukantUser.userId, { transaction: t });
|
||||||
const moneyResult = await updateFalukantUserMoney(
|
notifyUser(user.hashedId, 'falukantPartyUpdate', {
|
||||||
falukantUser.id,
|
partyId: party.id,
|
||||||
-cost,
|
cost,
|
||||||
'partyOrder',
|
reputationGain,
|
||||||
falukantUser.id
|
});
|
||||||
);
|
// Statusbar kann sich damit ebenfalls aktualisieren
|
||||||
if (!moneyResult.success) {
|
notifyUser(user.hashedId, 'falukantUpdateStatus', {});
|
||||||
throw new Error('Geld konnte nicht abgezogen werden');
|
|
||||||
}
|
return { success: true, reputationGain };
|
||||||
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) {
|
async getParties(hashedUserId) {
|
||||||
|
|||||||
@@ -3,6 +3,25 @@ import dotenv from 'dotenv';
|
|||||||
|
|
||||||
dotenv.config();
|
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
|
// Validiere Umgebungsvariablen
|
||||||
const dbName = process.env.DB_NAME;
|
const dbName = process.env.DB_NAME;
|
||||||
const dbUser = process.env.DB_USER;
|
const dbUser = process.env.DB_USER;
|
||||||
@@ -26,6 +45,8 @@ const sequelize = new Sequelize(dbName, dbUser, dbPass, {
|
|||||||
timestamps: false,
|
timestamps: false,
|
||||||
underscored: true // WICHTIG: Alle Datenbankfelder im snake_case Format
|
underscored: true // WICHTIG: Alle Datenbankfelder im snake_case Format
|
||||||
},
|
},
|
||||||
|
benchmark: SQL_BENCHMARK,
|
||||||
|
logging: sqlLogger,
|
||||||
});
|
});
|
||||||
|
|
||||||
const createSchemas = async () => {
|
const createSchemas = async () => {
|
||||||
@@ -380,7 +401,7 @@ const updateSchema = async (models) => {
|
|||||||
console.log('✅ Datenbankschema aktualisiert');
|
console.log('✅ Datenbankschema aktualisiert');
|
||||||
};
|
};
|
||||||
|
|
||||||
async function updateFalukantUserMoney(falukantUserId, moneyChange, activity, changedBy = null) {
|
async function updateFalukantUserMoney(falukantUserId, moneyChange, activity, changedBy = null, transaction = null) {
|
||||||
try {
|
try {
|
||||||
const result = await sequelize.query(
|
const result = await sequelize.query(
|
||||||
`SELECT falukant_data.update_money(
|
`SELECT falukant_data.update_money(
|
||||||
@@ -397,6 +418,7 @@ async function updateFalukantUserMoney(falukantUserId, moneyChange, activity, ch
|
|||||||
changedBy,
|
changedBy,
|
||||||
},
|
},
|
||||||
type: sequelize.QueryTypes.SELECT,
|
type: sequelize.QueryTypes.SELECT,
|
||||||
|
transaction: transaction || undefined,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -20,8 +20,10 @@
|
|||||||
<td>{{ item.quality }}</td>
|
<td>{{ item.quality }}</td>
|
||||||
<td>{{ item.totalQuantity }}</td>
|
<td>{{ item.totalQuantity }}</td>
|
||||||
<td>
|
<td>
|
||||||
<input type="number" v-model.number="item.sellQuantity" :min="1" :max="item.totalQuantity" />
|
<input type="number" v-model.number="item.sellQuantity" :min="1" :max="item.totalQuantity" :disabled="sellingItemIndex === index" />
|
||||||
<button @click="sellItem(index)">{{ $t('falukant.branch.sale.sellButton') }}</button>
|
<button @click="sellItem(index)" :disabled="sellingItemIndex === index || sellingAll">
|
||||||
|
{{ sellingItemIndex === index ? $t('falukant.branch.sale.selling') : $t('falukant.branch.sale.sellButton') }}
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div v-if="item.betterPrices && item.betterPrices.length > 0" class="price-cities">
|
<div v-if="item.betterPrices && item.betterPrices.length > 0" class="price-cities">
|
||||||
@@ -36,7 +38,12 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<p>{{ $t('falukant.branch.sale.noInventory') }}</p>
|
<p>{{ $t('falukant.branch.sale.noInventory') }}</p>
|
||||||
@@ -183,6 +190,9 @@
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
inventory: [],
|
inventory: [],
|
||||||
|
sellingItemIndex: null,
|
||||||
|
sellingAll: false,
|
||||||
|
sellAllStatus: null,
|
||||||
transportForm: {
|
transportForm: {
|
||||||
sourceKey: null,
|
sourceKey: null,
|
||||||
vehicleTypeId: null,
|
vehicleTypeId: null,
|
||||||
@@ -251,13 +261,6 @@
|
|||||||
return new Date(a.eta).getTime() - new Date(b.eta).getTime();
|
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() {
|
async mounted() {
|
||||||
await this.loadInventory();
|
await this.loadInventory();
|
||||||
@@ -274,12 +277,22 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
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() {
|
async loadInventory() {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get(`/api/falukant/inventory/${this.branchId}`);
|
const response = await apiClient.get(`/api/falukant/inventory/${this.branchId}`);
|
||||||
this.inventory = response.data.map(item => ({
|
this.inventory = response.data.map(item => ({
|
||||||
...item,
|
...item,
|
||||||
sellQuantity: item.totalQuantity,
|
sellQuantity: item.totalQuantity,
|
||||||
|
// Vue3: besserPrices direkt als Property setzen (statt this.$set)
|
||||||
|
betterPrices: Array.isArray(item.betterPrices) ? item.betterPrices : [],
|
||||||
}));
|
}));
|
||||||
await this.loadPricesForInventory();
|
await this.loadPricesForInventory();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -300,10 +313,11 @@
|
|||||||
currentPrice: currentPrice
|
currentPrice: currentPrice
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.$set(item, 'betterPrices', data || []);
|
// Vue3: direkte Zuweisung ist reaktiv
|
||||||
|
item.betterPrices = Array.isArray(data) ? data : [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error loading prices for item ${itemKey}:`, error);
|
console.error(`Error loading prices for item ${itemKey}:`, error);
|
||||||
this.$set(item, 'betterPrices', []);
|
item.betterPrices = [];
|
||||||
} finally {
|
} finally {
|
||||||
this.loadingPrices.delete(itemKey);
|
this.loadingPrices.delete(itemKey);
|
||||||
}
|
}
|
||||||
@@ -320,23 +334,61 @@
|
|||||||
maximumFractionDigits: 2,
|
maximumFractionDigits: 2,
|
||||||
}).format(price);
|
}).format(price);
|
||||||
},
|
},
|
||||||
sellItem(index) {
|
async sellItem(index) {
|
||||||
|
if (this.sellingItemIndex !== null || this.sellingAll) return;
|
||||||
|
|
||||||
const item = this.inventory[index];
|
const item = this.inventory[index];
|
||||||
const quantityToSell = item.sellQuantity || item.totalQuantity;
|
const quantityToSell = item.sellQuantity || item.totalQuantity;
|
||||||
apiClient.post(`/api/falukant/sell`, {
|
this.sellingItemIndex = index;
|
||||||
branchId: this.branchId,
|
|
||||||
productId: item.product.id,
|
try {
|
||||||
quantity: quantityToSell,
|
await apiClient.post(`/api/falukant/sell`, {
|
||||||
quality: item.quality,
|
branchId: this.branchId,
|
||||||
}).catch(() => {
|
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'));
|
alert(this.$t('falukant.branch.sale.sellError'));
|
||||||
});
|
} finally {
|
||||||
|
this.sellingItemIndex = null;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
sellAll() {
|
async sellAll() {
|
||||||
apiClient.post(`/api/falukant/sell/all`, { branchId: this.branchId })
|
if (this.sellingAll || this.sellingItemIndex !== null) return;
|
||||||
.catch(() => {
|
|
||||||
alert(this.$t('falukant.branch.sale.sellAllError'));
|
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() {
|
inventoryOptions() {
|
||||||
return this.inventory.map((item, index) => ({
|
return this.inventory.map((item, index) => ({
|
||||||
@@ -590,5 +642,19 @@
|
|||||||
color: #999;
|
color: #999;
|
||||||
font-style: italic;
|
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>
|
</style>
|
||||||
|
|
||||||
@@ -260,6 +260,10 @@
|
|||||||
"sell": "Verkauf",
|
"sell": "Verkauf",
|
||||||
"sellButton": "Verkaufen",
|
"sellButton": "Verkaufen",
|
||||||
"sellAllButton": "Alles 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",
|
"transportTitle": "Transport anlegen",
|
||||||
"transportSource": "Artikel",
|
"transportSource": "Artikel",
|
||||||
"transportSourcePlaceholder": "Artikel wählen",
|
"transportSourcePlaceholder": "Artikel wählen",
|
||||||
@@ -738,7 +742,8 @@
|
|||||||
"reputation": {
|
"reputation": {
|
||||||
"title": "Reputation",
|
"title": "Reputation",
|
||||||
"overview": {
|
"overview": {
|
||||||
"title": "Übersicht"
|
"title": "Übersicht",
|
||||||
|
"current": "Deine aktuelle Reputation"
|
||||||
},
|
},
|
||||||
"party": {
|
"party": {
|
||||||
"title": "Feste",
|
"title": "Feste",
|
||||||
|
|||||||
@@ -198,6 +198,16 @@
|
|||||||
"nobility": {
|
"nobility": {
|
||||||
"cooldown": "You can only advance again on {date}."
|
"cooldown": "You can only advance again on {date}."
|
||||||
},
|
},
|
||||||
|
"reputation": {
|
||||||
|
"title": "Reputation",
|
||||||
|
"overview": {
|
||||||
|
"title": "Overview",
|
||||||
|
"current": "Your current reputation"
|
||||||
|
},
|
||||||
|
"party": {
|
||||||
|
"title": "Parties"
|
||||||
|
}
|
||||||
|
},
|
||||||
"branchProduction": {
|
"branchProduction": {
|
||||||
"storageAvailable": "Free storage"
|
"storageAvailable": "Free storage"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1014,12 +1014,15 @@ export default {
|
|||||||
});
|
});
|
||||||
await this.loadVehicles();
|
await this.loadVehicles();
|
||||||
this.closeRepairAllVehiclesDialog();
|
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();
|
this.$refs.statusBar?.fetchStatus();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error repairing all vehicles:', error);
|
console.error('Error repairing all vehicles:', error);
|
||||||
const errorMessage = error.response?.data?.message || this.$t('falukant.branch.transport.repairAllError');
|
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 apiClient.post(`/api/falukant/vehicles/${this.repairVehicleDialog.vehicle.id}/repair`);
|
||||||
await this.loadVehicles();
|
await this.loadVehicles();
|
||||||
this.closeRepairVehicleDialog();
|
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();
|
this.$refs.statusBar?.fetchStatus();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error repairing vehicle:', error);
|
console.error('Error repairing vehicle:', error);
|
||||||
const errorMessage = error.response?.data?.message || this.$t('falukant.branch.transport.repairError');
|
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'));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,7 +12,10 @@
|
|||||||
|
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
<div v-if="activeTab === 'overview'">
|
<div v-if="activeTab === 'overview'">
|
||||||
<p>Deine aktuelle Reputation: …</p>
|
<p>
|
||||||
|
{{ $t('falukant.reputation.overview.current') }}:
|
||||||
|
<strong>{{ reputationDisplay }}</strong>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="activeTab === 'party'">
|
<div v-else-if="activeTab === 'party'">
|
||||||
@@ -169,7 +172,8 @@ export default {
|
|||||||
selectedNobilityIds: [],
|
selectedNobilityIds: [],
|
||||||
servantRatio: 50,
|
servantRatio: 50,
|
||||||
inProgressParties: [],
|
inProgressParties: [],
|
||||||
completedParties: []
|
completedParties: [],
|
||||||
|
reputation: null,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -198,6 +202,15 @@ export default {
|
|||||||
return partyDate <= twentyFourHoursAgo;
|
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() {
|
async loadNobilityTitles() {
|
||||||
this.nobilityTitles = await apiClient.get('/api/falukant/nobility/titels').then(r => r.data)
|
this.nobilityTitles = await apiClient.get('/api/falukant/nobility/titels').then(r => r.data)
|
||||||
},
|
},
|
||||||
@@ -219,6 +232,10 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
reputationDisplay() {
|
||||||
|
if (this.reputation == null) return '—';
|
||||||
|
return String(this.reputation);
|
||||||
|
},
|
||||||
formattedCost() {
|
formattedCost() {
|
||||||
const type = this.partyTypes.find(t => t.id === this.newPartyTypeId) || {};
|
const type = this.partyTypes.find(t => t.id === this.newPartyTypeId) || {};
|
||||||
const music = this.musicTypes.find(m => m.id === this.musicId) || {};
|
const music = this.musicTypes.find(m => m.id === this.musicId) || {};
|
||||||
@@ -245,6 +262,7 @@ export default {
|
|||||||
await this.loadPartyTypes();
|
await this.loadPartyTypes();
|
||||||
await this.loadNobilityTitles();
|
await this.loadNobilityTitles();
|
||||||
await this.loadParties();
|
await this.loadParties();
|
||||||
|
await this.loadReputation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
1451
package-lock.json
generated
1451
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^7.0.0",
|
"concurrently": "^7.0.0",
|
||||||
"nodemon": "^3.1.10",
|
"nodemon": "^3.1.10",
|
||||||
"npm-run-all": "^4.1.5"
|
"npm-run-all2": "^5.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
|||||||
Reference in New Issue
Block a user