using System; using System.Collections.Generic; using Microsoft.Data.SqlClient; using System.Linq; using System.Text; using System.Threading.Tasks; using System.Transactions; namespace bnhtrade.Core.Data.Database.Stock { public class JournalCrud : Connection { // only to be assessable via code, use stock relocate to move stock bewtween status' public int StockJournalInsert(int journalTypeId, int stockId, List<(int statusId, int quantity)> journalPosts, DateTime entryDate, bool isNewStock = false) { /* * TODO: currently the consistancy check checks the journal after the entry has been inserted to the db, if the check fails * the transaction scope is disposed, and the ne journal entries roll back. However, if this is done within a higher * level transaction scope, this nested dispose() also rolls back the all transacopes scopes it is a child of. * * Therefore, a consistancy check needs to be simulated in code to negate the need to rollback/dispose of a db transaction. * This would also have some slight performance benefits. * * Once you've done this, fix the SkuTransactionReconcile class: Currently it's transactionscope only covers updates. * Need to set the scope to cover the intial table read (to lock the records). The issue above restricts this. */ // balance and status IsCredit checks made by post insert function // create the journal entry int stockJournalId; using (TransactionScope scope = new TransactionScope()) using (SqlConnection conn = new SqlConnection(SqlConnectionString)) { conn.Open(); //consitancy check is required? bool consistencyRequired = true; // get date of most recent debit for status' that I will be crediting if (isNewStock == false) { // build sql string string stringSql = @" SELECT tblStockJournal.EntryDate FROM tblStockJournal INNER JOIN tblStockJournalPost ON tblStockJournal.StockJournalID = tblStockJournalPost.StockJournalID WHERE tblStockJournal.StockID=@stockId AND EntryDate>=@entryDate AND tblStockJournalPost.Quantity>0 AND ("; bool firstDone = false; foreach (var item in journalPosts) { if (item.quantity < 0) { if (firstDone) { stringSql = stringSql + " OR "; } stringSql = stringSql + "tblStockJournalPost.StockStatusID=" + item.statusId; firstDone = true; } } stringSql = stringSql + ");"; using (SqlCommand cmd = new SqlCommand(stringSql, conn)) { cmd.Parameters.AddWithValue("@stockId", stockId); cmd.Parameters.AddWithValue("@entryDate", entryDate.ToUniversalTime()); using (var reader = cmd.ExecuteReader()) { if (reader.HasRows) { consistencyRequired = true; } else { consistencyRequired = false; } } } } // create journal entry using (SqlCommand cmd = new SqlCommand(@" INSERT INTO tblStockJournal ( EntryDate, StockJournalTypeID, StockID, IsLocked ) OUTPUT INSERTED.StockJournalID VALUES ( @EntryDate, @journalTypeId, @stockID, @isLocked ); ", conn)) { // add parameters cmd.Parameters.AddWithValue("@stockID", stockId); cmd.Parameters.AddWithValue("@journalTypeId", journalTypeId); cmd.Parameters.AddWithValue("@EntryDate", entryDate.ToUniversalTime()); cmd.Parameters.AddWithValue("@isLocked", isNewStock); //execute stockJournalId = (int)cmd.ExecuteScalar(); } // insert journal posts into database StockJournalPostInsert(conn, stockId, stockJournalId, journalPosts, isNewStock); // consistency check bool consistencyResult = true; if (consistencyRequired) { consistencyResult = false; // build list of effected status' var statusIdEffected = new List(); foreach (var item in journalPosts) { if (item.quantity < 0) { statusIdEffected.Add(item.statusId); } } // run check consistencyResult = StockJournalConsistencyCheck(stockId, statusIdEffected); } if (consistencyResult) { // commit scope.Complete(); return stockJournalId; } else { throw new Exception("Unable to insert stock journal entry, consistancy check failed."); } } } public void StockJournalDelete(int stockJournalId) { using (TransactionScope scope = new TransactionScope()) using (SqlConnection conn = new SqlConnection(SqlConnectionString)) { conn.Open(); // get date for journal entry DateTime entryDate; int stockId; using (SqlCommand cmd = new SqlCommand(@" SELECT tblStockJournal.EntryDate, StockID FROM tblStockJournal WHERE (((tblStockJournal.StockJournalID)=@stockJournalId)); ", conn)) { // add parameters cmd.Parameters.AddWithValue("@stockJournalId", stockJournalId); using (var reader = cmd.ExecuteReader()) { if (reader.Read()) { entryDate = DateTime.SpecifyKind(reader.GetDateTime(0), DateTimeKind.Utc); stockId = reader.GetInt32(1); } else { throw new Exception("StockJournalID=" + stockJournalId + " does not exist!"); } } } // is consistancy check required bool consistancyCheck; // build list of debits that are to be deleted var debitList = new List(); using (SqlCommand cmd = new SqlCommand(@" SELECT tblStockJournalPost.StockStatusID FROM tblStockJournalPost WHERE (((tblStockJournalPost.StockJournalID)=@stockJournalId) AND ((tblStockJournalPost.Quantity)>0)); ", conn)) { // add parameters cmd.Parameters.AddWithValue("@stockJournalId", stockJournalId); using (var reader = cmd.ExecuteReader()) { if (reader.HasRows) { while (reader.Read()) { debitList.Add(reader.GetInt32(0)); } } else { throw new Exception("StockJournalID=" + stockJournalId + " has no debits with quantity greater than zero!"); } } } // check no credits for stockId & debit combination have been made since delete entry string stringSql = @" SELECT tblStockJournal.EntryDate FROM tblStockJournal INNER JOIN tblStockJournalPost ON tblStockJournal.StockJournalID = tblStockJournalPost.StockJournalID WHERE tblStockJournal.StockID=@stockId AND tblStockJournalPost.Quantity<0 AND EntryDate>=@entryDate AND ("; bool firstDone = false; foreach (var item in debitList) { if (firstDone) { stringSql = stringSql + " OR "; } stringSql = stringSql + "tblStockJournalPost.StockStatusID=" + item; firstDone = true; } stringSql = stringSql + ");"; using (SqlCommand cmd = new SqlCommand(stringSql, conn)) { cmd.Parameters.AddWithValue("@stockId", stockId); cmd.Parameters.AddWithValue("@entryDate", entryDate.ToUniversalTime()); using (var reader = cmd.ExecuteReader()) { if (reader.HasRows) { consistancyCheck = true; } else { consistancyCheck = false; } } } // delete the posts StockJournalPostDelete(conn, stockJournalId); // delete journal entry using (SqlCommand cmd = new SqlCommand(@" DELETE FROM tblStockJournal WHERE StockJournalID=@stockJournalId; ", conn)) { // add parameters cmd.Parameters.AddWithValue("@stockJournalId", stockJournalId); int count = cmd.ExecuteNonQuery(); if (count != 1) { throw new Exception("Failed to delete stock journal header."); } } // consistanct check bool consistencyResult = true; if (consistancyCheck) { // run check consistencyResult = StockJournalConsistencyCheck(stockId, debitList); } if (consistencyResult) { // commit scope.Complete(); } else { throw new Exception("Unable to delete stock journal entry, consistancy check failed."); } } } private void StockJournalPostInsert(SqlConnection conn, int stockId, int stockJournalId, List<(int statusId, int quantity)> journalPosts, bool isNewStock = false) { //checks if (journalPosts.Count > 2) { // I have purposely made the code to accept split transaction incase of future requirements, however, db design is simpler this way. throw new Exception("Stock journal does not currently support split transactions (greater than two posts)." + journalPosts.Count + " number posts attempted."); } else if (journalPosts.Count < 2) { // list not long enough throw new Exception("Stock journal entry requires minium of two posts, entry of " + journalPosts.Count + " number posts attempted."); } if (journalPosts.Sum(item => item.quantity) != 0) { // credits and debits do not match throw new Exception("Sum of credits and debits do not resolve to zero."); } // group credits and debits by status var dicStatusQty = new Dictionary(); foreach (var post in journalPosts) { if (dicStatusQty.ContainsKey(post.statusId) == false) { dicStatusQty.Add(post.statusId, post.quantity); } else { dicStatusQty[post.statusId] = dicStatusQty[post.statusId] + post.quantity; } } // get isCreditOnly for each status var dicStatusIsCreditOnly = new Dictionary(); foreach (var item in dicStatusQty) { using (SqlCommand cmd = new SqlCommand(@" SELECT IsCreditOnly FROM tblStockStatus WHERE StockStatusID=@statusId; ", conn)) { cmd.Parameters.AddWithValue("@statusId", item.Key); dicStatusIsCreditOnly.Add(item.Key, (bool)cmd.ExecuteScalar()); } } // check there is only one IsCreditOnly in list and it is allowed in this instance int isCreditOnlyCount = 0; foreach (var item in dicStatusIsCreditOnly) { if (item.Value) { isCreditOnlyCount = isCreditOnlyCount + 1; } } if (isNewStock == false && isCreditOnlyCount > 0) { throw new Exception("Attempted credit or debit to 'Is Credit Only' status not allowed, in this instance."); } if (isNewStock == true && isCreditOnlyCount != 1) { throw new Exception("StockID=" + stockId + ", each stock line must have only have one IsCreditOnly=True status assigned to it."); } // ensure debit (or zero credit) isn't made to credit only status // need to do this check via original post list (i.e. journalPosts) foreach (var post in journalPosts) { // debit check if (post.quantity >= 0) { if (dicStatusIsCreditOnly[post.statusId] == true) { throw new Exception("Cannot make debit, or zero quantity credit, to credit only status. StatusID=" + post.statusId); } } } // balance check for any credits (that are not isCreditOnly=true) foreach (var item in dicStatusQty) { if (item.Value < 0 && dicStatusIsCreditOnly[item.Key] == false) { int quantity = new Data.Database.Stock.ReadStatusBalance().ByStockId(stockId, item.Key); if (quantity + item.Value < 0) { throw new Exception("Credit status balance check failed. Available balance " + quantity + ", attempted credit " + item.Value * -1 + "."); } } } // get this far... // insert journal posts into database foreach (var post in journalPosts) { using (SqlCommand cmd = new SqlCommand(@" INSERT INTO tblStockJournalPost ( StockJournalID, StockStatusID, Quantity ) VALUES ( @StockJournalId, @stockStatudId, @quantity ); ", conn)) { cmd.Parameters.AddWithValue("@StockJournalId", stockJournalId); cmd.Parameters.AddWithValue("@stockStatudId", post.statusId); cmd.Parameters.AddWithValue("@quantity", post.quantity); // execute cmd.ExecuteNonQuery(); } } } private void StockJournalPostDelete(SqlConnection conn, int stockJournalId) { using (SqlCommand cmd = new SqlCommand(@" DELETE FROM tblStockJournalPost WHERE StockJournalID=@stockJournalId ", conn)) { cmd.Parameters.AddWithValue("@StockJournalId", stockJournalId); // execute cmd.ExecuteNonQuery(); // the calling method must compete any transaction-scope on the connection } } // can be used before commiting an sql insert, update or delete to the stock journal to ensure a status does not fall below 0 // (unless the status is enabled to do so) // set empty list or statusIdEffected to null to check entier stock entries for consistency public bool StockJournalConsistencyCheck(int stockId, List statusIdEffected = null) { if (statusIdEffected == null) { statusIdEffected = new List(); } using (SqlConnection conn = new SqlConnection(SqlConnectionString)) { conn.Open(); // if no list supplied, build list of all used status' for stockId if (statusIdEffected.Count == 0) { using (SqlCommand cmd = new SqlCommand(@" SELECT tblStockJournalPost.StockStatusID FROM tblStockJournal INNER JOIN tblStockJournalPost ON tblStockJournal.StockJournalID = tblStockJournalPost.StockJournalID WHERE tblStockJournal.StockID=@stockId GROUP BY tblStockJournalPost.StockStatusID; ", conn)) { cmd.Parameters.AddWithValue("@stockId", stockId); using (var reader = cmd.ExecuteReader()) { if (reader.HasRows) { while (reader.Read()) { statusIdEffected.Add(reader.GetInt32(0)); } } else { throw new Exception("No stock journal entries exist for StockID=" + stockId); } } } } // build the sql string to build creditCreate bool var dicStatusCreditOnly = new Dictionary(); string sqlString = @" SELECT tblStockStatus.StockStatusID, tblStockStatus.IsCreditOnly FROM tblStockStatus "; for (var i = 0; i < statusIdEffected.Count; i++) { if (i == 0) { sqlString = sqlString + " WHERE tblStockStatus.StockStatusID=" + statusIdEffected[i]; } else { sqlString = sqlString + " OR tblStockStatus.StockStatusID=" + statusIdEffected[i]; } //if (i == (statusIdEffected.Count - 1)) //{ // sqlString = sqlString + ";"; //} } sqlString = sqlString + " GROUP BY tblStockStatus.StockStatusID, tblStockStatus.IsCreditOnly;"; // run query & build dictionaries using (SqlCommand cmd = new SqlCommand(sqlString, conn)) { cmd.Parameters.AddWithValue("@stockId", stockId); using (SqlDataReader reader = cmd.ExecuteReader()) { if (reader.Read()) { dicStatusCreditOnly.Add(reader.GetInt32(0), reader.GetBoolean(1)); while (reader.Read()) { dicStatusCreditOnly.Add(reader.GetInt32(0), reader.GetBoolean(1)); } } else { throw new Exception("Error, no journal entries found for StockID=" + stockId); } } } // check integrity of supplied statusIds foreach (int statusId in statusIdEffected) { if (!dicStatusCreditOnly.ContainsKey(statusId)) { throw new Exception("Supplied StatusId (" + statusId + ") doesn't exist for StockId=" + stockId); } } // loop through each statudId and check integrity, if createdEnabled=false foreach (int statudId in statusIdEffected) { if (dicStatusCreditOnly[statudId] == false) { using (SqlCommand cmd = new SqlCommand(@" SELECT tblStockJournal.EntryDate, tblStockJournalPost.Quantity FROM tblStockJournal INNER JOIN tblStockJournalPost ON tblStockJournal.StockJournalID = tblStockJournalPost.StockJournalID WHERE tblStockJournal.StockID=@stockId AND tblStockJournalPost.StockStatusID=@statudId ORDER BY tblStockJournal.EntryDate; ", conn)) { cmd.Parameters.AddWithValue("@stockId", stockId); cmd.Parameters.AddWithValue("@statudId", statudId); using (SqlDataReader reader = cmd.ExecuteReader()) { // read first line into variables reader.Read(); int quantity = reader.GetInt32(1); DateTime entryDate = DateTime.SpecifyKind(reader.GetDateTime(0), DateTimeKind.Utc); while (true) { // compare to next values if (reader.Read()) { DateTime nextEntryDate = DateTime.SpecifyKind(reader.GetDateTime(0), DateTimeKind.Utc); // don't check quantites for transactions on same date if (entryDate == nextEntryDate) { entryDate = nextEntryDate; quantity = quantity + reader.GetInt32(1); } // check if dates are different else { if (quantity < 0) { return false; } else { entryDate = nextEntryDate; quantity = quantity + reader.GetInt32(1); } } } // end if no records, check quantity else { if (quantity < 0) { return false; } break; } } } } } } } // get this far, all good return true; } } }