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.

This commit is contained in:
Torsten Schulz (local)
2025-12-20 22:04:29 +01:00
parent 83d1168f26
commit b34b374f76
2 changed files with 145 additions and 127 deletions

View File

@@ -1679,14 +1679,18 @@ class FalukantService extends BaseService {
} }
async sellAllProducts(hashedUserId, branchId) { async sellAllProducts(hashedUserId, branchId) {
// 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 falukantUser = await getFalukantUserOrFail(hashedUserId);
const branch = await Branch.findOne({ const branch = await Branch.findOne({
where: { id: branchId, falukantUserId: falukantUser.id }, where: { id: branchId, falukantUserId: falukantUser.id },
include: [{ model: FalukantStock, as: 'stocks' }] include: [{ model: FalukantStock, as: 'stocks' }],
transaction: t
}); });
if (!branch) throw new Error('Branch not found'); if (!branch) throw new Error('Branch not found');
const stockIds = branch.stocks.map(s => s.id); const stockIds = branch.stocks.map(s => s.id);
const character = await FalukantCharacter.findOne({ where: { userId: falukantUser.id } }); const character = await FalukantCharacter.findOne({ where: { userId: falukantUser.id }, transaction: t });
if (!character) throw new Error('No character for user'); if (!character) throw new Error('No character for user');
const inventory = await Inventory.findAll({ const inventory = await Inventory.findAll({
where: { stockId: stockIds }, where: { stockId: stockIds },
@@ -1719,7 +1723,8 @@ class FalukantService extends BaseService {
} }
] ]
} }
] ],
transaction: t
}); });
if (!inventory.length) return { success: true, revenue: 0 }; if (!inventory.length) return { success: true, revenue: 0 };
@@ -1732,15 +1737,15 @@ class FalukantService extends BaseService {
where: { where: {
productId: { [Op.in]: productIds }, productId: { [Op.in]: productIds },
regionId: { [Op.in]: regionIds } regionId: { [Op.in]: regionIds }
} },
transaction: t
}); });
const worthMap = new Map(); const worthMap = new Map();
townWorths.forEach(tw => { townWorths.forEach(tw => {
worthMap.set(`${tw.productId}-${tw.regionId}`, tw.worthPercent); worthMap.set(`${tw.productId}-${tw.regionId}`, tw.worthPercent);
}); });
// 2. Batch-Load alle RegionData (falls benötigt, aber eigentlich schon in inventory.stock.branch enthalten) // 2/3. Batch-Berechne Steuern für alle Regionen
// 3. Batch-Berechne Steuern für alle Regionen
const taxMap = new Map(); const taxMap = new Map();
const uniqueRegionIds = [...new Set(regionIds)]; const uniqueRegionIds = [...new Set(regionIds)];
for (const regionId of uniqueRegionIds) { for (const regionId of uniqueRegionIds) {
@@ -1758,10 +1763,8 @@ class FalukantService extends BaseService {
const regionId = item.stock.branch.regionId; const regionId = item.stock.branch.regionId;
const worthPercent = worthMap.get(`${item.productType.id}-${regionId}`) || 50; 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); const pricePerUnit = calcRegionalSellPriceSync(item.productType, knowledgeVal, worthPercent);
// Hole Steuer aus Cache/Map
const cumulativeTax = taxMap.get(regionId); const cumulativeTax = taxMap.get(regionId);
const inflationFactor = cumulativeTax >= 100 ? 1 : (1 / (1 - cumulativeTax / 100)); const inflationFactor = cumulativeTax >= 100 ? 1 : (1 / (1 - cumulativeTax / 100));
const adjustedPricePerUnit = Math.round(pricePerUnit * inflationFactor * 100) / 100; const adjustedPricePerUnit = Math.round(pricePerUnit * inflationFactor * 100) / 100;
@@ -1771,7 +1774,6 @@ class FalukantService extends BaseService {
total += itemRevenue; total += itemRevenue;
totalTax += itemTax; totalTax += itemTax;
// Sammle SellItems für Batch-Operation
sellItems.push({ sellItems.push({
branchId: item.stock.branch.id, branchId: item.stock.branch.id,
productId: item.productType.id, productId: item.productType.id,
@@ -1781,31 +1783,44 @@ class FalukantService extends BaseService {
const totalNet = Math.round((total - totalTax) * 100) / 100; const totalNet = Math.round((total - totalTax) * 100) / 100;
// 5. Batch-Update DaySell Einträge // 5. Batch-Update DaySell Einträge (innerhalb Transaktion)
await this.addSellItemsBatch(sellItems, falukantUser.id); 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( const moneyResult = await updateFalukantUserMoney(
falukantUser.id, falukantUser.id,
totalNet, totalNet,
'Sell all products (net)', 'Sell all products (net)',
falukantUser.id falukantUser.id,
t
); );
if (!moneyResult.success) throw new Error('Failed to update money for seller'); if (!moneyResult.success) throw new Error('Failed to update money for seller');
const treasuryId = process.env.TREASURY_FALUKANT_USER_ID; const treasuryId = process.env.TREASURY_FALUKANT_USER_ID;
if (treasuryId && totalTax > 0) { if (treasuryId && totalTax > 0) {
const taxResult = await updateFalukantUserMoney(parseInt(treasuryId, 10), Math.round(totalTax * 100) / 100, `Sales tax (aggregate)`, falukantUser.id); 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'); if (!taxResult.success) throw new Error('Failed to update money for treasury');
} }
// Batch-Delete Inventory Items console.log('[FalukantService.sellAllProducts] sold items', expected, 'deleted', deleted, 'revenue', total);
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, 'falukantUpdateStatus', {});
notifyUser(falukantUser.user.hashedId, 'falukantBranchUpdate', { branchId }); notifyUser(falukantUser.user.hashedId, 'falukantBranchUpdate', { branchId });
return { success: true, revenue: total }; return { success: true, revenue: total };
});
} }
async addSellItem(branchId, userId, productId, quantity) { 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; if (!sellItems || sellItems.length === 0) return;
// Lade alle benötigten Branches auf einmal // Lade alle benötigten Branches auf einmal
const branchIds = [...new Set(sellItems.map(item => item.branchId))]; const branchIds = [...new Set(sellItems.map(item => item.branchId))];
const branches = await Branch.findAll({ const branches = await Branch.findAll({
where: { id: { [Op.in]: branchIds } }, where: { id: { [Op.in]: branchIds } },
attributes: ['id', 'regionId'] attributes: ['id', 'regionId'],
transaction: transaction || undefined
}); });
const branchMap = new Map(branches.map(b => [b.id, b])); const branchMap = new Map(branches.map(b => [b.id, b]));
// WICHTIG: Wie bei addSellItem muss ein fehlender Branch ein harter Fehler sein, // WICHTIG: Wie bei addSellItem muss ein fehlender Branch ein harter Fehler sein,
@@ -1883,11 +1899,12 @@ class FalukantService extends BaseService {
productId: data.productId, productId: data.productId,
sellerId: data.sellerId sellerId: data.sellerId
}, },
defaults: { quantity: data.quantity } defaults: { quantity: data.quantity },
transaction: transaction || undefined
}).then(([daySell, created]) => { }).then(([daySell, created]) => {
if (!created) { if (!created) {
daySell.quantity += data.quantity; daySell.quantity += data.quantity;
return daySell.save(); return daySell.save({ transaction: transaction || undefined });
} }
}) })
); );

View File

@@ -401,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(
@@ -418,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 {