-- ------------------------------------------------------------------------------ -- -- TradeSkillMaster -- -- https://tradeskillmaster.com -- -- All Rights Reserved - Detailed license information included with addon. -- -- ------------------------------------------------------------------------------ -- local _, TSM = ... local Transactions = TSM.Accounting:NewPackage("Transactions") local L = TSM.Include("Locale").GetTable() local Database = TSM.Include("Util.Database") local TempTable = TSM.Include("Util.TempTable") local CSV = TSM.Include("Util.CSV") local Math = TSM.Include("Util.Math") local String = TSM.Include("Util.String") local Log = TSM.Include("Util.Log") local Table = TSM.Include("Util.Table") local ItemString = TSM.Include("Util.ItemString") local Theme = TSM.Include("Util.Theme") local CustomPrice = TSM.Include("Service.CustomPrice") local ItemInfo = TSM.Include("Service.ItemInfo") local Inventory = TSM.Include("Service.Inventory") local Settings = TSM.Include("Service.Settings") local Threading = TSM.Include("Service.Threading") local private = { db = nil, dbSummary = nil, dataChanged = false, baseStatsQuery = nil, statsQuery = nil, baseStatsMinTimeQuery = nil, statsMinTimeQuery = nil, syncHashesThread = nil, isSyncHashesThreadRunning = false, syncHashDayCache = {}, syncHashDayCacheIsInvalid = {}, pendingSyncHashCharacters = {}, } local OLD_CSV_KEYS = { sale = { "itemString", "stackSize", "quantity", "price", "buyer", "player", "time", "source" }, buy = { "itemString", "stackSize", "quantity", "price", "seller", "player", "time", "source" }, } local CSV_KEYS = { "itemString", "stackSize", "quantity", "price", "otherPlayer", "player", "time", "source" } local COMBINE_TIME_THRESHOLD = 300 -- group transactions within 5 minutes together local MAX_CSV_RECORDS = 55000 -- the max number of records we can store without WoW corrupting the SV file local TRIMMED_CSV_RECORDS = 50000 -- how many records to trim to if we're over the limit (so we don't trim every time) local SECONDS_PER_DAY = 24 * 60 * 60 local SYNC_FIELDS = { "type", "itemString", "stackSize", "quantity", "price", "otherPlayer", "time", "source", "saveTime" } -- ============================================================================ -- Module Functions -- ============================================================================ function Transactions.OnInitialize() if TSM.db.realm.internalData.accountingTrimmed.sales then Log.PrintfUser(L["%sIMPORTANT:|r When Accounting data was last saved for this realm, it was too big for WoW to handle, so old data was automatically trimmed in order to avoid corruption of the saved variables. The last %s of sale data has been preserved."], Theme.GetFeedbackColor("RED"):GetTextColorPrefix(), SecondsToTime(time() - TSM.db.realm.internalData.accountingTrimmed.sales)) TSM.db.realm.internalData.accountingTrimmed.sales = nil end if TSM.db.realm.internalData.accountingTrimmed.buys then Log.PrintfUser(L["%sIMPORTANT:|r When Accounting data was last saved for this realm, it was too big for WoW to handle, so old data was automatically trimmed in order to avoid corruption of the saved variables. The last %s of purchase data has been preserved."], Theme.GetFeedbackColor("RED"):GetTextColorPrefix(), SecondsToTime(time() - TSM.db.realm.internalData.accountingTrimmed.buys)) TSM.db.realm.internalData.accountingTrimmed.buys = nil end private.db = Database.NewSchema("TRANSACTIONS_LOG") :AddStringField("baseItemString") :AddStringField("type") :AddStringField("itemString") :AddNumberField("stackSize") :AddNumberField("quantity") :AddNumberField("price") :AddStringField("otherPlayer") :AddStringField("player") :AddNumberField("time") :AddStringField("source") :AddNumberField("saveTime") :AddIndex("baseItemString") :AddIndex("time") :Commit() private.db:BulkInsertStart() private.LoadData("sale", TSM.db.realm.internalData.csvSales, TSM.db.realm.internalData.saveTimeSales) private.LoadData("buy", TSM.db.realm.internalData.csvBuys, TSM.db.realm.internalData.saveTimeBuys) private.db:BulkInsertEnd() private.dbSummary = Database.NewSchema("TRANSACTIONS_SUMMARY") :AddUniqueStringField("itemString") :AddNumberField("sold") :AddNumberField("avgSellPrice") :AddNumberField("bought") :AddNumberField("avgBuyPrice") :AddNumberField("avgProfit") :AddNumberField("totalProfit") :AddNumberField("profitPct") :Commit() private.baseStatsQuery = private.db:NewQuery() :Select("quantity", "price") :Equal("type", Database.BoundQueryParam()) :Equal("baseItemString", Database.BoundQueryParam()) :NotEqual("source", "Vendor") private.statsQuery = private.db:NewQuery() :Select("quantity", "price") :Equal("type", Database.BoundQueryParam()) :Equal("baseItemString", Database.BoundQueryParam()) :Equal("itemString", Database.BoundQueryParam()) :NotEqual("source", "Vendor") private.baseStatsMinTimeQuery = private.db:NewQuery() :Select("quantity", "price") :Equal("type", Database.BoundQueryParam()) :Equal("baseItemString", Database.BoundQueryParam()) :GreaterThanOrEqual("time", Database.BoundQueryParam()) :NotEqual("source", "Vendor") private.statsMinTimeQuery = private.db:NewQuery() :Select("quantity", "price") :Equal("type", Database.BoundQueryParam()) :Equal("baseItemString", Database.BoundQueryParam()) :Equal("itemString", Database.BoundQueryParam()) :GreaterThanOrEqual("time", Database.BoundQueryParam()) :NotEqual("source", "Vendor") private.syncHashesThread = Threading.New("TRANSACTIONS_SYNC_HASHES", private.SyncHashesThread) Inventory.RegisterCallback(private.InventoryCallback) end function Transactions.OnDisable() if not private.dataChanged then -- nothing changed, so just keep the previous saved values return end TSM.db.realm.internalData.csvSales, TSM.db.realm.internalData.saveTimeSales, TSM.db.realm.internalData.accountingTrimmed.sales = private.SaveData("sale") TSM.db.realm.internalData.csvBuys, TSM.db.realm.internalData.saveTimeBuys, TSM.db.realm.internalData.accountingTrimmed.buys = private.SaveData("buy") end function Transactions.InsertAuctionSale(itemString, stackSize, price, buyer, timestamp) private.InsertRecord("sale", itemString, "Auction", stackSize, price, buyer, timestamp) end function Transactions.InsertAuctionBuy(itemString, stackSize, price, seller, timestamp) private.InsertRecord("buy", itemString, "Auction", stackSize, price, seller, timestamp) end function Transactions.InsertCODSale(itemString, stackSize, price, buyer, timestamp) private.InsertRecord("sale", itemString, "COD", stackSize, price, buyer, timestamp) end function Transactions.InsertCODBuy(itemString, stackSize, price, seller, timestamp) private.InsertRecord("buy", itemString, "COD", stackSize, price, seller, timestamp) end function Transactions.InsertTradeSale(itemString, stackSize, price, buyer) private.InsertRecord("sale", itemString, "Trade", stackSize, price, buyer, time()) end function Transactions.InsertTradeBuy(itemString, stackSize, price, seller) private.InsertRecord("buy", itemString, "Trade", stackSize, price, seller, time()) end function Transactions.InsertVendorSale(itemString, stackSize, price) private.InsertRecord("sale", itemString, "Vendor", stackSize, price, "Merchant", time()) end function Transactions.InsertVendorBuy(itemString, stackSize, price) private.InsertRecord("buy", itemString, "Vendor", stackSize, price, "Merchant", time()) end function Transactions.CreateQuery() return private.db:NewQuery() end function Transactions.RemoveOldData(days) private.dataChanged = true private.db:SetQueryUpdatesPaused(true) local numRecords = private.db:NewQuery() :LessThan("time", time() - days * SECONDS_PER_DAY) :DeleteAndRelease() private.db:SetQueryUpdatesPaused(false) private.OnItemRecordsChanged("sale") private.OnItemRecordsChanged("buy") TSM.Accounting.Sync.OnTransactionsChanged() return numRecords end function Transactions.GetSaleStats(itemString, minTime) local baseItemString = ItemString.GetBase(itemString) local isBaseItemString = itemString == baseItemString local query = nil if minTime then if isBaseItemString then query = private.baseStatsMinTimeQuery:BindParams("sale", baseItemString, minTime) else query = private.statsMinTimeQuery:BindParams("sale", baseItemString, itemString, minTime) end else if isBaseItemString then query = private.baseStatsQuery:BindParams("sale", baseItemString) else query = private.statsQuery:BindParams("sale", baseItemString, itemString) end end query:ResetOrderBy() local totalPrice = query:SumOfProduct("quantity", "price") local totalNum = query:Sum("quantity") if not totalNum or totalNum == 0 then return end return totalPrice, totalNum end function Transactions.GetBuyStats(itemString, isSmart) local baseItemString = ItemString.GetBaseFast(itemString) local isBaseItemString = itemString == baseItemString local query = nil if isBaseItemString then query = private.baseStatsQuery:BindParams("buy", baseItemString) else query = private.statsQuery:BindParams("buy", baseItemString, itemString) end query:ResetOrderBy() if isSmart then local totalQuantity = CustomPrice.GetItemPrice(itemString, "NumInventory") or 0 if totalQuantity == 0 then return nil, nil end query:OrderBy("time", false) local remainingSmartQuantity = totalQuantity local priceSum, quantitySum = 0, 0 for _, quantity, price in query:Iterator() do if remainingSmartQuantity > 0 then quantity = min(remainingSmartQuantity, quantity) remainingSmartQuantity = remainingSmartQuantity - quantity priceSum = priceSum + price * quantity quantitySum = quantitySum + quantity end end if priceSum == 0 then return nil, nil end return priceSum, quantitySum else local quantitySum = query:Sum("quantity") if not quantitySum then return nil, nil end local priceSum = query:SumOfProduct("quantity", "price") if priceSum == 0 then return nil, nil end return priceSum, quantitySum end end function Transactions.GetMaxSalePrice(itemString) local baseItemString = ItemString.GetBase(itemString) local isBaseItemString = itemString == baseItemString local query = private.db:NewQuery() :Select("price") :Equal("type", "sale") :NotEqual("source", "Vendor") :OrderBy("price", false) if isBaseItemString then query:Equal("baseItemString", itemString) else query:Equal("baseItemString", baseItemString) :Equal("itemString", itemString) end return query:GetFirstResultAndRelease() end function Transactions.GetMaxBuyPrice(itemString) local baseItemString = ItemString.GetBase(itemString) local isBaseItemString = itemString == baseItemString local query = private.db:NewQuery() :Select("price") :Equal("type", "buy") :NotEqual("source", "Vendor") :OrderBy("price", false) if isBaseItemString then query:Equal("baseItemString", itemString) else query:Equal("baseItemString", baseItemString) :Equal("itemString", itemString) end return query:GetFirstResultAndRelease() end function Transactions.GetMinSalePrice(itemString) local baseItemString = ItemString.GetBase(itemString) local isBaseItemString = itemString == baseItemString local query = private.db:NewQuery() :Select("price") :Equal("type", "sale") :NotEqual("source", "Vendor") :OrderBy("price", true) if isBaseItemString then query:Equal("baseItemString", itemString) else query:Equal("baseItemString", baseItemString) :Equal("itemString", itemString) end return query:GetFirstResultAndRelease() end function Transactions.GetMinBuyPrice(itemString) local baseItemString = ItemString.GetBase(itemString) local isBaseItemString = itemString == baseItemString local query = private.db:NewQuery() :Select("price") :Equal("type", "buy") :NotEqual("source", "Vendor") :OrderBy("price", true) if isBaseItemString then query:Equal("baseItemString", itemString) else query:Equal("baseItemString", baseItemString) :Equal("itemString", itemString) end return query:GetFirstResultAndRelease() end function Transactions.GetAverageSalePrice(itemString) local totalPrice, totalNum = Transactions.GetSaleStats(itemString) if not totalPrice or totalPrice == 0 then return end return Math.Round(totalPrice / totalNum), totalNum end function Transactions.GetAverageBuyPrice(itemString, isSmart) local totalPrice, totalNum = Transactions.GetBuyStats(itemString, isSmart) return totalPrice and Math.Round(totalPrice / totalNum) or nil end function Transactions.GetLastSaleTime(itemString) local baseItemString = ItemString.GetBase(itemString) local isBaseItemString = itemString == baseItemString local query = private.db:NewQuery() :Select("time") :Equal("type", "sale") :NotEqual("source", "Vendor") :OrderBy("time", false) if isBaseItemString then query:Equal("baseItemString", itemString) else query:Equal("baseItemString", baseItemString) :Equal("itemString", itemString) end return query:GetFirstResultAndRelease() end function Transactions.GetLastBuyTime(itemString) local baseItemString = ItemString.GetBase(itemString) local isBaseItemString = itemString == baseItemString local query = private.db:NewQuery():Select("time") :Equal("type", "buy") :NotEqual("source", "Vendor") :OrderBy("time", false) if isBaseItemString then query:Equal("baseItemString", itemString) else query:Equal("baseItemString", baseItemString) :Equal("itemString", itemString) end return query:GetFirstResultAndRelease() end function Transactions.GetQuantity(itemString, timeFilter, typeFilter) local baseItemString = ItemString.GetBase(itemString) local isBaseItemString = itemString == baseItemString local query = private.db:NewQuery() :Equal("type", typeFilter) if isBaseItemString then query:Equal("baseItemString", itemString) else query:Equal("baseItemString", baseItemString) :Equal("itemString", itemString) end if timeFilter then query:GreaterThan("time", time() - timeFilter) end local sum = query:Sum("quantity") or 0 query:Release() return sum end function Transactions.GetAveragePrice(itemString, timeFilter, typeFilter) local baseItemString = ItemString.GetBase(itemString) local isBaseItemString = itemString == baseItemString local query = private.db:NewQuery() :Select("price", "quantity") :Equal("type", typeFilter) if isBaseItemString then query:Equal("baseItemString", itemString) else query:Equal("baseItemString", baseItemString) :Equal("itemString", itemString) end if timeFilter then query:GreaterThan("time", time() - timeFilter) end local avgPrice = 0 local totalQuantity = 0 for _, price, quantity in query:IteratorAndRelease() do avgPrice = avgPrice + price * quantity totalQuantity = totalQuantity + quantity end return Math.Round(avgPrice / totalQuantity) end function Transactions.GetTotalPrice(itemString, timeFilter, typeFilter) local baseItemString = ItemString.GetBase(itemString) local isBaseItemString = itemString == baseItemString local query = private.db:NewQuery() :Select("price", "quantity") :Equal("type", typeFilter) if isBaseItemString then query:Equal("baseItemString", itemString) else query:Equal("baseItemString", baseItemString) :Equal("itemString", itemString) end if timeFilter then query:GreaterThan("time", time() - timeFilter) end local sumPrice = query:SumOfProduct("price", "quantity") or 0 query:Release() return sumPrice end function Transactions.CreateSummaryQuery() return private.dbSummary:NewQuery() end function Transactions.UpdateSummaryData(groupFilter, searchFilter, typeFilter, characterFilter, minTime) local totalSold = TempTable.Acquire() local totalSellPrice = TempTable.Acquire() local totalBought = TempTable.Acquire() local totalBoughtPrice = TempTable.Acquire() local items = private.db:NewQuery() :Select("itemString", "price", "quantity", "type") :LeftJoin(TSM.Groups.GetItemDBForJoin(), "itemString") :InnerJoin(ItemInfo.GetDBForJoin(), "itemString") if groupFilter then items:InTable("groupPath", groupFilter) end if searchFilter then items:Matches("name", String.Escape(searchFilter)) end if typeFilter then items:InTable("source", typeFilter) end if characterFilter then items:InTable("player", characterFilter) end if minTime then items:GreaterThan("time", minTime) end for _, itemString, price, quantity, recordType in items:IteratorAndRelease() do if not totalSold[itemString] then totalSold[itemString] = 0 totalSellPrice[itemString] = 0 totalBought[itemString] = 0 totalBoughtPrice[itemString] = 0 end if recordType == "sale" then totalSold[itemString] = totalSold[itemString] + quantity totalSellPrice[itemString] = totalSellPrice[itemString] + price * quantity elseif recordType == "buy" then totalBought[itemString] = totalBought[itemString] + quantity totalBoughtPrice[itemString] = totalBoughtPrice[itemString] + price * quantity else error("Invalid recordType: "..tostring(recordType)) end end private.dbSummary:TruncateAndBulkInsertStart() for itemString, sold in pairs(totalSold) do if sold > 0 and totalBought[itemString] > 0 then local totalAvgSellPrice = totalSellPrice[itemString] / totalSold[itemString] local totalAvgBuyPrice = totalBoughtPrice[itemString] / totalBought[itemString] local profit = totalAvgSellPrice - totalAvgBuyPrice local totalProfit = profit * min(totalSold[itemString], totalBought[itemString]) local profitPct = Math.Round(profit * 100 / totalAvgBuyPrice) private.dbSummary:BulkInsertNewRow(itemString, sold, totalAvgSellPrice, totalBought[itemString], totalAvgBuyPrice, profit, totalProfit, profitPct) end end private.dbSummary:BulkInsertEnd() TempTable.Release(totalSold) TempTable.Release(totalSellPrice) TempTable.Release(totalBought) TempTable.Release(totalBoughtPrice) end function Transactions.GetCharacters(characters) private.db:NewQuery() :Distinct("player") :Select("player") :AsTable(characters) :Release() return characters end function Transactions.CanDeleteByUUID(uuid) return Settings.IsCurrentAccountOwner(private.db:GetRowFieldByUUID(uuid, "player")) end function Transactions.RemoveRowByUUID(uuid) local recordType = private.db:GetRowFieldByUUID(uuid, "type") local itemString = private.db:GetRowFieldByUUID(uuid, "itemString") local player = private.db:GetRowFieldByUUID(uuid, "player") private.db:DeleteRowByUUID(uuid) if private.syncHashDayCache[player] then private.syncHashDayCacheIsInvalid[player] = true end private.dataChanged = true private.OnItemRecordsChanged(recordType, itemString) TSM.Accounting.Sync.OnTransactionsChanged() end function Transactions.PrepareSyncHashes(player) tinsert(private.pendingSyncHashCharacters, player) if not private.isSyncHashesThreadRunning then private.isSyncHashesThreadRunning = true Threading.Start(private.syncHashesThread) end end function Transactions.GetSyncHash(player) local hashesByDay = Transactions.GetSyncHashByDay(player) if not hashesByDay then return end return Math.CalculateHash(hashesByDay) end function Transactions.GetSyncHashByDay(player) if not private.syncHashDayCache[player] or private.syncHashDayCacheIsInvalid[player] then return end return private.syncHashDayCache[player] end function Transactions.GetSyncData(player, day, result) local query = private.db:NewQuery() :Equal("player", player) :GreaterThanOrEqual("time", day * SECONDS_PER_DAY) :LessThan("time", (day + 1) * SECONDS_PER_DAY) for _, row in query:Iterator() do Table.Append(result, row:GetFields(unpack(SYNC_FIELDS))) end query:Release() end function Transactions.RemovePlayerDay(player, day) private.dataChanged = true private.db:SetQueryUpdatesPaused(true) local query = private.db:NewQuery() :Equal("player", player) :GreaterThanOrEqual("time", day * SECONDS_PER_DAY) :LessThan("time", (day + 1) * SECONDS_PER_DAY) for _, uuid in query:UUIDIterator() do private.db:DeleteRowByUUID(uuid) end query:Release() if private.syncHashDayCache[player] then private.syncHashDayCacheIsInvalid[player] = true end private.db:SetQueryUpdatesPaused(false) private.OnItemRecordsChanged("sale") private.OnItemRecordsChanged("buy") end function Transactions.HandleSyncedData(player, day, data) assert(#data % 9 == 0) private.dataChanged = true private.db:SetQueryUpdatesPaused(true) -- remove any prior data for the day local query = private.db:NewQuery() :Equal("player", player) :GreaterThanOrEqual("time", day * SECONDS_PER_DAY) :LessThan("time", (day + 1) * SECONDS_PER_DAY) for _, uuid in query:UUIDIterator() do private.db:DeleteRowByUUID(uuid) end query:Release() if private.syncHashDayCache[player] then private.syncHashDayCacheIsInvalid[player] = true end -- insert the new data private.db:BulkInsertStart() for i = 1, #data, 9 do private.BulkInsertNewRowHelper(player, unpack(data, i, i + 8)) end private.db:BulkInsertEnd() private.db:SetQueryUpdatesPaused(false) private.OnItemRecordsChanged("sale") private.OnItemRecordsChanged("buy") end -- ============================================================================ -- Private Helper Functions -- ============================================================================ function private.LoadData(recordType, csvRecords, csvSaveTimes) local saveTimes = String.SafeSplit(csvSaveTimes, ",") local decodeContext = CSV.DecodeStart(csvRecords, OLD_CSV_KEYS[recordType]) or CSV.DecodeStart(csvRecords, CSV_KEYS) if not decodeContext then Log.Err("Failed to decode %s records", recordType) private.dataChanged = true return end local saveTimeIndex = 1 for itemString, stackSize, quantity, price, otherPlayer, player, timestamp, source in CSV.DecodeIterator(decodeContext) do local saveTime = 0 if saveTimes and source == "Auction" then saveTime = tonumber(saveTimes[saveTimeIndex]) saveTimeIndex = saveTimeIndex + 1 end private.BulkInsertNewRowHelper(player, recordType, itemString, stackSize, quantity, price, otherPlayer, timestamp, source, saveTime) end if not CSV.DecodeEnd(decodeContext) then Log.Err("Failed to decode %s records", recordType) private.dataChanged = true end private.OnItemRecordsChanged(recordType) end function private.BulkInsertNewRowHelper(player, recordType, itemString, stackSize, quantity, price, otherPlayer, timestamp, source, saveTime) itemString = ItemString.Get(itemString) local baseItemString = ItemString.GetBaseFast(itemString) stackSize = tonumber(stackSize) quantity = tonumber(quantity) price = tonumber(price) timestamp = tonumber(timestamp) if itemString and stackSize and quantity and price and otherPlayer and player and timestamp and source then local newTimestamp = floor(timestamp) if newTimestamp ~= timestamp then -- make sure all timestamps are stored as integers private.dataChanged = true timestamp = newTimestamp end local newPrice = floor(price) if newPrice ~= price then -- make sure all prices are stored as integers private.dataChanged = true price = newPrice end private.db:BulkInsertNewRowFast11(baseItemString, recordType, itemString, stackSize, quantity, price, otherPlayer, player, timestamp, source, saveTime) else private.dataChanged = true end end function private.SaveData(recordType) local numRecords = private.db:NewQuery() :Equal("type", recordType) :CountAndRelease() if numRecords > MAX_CSV_RECORDS then local query = private.db:NewQuery() :Equal("type", recordType) :OrderBy("time", false) local count = 0 local saveTimes = {} local shouldTrim = query:Count() > MAX_CSV_RECORDS local lastTime = nil local encodeContext = CSV.EncodeStart(CSV_KEYS) for _, row in query:Iterator() do if not shouldTrim or count <= TRIMMED_CSV_RECORDS then -- add the save time local saveTime = row:GetField("saveTime") saveTime = saveTime ~= 0 and saveTime or time() if row:GetField("source") == "Auction" then tinsert(saveTimes, saveTime) end -- update the time we're trimming to if shouldTrim then lastTime = row:GetField("time") end -- add to our list of CSV lines CSV.EncodeAddRowData(encodeContext, row) end count = count + 1 end query:Release() return CSV.EncodeEnd(encodeContext), table.concat(saveTimes, ","), lastTime else local saveTimes = {} local encodeContext = CSV.EncodeStart(CSV_KEYS) for _, _, rowRecordType, itemString, stackSize, quantity, price, otherPlayer, player, timestamp, source, saveTime in private.db:RawIterator() do if rowRecordType == recordType then -- add the save time if source == "Auction" then tinsert(saveTimes, saveTime ~= 0 and saveTime or time()) end -- add to our list of CSV lines CSV.EncodeAddRowDataRaw(encodeContext, itemString, stackSize, quantity, price, otherPlayer, player, timestamp, source) end end return CSV.EncodeEnd(encodeContext), table.concat(saveTimes, ","), nil end end function private.InsertRecord(recordType, itemString, source, stackSize, price, otherPlayer, timestamp) private.dataChanged = true assert(itemString and source and stackSize and price and otherPlayer and timestamp) timestamp = floor(timestamp) local baseItemString = ItemString.GetBase(itemString) local player = UnitName("player") local matchingRow = private.db:NewQuery() :Equal("type", recordType) :Equal("itemString", itemString) :Equal("baseItemString", baseItemString) :Equal("stackSize", stackSize) :Equal("source", source) :Equal("price", price) :Equal("player", player) :Equal("otherPlayer", otherPlayer) :GreaterThan("time", timestamp - COMBINE_TIME_THRESHOLD) :LessThan("time", timestamp + COMBINE_TIME_THRESHOLD) :Equal("saveTime", 0) :GetFirstResultAndRelease() if matchingRow then matchingRow:SetField("quantity", matchingRow:GetField("quantity") + stackSize) matchingRow:Update() matchingRow:Release() else private.db:NewRow() :SetField("type", recordType) :SetField("itemString", itemString) :SetField("baseItemString", baseItemString) :SetField("stackSize", stackSize) :SetField("quantity", stackSize) :SetField("price", price) :SetField("otherPlayer", otherPlayer) :SetField("player", player) :SetField("time", timestamp) :SetField("source", source) :SetField("saveTime", 0) :Create() end if private.syncHashDayCache[player] then private.syncHashDayCacheIsInvalid[player] = true end private.OnItemRecordsChanged(recordType, itemString) TSM.Accounting.Sync.OnTransactionsChanged() end function private.OnItemRecordsChanged(recordType, itemString) if recordType == "sale" then CustomPrice.OnSourceChange("AvgSell", itemString) CustomPrice.OnSourceChange("MaxSell", itemString) CustomPrice.OnSourceChange("MinSell", itemString) CustomPrice.OnSourceChange("NumExpires", itemString) elseif recordType == "buy" then CustomPrice.OnSourceChange("AvgBuy", itemString) CustomPrice.OnSourceChange("MaxBuy", itemString) CustomPrice.OnSourceChange("MinBuy", itemString) else error("Invalid recordType: "..tostring(recordType)) end end function private.SyncHashesThread(otherPlayer) private.CalculateSyncHashesThreaded(UnitName("player")) while #private.pendingSyncHashCharacters > 0 do local player = tremove(private.pendingSyncHashCharacters, 1) private.CalculateSyncHashesThreaded(player) end private.isSyncHashesThreadRunning = false end function private.CalculateSyncHashesThreaded(player) if private.syncHashDayCache[player] and not private.syncHashDayCacheIsInvalid[player] then Log.Info("Sync hashes for player (%s) are already up to date", player) return end private.syncHashDayCache[player] = private.syncHashDayCache[player] or {} local result = private.syncHashDayCache[player] wipe(result) private.syncHashDayCacheIsInvalid[player] = true while true do local aborted = false local query = private.db:NewQuery() :Equal("player", player) :OrderBy("time", false) :OrderBy("itemString", true) Threading.GuardDatabaseQuery(query) for _, row in query:Iterator(true) do local rowHash = row:CalculateHash(SYNC_FIELDS) local day = floor(row:GetField("time") / SECONDS_PER_DAY) result[day] = Math.CalculateHash(rowHash, result[day]) Threading.Yield() if query:IsIteratorAborted() then Log.Warn("Iterator was aborted for player (%s), will retry", player) aborted = true end end Threading.UnguardDatabaseQuery(query) query:Release() if not aborted then break end end private.syncHashDayCacheIsInvalid[player] = nil Log.Info("Updated sync hashes for player (%s)", player) end function private.InventoryCallback() CustomPrice.OnSourceChange("SmartAvgBuy") end