From b34b374f76e439b5ba62b91be399a013d40e8da3 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Sat, 20 Dec 2025 22:04:29 +0100 Subject: [PATCH] Refactor sellAllProducts method in FalukantService to ensure atomic transactions for selling products, updating inventory, and handling financial transactions. Implement batch processing for sell items and enhance error handling for inventory deletions. Update updateFalukantUserMoney function to support transactions, improving consistency and reliability in financial operations. --- backend/services/falukantService.js | 269 +++++++++++++++------------- backend/utils/sequelize.js | 3 +- 2 files changed, 145 insertions(+), 127 deletions(-) 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 {