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,133 +1679,148 @@ class FalukantService extends BaseService {
} }
async sellAllProducts(hashedUserId, branchId) { async sellAllProducts(hashedUserId, branchId) {
const falukantUser = await getFalukantUserOrFail(hashedUserId); // Konsistenz-Garantie: Verkauf, DaySell-Log, Geldbuchung und Inventory-Löschung müssen atomar sein.
const branch = await Branch.findOne({ // Sonst kann es (wie beobachtet) zu "teilweise verkauft/gelöscht" kommen.
where: { id: branchId, falukantUserId: falukantUser.id }, return await sequelize.transaction(async (t) => {
include: [{ model: FalukantStock, as: 'stocks' }] const falukantUser = await getFalukantUserOrFail(hashedUserId);
}); const branch = await Branch.findOne({
if (!branch) throw new Error('Branch not found'); where: { id: branchId, falukantUserId: falukantUser.id },
const stockIds = branch.stocks.map(s => s.id); include: [{ model: FalukantStock, as: 'stocks' }],
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'
}
]
}
]
});
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
}); });
} 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 // 1. Batch-Load alle TownProductWorth Einträge
await this.addSellItemsBatch(sellItems, falukantUser.id); 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( // 2/3. Batch-Berechne Steuern für alle Regionen
falukantUser.id, const taxMap = new Map();
totalNet, const uniqueRegionIds = [...new Set(regionIds)];
'Sell all products (net)', for (const regionId of uniqueRegionIds) {
falukantUser.id const tax = await getCumulativeTaxPercentWithExemptions(falukantUser.id, regionId);
); taxMap.set(regionId, tax);
if (!moneyResult.success) throw new Error('Failed to update money for seller'); }
const treasuryId = process.env.TREASURY_FALUKANT_USER_ID; // 4. Berechne Preise, Steuern und Einnahmen in EINER Schleife
if (treasuryId && totalTax > 0) { let total = 0;
const taxResult = await updateFalukantUserMoney(parseInt(treasuryId, 10), Math.round(totalTax * 100) / 100, `Sales tax (aggregate)`, falukantUser.id); let totalTax = 0;
if (!taxResult.success) throw new Error('Failed to update money for treasury'); const sellItems = [];
}
// Batch-Delete Inventory Items for (const item of inventory) {
const inventoryIds = inventory.map(item => item.id); const knowledgeVal = item.productType.knowledges[0]?.knowledge || 0;
await Inventory.destroy({ where: { id: { [Op.in]: inventoryIds } } }); const regionId = item.stock.branch.regionId;
const worthPercent = worthMap.get(`${item.productType.id}-${regionId}`) || 50;
console.log('[FalukantService.sellAllProducts] emitting events for user', falukantUser.user.hashedId, 'branch', branchId, 'revenue', total, 'items', inventory.length); const pricePerUnit = calcRegionalSellPriceSync(item.productType, knowledgeVal, worthPercent);
notifyUser(falukantUser.user.hashedId, 'falukantUpdateStatus', {});
notifyUser(falukantUser.user.hashedId, 'falukantBranchUpdate', { branchId }); const cumulativeTax = taxMap.get(regionId);
return { success: true, revenue: total }; 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) { 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 {