Enhance SaleSection component to manage selling state with improved user feedback. Disable buttons during selling, show status messages for sellAll actions, and update translations for new statuses.
This commit is contained in:
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`
|
||||
|
||||
@@ -20,8 +20,10 @@
|
||||
<td>{{ item.quality }}</td>
|
||||
<td>{{ item.totalQuantity }}</td>
|
||||
<td>
|
||||
<input type="number" v-model.number="item.sellQuantity" :min="1" :max="item.totalQuantity" />
|
||||
<button @click="sellItem(index)">{{ $t('falukant.branch.sale.sellButton') }}</button>
|
||||
<input type="number" v-model.number="item.sellQuantity" :min="1" :max="item.totalQuantity" :disabled="sellingItemIndex === index" />
|
||||
<button @click="sellItem(index)" :disabled="sellingItemIndex === index || sellingAll">
|
||||
{{ sellingItemIndex === index ? $t('falukant.branch.sale.selling') : $t('falukant.branch.sale.sellButton') }}
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<div v-if="item.betterPrices && item.betterPrices.length > 0" class="price-cities">
|
||||
@@ -36,7 +38,12 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<button @click="sellAll">{{ $t('falukant.branch.sale.sellAllButton') }}</button>
|
||||
<button @click="sellAll" :disabled="sellingAll || sellingItemIndex !== null">
|
||||
{{ sellingAll ? $t('falukant.branch.sale.selling') : $t('falukant.branch.sale.sellAllButton') }}
|
||||
</button>
|
||||
<div v-if="sellAllStatus" class="sell-all-status" :class="sellAllStatus.type">
|
||||
{{ sellAllStatus.message }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<p>{{ $t('falukant.branch.sale.noInventory') }}</p>
|
||||
@@ -183,6 +190,9 @@
|
||||
data() {
|
||||
return {
|
||||
inventory: [],
|
||||
sellingItemIndex: null,
|
||||
sellingAll: false,
|
||||
sellAllStatus: null,
|
||||
transportForm: {
|
||||
sourceKey: null,
|
||||
vehicleTypeId: null,
|
||||
@@ -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`, {
|
||||
this.sellingItemIndex = index;
|
||||
|
||||
try {
|
||||
await apiClient.post(`/api/falukant/sell`, {
|
||||
branchId: this.branchId,
|
||||
productId: item.product.id,
|
||||
quantity: quantityToSell,
|
||||
quality: item.quality,
|
||||
}).catch(() => {
|
||||
});
|
||||
// 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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user