diff --git a/PERFORMANCE_ANALYSIS.md b/PERFORMANCE_ANALYSIS.md new file mode 100644 index 0000000..ce457b3 --- /dev/null +++ b/PERFORMANCE_ANALYSIS.md @@ -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 + diff --git a/SELL_OVERVIEW.md b/SELL_OVERVIEW.md new file mode 100644 index 0000000..2c63908 --- /dev/null +++ b/SELL_OVERVIEW.md @@ -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` + diff --git a/frontend/src/components/falukant/SaleSection.vue b/frontend/src/components/falukant/SaleSection.vue index c481234..256575a 100644 --- a/frontend/src/components/falukant/SaleSection.vue +++ b/frontend/src/components/falukant/SaleSection.vue @@ -20,8 +20,10 @@
{{ $t('falukant.branch.sale.noInventory') }}
@@ -183,6 +190,9 @@ data() { return { inventory: [], + sellingItemIndex: null, + sellingAll: false, + sellAllStatus: null, transportForm: { sourceKey: null, vehicleTypeId: null, @@ -320,23 +330,55 @@ maximumFractionDigits: 2, }).format(price); }, - sellItem(index) { + async sellItem(index) { + if (this.sellingItemIndex !== null || this.sellingAll) return; + const item = this.inventory[index]; const quantityToSell = item.sellQuantity || item.totalQuantity; - apiClient.post(`/api/falukant/sell`, { - branchId: this.branchId, - productId: item.product.id, - quantity: quantityToSell, - quality: item.quality, - }).catch(() => { + this.sellingItemIndex = index; + + try { + await apiClient.post(`/api/falukant/sell`, { + branchId: this.branchId, + productId: item.product.id, + quantity: quantityToSell, + quality: item.quality, + }); + // Inventory neu laden nach erfolgreichem Verkauf + await this.loadInventory(); + } catch (error) { alert(this.$t('falukant.branch.sale.sellError')); - }); + } finally { + this.sellingItemIndex = null; + } }, - sellAll() { - apiClient.post(`/api/falukant/sell/all`, { branchId: this.branchId }) - .catch(() => { - alert(this.$t('falukant.branch.sale.sellAllError')); - }); + async sellAll() { + if (this.sellingAll || this.sellingItemIndex !== null) return; + + this.sellingAll = true; + this.sellAllStatus = null; + + try { + const response = await apiClient.post(`/api/falukant/sell/all`, { branchId: this.branchId }); + const revenue = response.data?.revenue || 0; + 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) { + this.sellAllStatus = { + type: 'error', + message: this.$t('falukant.branch.sale.sellAllError') + }; + } finally { + this.sellingAll = false; + // Status nach 5 Sekunden löschen + setTimeout(() => { + this.sellAllStatus = null; + }, 5000); + } }, inventoryOptions() { return this.inventory.map((item, index) => ({ @@ -590,5 +632,20 @@ color: #999; font-style: italic; } + .sell-all-status { + margin-top: 10px; + padding: 8px; + border-radius: 4px; + } + .sell-all-status.success { + background-color: #d4edda; + color: #155724; + border: 1px solid #c3e6cb; + } + .sell-all-status.error { + background-color: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; + } \ No newline at end of file diff --git a/frontend/src/i18n/locales/de/falukant.json b/frontend/src/i18n/locales/de/falukant.json index e6d532a..a809d42 100644 --- a/frontend/src/i18n/locales/de/falukant.json +++ b/frontend/src/i18n/locales/de/falukant.json @@ -260,6 +260,10 @@ "sell": "Verkauf", "sellButton": "Verkaufen", "sellAllButton": "Alles verkaufen", + "selling": "Verkauf läuft...", + "sellError": "Fehler beim Verkauf des Produkts.", + "sellAllError": "Fehler beim Verkauf aller Produkte.", + "sellAllSuccess": "Alle Produkte wurden erfolgreich verkauft. Einnahmen: {revenue}" "transportTitle": "Transport anlegen", "transportSource": "Artikel", "transportSourcePlaceholder": "Artikel wählen",