diff --git a/backend/services/falukantService.js b/backend/services/falukantService.js index 6cb1a7f..5232c1b 100644 --- a/backend/services/falukantService.js +++ b/backend/services/falukantService.js @@ -1679,133 +1679,148 @@ class FalukantService extends BaseService { } async sellAllProducts(hashedUserId, branchId) { - const falukantUser = await getFalukantUserOrFail(hashedUserId); - const branch = await Branch.findOne({ - where: { id: branchId, falukantUserId: falukantUser.id }, - include: [{ model: FalukantStock, as: 'stocks' }] - }); - if (!branch) throw new Error('Branch not found'); - const stockIds = branch.stocks.map(s => s.id); - const character = await FalukantCharacter.findOne({ where: { userId: falukantUser.id } }); - if (!character) throw new Error('No character for user'); - const inventory = await Inventory.findAll({ - where: { stockId: stockIds }, - include: [ - { - model: ProductType, - as: 'productType', - include: [ - { - model: Knowledge, - as: 'knowledges', - required: false, - where: { - characterId: character.id - } - } - ] - }, - { - model: FalukantStock, - as: 'stock', - include: [ - { - model: Branch, - as: 'branch' - }, - { - model: FalukantStockType, - as: 'stockType' - } - ] - } - ] - }); - 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 } - } - }); - const worthMap = new Map(); - townWorths.forEach(tw => { - worthMap.set(`${tw.productId}-${tw.regionId}`, tw.worthPercent); - }); - - // 2. Batch-Load alle RegionData (falls benötigt, aber eigentlich schon in inventory.stock.branch enthalten) - // 3. Batch-Berechne Steuern für alle Regionen - const taxMap = new Map(); - const uniqueRegionIds = [...new Set(regionIds)]; - for (const regionId of uniqueRegionIds) { - const tax = await getCumulativeTaxPercentWithExemptions(falukantUser.id, regionId); - taxMap.set(regionId, tax); - } - - // 4. Berechne Preise, Steuern und Einnahmen in EINER Schleife - let total = 0; - let totalTax = 0; - const sellItems = []; - - for (const item of inventory) { - const knowledgeVal = item.productType.knowledges[0]?.knowledge || 0; - const regionId = item.stock.branch.regionId; - const worthPercent = worthMap.get(`${item.productType.id}-${regionId}`) || 50; - - // Berechne Preis (ohne DB-Query, da worthPercent bereits geladen) - const pricePerUnit = calcRegionalSellPriceSync(item.productType, knowledgeVal, worthPercent); - - // Hole Steuer aus Cache/Map - 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; - - // Sammle SellItems für Batch-Operation - sellItems.push({ - branchId: item.stock.branch.id, - productId: item.productType.id, - quantity: item.quantity + // Konsistenz-Garantie: Verkauf, DaySell-Log, Geldbuchung und Inventory-Löschung müssen atomar sein. + // Sonst kann es (wie beobachtet) zu "teilweise verkauft/gelöscht" kommen. + return await sequelize.transaction(async (t) => { + const falukantUser = await getFalukantUserOrFail(hashedUserId); + const branch = await Branch.findOne({ + where: { id: branchId, falukantUserId: falukantUser.id }, + include: [{ model: FalukantStock, as: 'stocks' }], + transaction: t }); - } + if (!branch) throw new Error('Branch not found'); + const stockIds = branch.stocks.map(s => s.id); + const character = await FalukantCharacter.findOne({ where: { userId: falukantUser.id }, transaction: t }); + if (!character) throw new Error('No character for user'); + const inventory = await Inventory.findAll({ + where: { stockId: stockIds }, + include: [ + { + model: ProductType, + as: 'productType', + include: [ + { + model: Knowledge, + as: 'knowledges', + required: false, + where: { + characterId: character.id + } + } + ] + }, + { + model: FalukantStock, + as: 'stock', + include: [ + { + model: Branch, + as: 'branch' + }, + { + model: FalukantStockType, + as: 'stockType' + } + ] + } + ], + transaction: t + }); + if (!inventory.length) return { success: true, revenue: 0 }; - const totalNet = Math.round((total - totalTax) * 100) / 100; + // 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))]; - // 5. Batch-Update DaySell Einträge - await this.addSellItemsBatch(sellItems, falukantUser.id); + // 1. Batch-Load alle TownProductWorth Einträge + const townWorths = await TownProductWorth.findAll({ + where: { + productId: { [Op.in]: productIds }, + regionId: { [Op.in]: regionIds } + }, + transaction: t + }); + const worthMap = new Map(); + townWorths.forEach(tw => { + worthMap.set(`${tw.productId}-${tw.regionId}`, tw.worthPercent); + }); - 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'); + // 2/3. Batch-Berechne Steuern für alle Regionen + const taxMap = new Map(); + const uniqueRegionIds = [...new Set(regionIds)]; + for (const regionId of uniqueRegionIds) { + const tax = await getCumulativeTaxPercentWithExemptions(falukantUser.id, regionId); + taxMap.set(regionId, tax); + } - 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'); - } - - // Batch-Delete Inventory Items - const inventoryIds = inventory.map(item => item.id); - await Inventory.destroy({ where: { id: { [Op.in]: inventoryIds } } }); - - 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 }; + // 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 }; + }); } async addSellItem(branchId, userId, productId, quantity) { @@ -1833,14 +1848,15 @@ class FalukantService extends BaseService { } } - async addSellItemsBatch(sellItems, userId) { + 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'] + 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, @@ -1883,11 +1899,12 @@ class FalukantService extends BaseService { productId: data.productId, sellerId: data.sellerId }, - defaults: { quantity: data.quantity } + defaults: { quantity: data.quantity }, + transaction: transaction || undefined }).then(([daySell, created]) => { if (!created) { daySell.quantity += data.quantity; - return daySell.save(); + return daySell.save({ transaction: transaction || undefined }); } }) ); diff --git a/backend/utils/sequelize.js b/backend/utils/sequelize.js index ee8eade..69c47c4 100644 --- a/backend/utils/sequelize.js +++ b/backend/utils/sequelize.js @@ -401,7 +401,7 @@ const updateSchema = async (models) => { console.log('✅ Datenbankschema aktualisiert'); }; -async function updateFalukantUserMoney(falukantUserId, moneyChange, activity, changedBy = null) { +async function updateFalukantUserMoney(falukantUserId, moneyChange, activity, changedBy = null, transaction = null) { try { const result = await sequelize.query( `SELECT falukant_data.update_money( @@ -418,6 +418,7 @@ async function updateFalukantUserMoney(falukantUserId, moneyChange, activity, ch changedBy, }, type: sequelize.QueryTypes.SELECT, + transaction: transaction || undefined, } ); return {