This commit is contained in:
2025-07-10 19:07:09 +01:00
parent 97b945e0cb
commit 7a28e2cc66
8 changed files with 605 additions and 575 deletions

View File

@@ -1,5 +1,6 @@
using bnhtrade.Core.Data.Database._BoilerPlate;
using bnhtrade.Core.Data.Database.Repository.Interface;
using bnhtrade.Core.Model.Account;
using Microsoft.Data.SqlClient;
using System;
using System.Collections.Generic;
@@ -9,6 +10,7 @@ using System.Text;
using System.Threading.Tasks;
using System.Transactions;
using static bnhtrade.Core.Data.Database.Constants;
using static bnhtrade.Core.Model.Account.Journal;
namespace bnhtrade.Core.Data.Database.Repository.Implementation
{
@@ -22,84 +24,13 @@ namespace bnhtrade.Core.Data.Database.Repository.Implementation
// Create
//
// 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)
public int InsertStockJournalHeader(int stockId, int journalTypeId, DateTime entryDate, bool isLocked)
{
/*
* 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;
//consitancy check is required?
bool consistencyRequired = true;
// get date of most recent debit for status' that I will be crediting
if (isNewStock == false)
if (entryDate.Kind != DateTimeKind.Utc)
{
// 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 = _connection.CreateCommand() as SqlCommand)
{
cmd.CommandText = stringSql;
cmd.Transaction = _transaction as SqlTransaction;
cmd.Parameters.AddWithValue("@stockId", stockId);
cmd.Parameters.AddWithValue("@entryDate", entryDate.ToUniversalTime());
using (var reader = cmd.ExecuteReader())
{
if (reader.HasRows)
{
consistencyRequired = true;
}
else
{
consistencyRequired = false;
}
}
}
throw new ArgumentException("Entry date must be in UTC format.", nameof(entryDate));
}
// create journal entry
using (SqlCommand cmd = _connection.CreateCommand() as SqlCommand)
{
cmd.Transaction = _transaction as SqlTransaction;
@@ -111,43 +42,33 @@ namespace bnhtrade.Core.Data.Database.Repository.Implementation
cmd.Parameters.AddWithValue("@stockID", stockId);
cmd.Parameters.AddWithValue("@journalTypeId", journalTypeId);
cmd.Parameters.AddWithValue("@EntryDate", entryDate.ToUniversalTime());
cmd.Parameters.AddWithValue("@isLocked", isNewStock);
cmd.Parameters.AddWithValue("@isLocked", isLocked);
//execute
stockJournalId = (int)cmd.ExecuteScalar();
return (int)cmd.ExecuteScalar();
}
}
// insert journal posts into database
//new Data.Database.Stock
StockJournalPostInsert(stockId, stockJournalId, journalPosts, isNewStock);
public int InsertStockJournalPost(int stockJournalId, int stockStatusId, int quantity)
{
if (quantity == 0)
{
throw new ArgumentException("Quantity must be non-zero.", nameof(quantity));
}
using (SqlCommand cmd = _connection.CreateCommand() as SqlCommand)
{
cmd.Transaction = _transaction as SqlTransaction;
cmd.CommandText = @"
INSERT INTO tblStockJournalPost ( StockJournalID, StockStatusID, Quantity )
OUTPUT INSERTED.StockJournalPostID
VALUES ( @StockJournalId, @stockStatudId, @quantity );";
// consistency check
bool consistencyResult = true;
if (consistencyRequired)
{
consistencyResult = false;
// build list of effected status'
var statusIdEffected = new List<int>();
foreach (var item in journalPosts)
{
if (item.quantity < 0)
{
statusIdEffected.Add(item.statusId);
}
}
// run check
consistencyResult = Stock.StockJournal.WIP_StockJournalConsistencyCheck(sqlConnectionString, stockId, statusIdEffected);
}
cmd.Parameters.AddWithValue("@StockJournalId", stockJournalId);
cmd.Parameters.AddWithValue("@stockStatudId", stockStatusId);
cmd.Parameters.AddWithValue("@quantity", quantity);
if (consistencyResult)
{
// commit
scope.Complete();
return stockJournalId;
}
else
{
throw new Exception("Unable to insert stock journal entry, consistancy check failed.");
// execute
return cmd.ExecuteNonQuery();
}
}
@@ -155,6 +76,32 @@ namespace bnhtrade.Core.Data.Database.Repository.Implementation
// Read
//
public (int, DateTime) ReadJournalStockIdAndEntryDate(int stockJournalId)
{
using (SqlCommand cmd = _connection.CreateCommand() as SqlCommand)
{
cmd.Transaction = _transaction as SqlTransaction;
cmd.CommandText = @"
SELECT tblStockJournal.EntryDate, StockID
FROM tblStockJournal
WHERE (((tblStockJournal.StockJournalID)=@stockJournalId));";
cmd.Parameters.AddWithValue("@stockJournalId", stockJournalId);
using (var reader = cmd.ExecuteReader())
{
if (reader.Read())
{
return (reader.GetInt32(1), DateTime.SpecifyKind(reader.GetDateTime(0), DateTimeKind.Utc));
}
else
{
throw new Exception("StockJournalID=" + stockJournalId + " does not exist!");
}
}
}
}
public int ReadJournalTypeIdByStockId(int stockId)
{
using (SqlCommand cmd = _connection.CreateCommand() as SqlCommand)
@@ -322,6 +269,54 @@ namespace bnhtrade.Core.Data.Database.Repository.Implementation
}
public DateTime? ReadMostRecentEntryDateForStatusDebit(int stockId, List<int> stockStatusIdList)
{
if (stockId <= 0)
{
throw new ArgumentException("Stock ID must be greater than zero.", nameof(stockId));
}
if (stockStatusIdList == null || stockStatusIdList.Count == 0)
{
throw new ArgumentException("Stock status ID list cannot be null or empty.", nameof(stockStatusIdList));
}
var sqlWhere = new SqlWhereBuilder();
// build sql string
string stringSql = @"
MAX (tblStockJournal.EntryDate) AS MostRecentEntryDate
FROM
tblStockJournal
INNER JOIN tblStockJournalPost
ON tblStockJournal.StockJournalID = tblStockJournalPost.StockJournalID
WHERE
tblStockJournal.StockID=@stockId
AND tblStockJournalPost.Quantity>0 ";
sqlWhere.In("tblStockJournalPost.StockStatusID", stockStatusIdList, "AND");
stringSql = stringSql + sqlWhere.SqlWhereString;
using (SqlCommand cmd = _connection.CreateCommand() as SqlCommand)
{
cmd.CommandText = stringSql;
cmd.Transaction = _transaction as SqlTransaction;
cmd.Parameters.AddWithValue("@stockId", stockId);
sqlWhere.AddParametersToSqlCommand(cmd);
object obj = cmd.ExecuteScalar();
if (obj == null || obj == DBNull.Value)
{
return null;
}
else
{
return DateTime.SpecifyKind((DateTime)obj, DateTimeKind.Utc);
}
}
}
//
// update
//
@@ -331,454 +326,6 @@ namespace bnhtrade.Core.Data.Database.Repository.Implementation
// Delete
//
public void StockJournalDelete(int stockJournalId)
{
// 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<int>();
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 = Stock.StockJournal.WIP_StockJournalConsistencyCheck(sqlConnectionString, stockId, debitList);
}
if (consistencyResult)
{
// commit
scope.Complete();
}
else
{
throw new Exception("Unable to delete stock journal entry, consistancy check failed.");
}
}
private void StockJournalPostInsert(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<int, int>();
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<int, bool>();
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().ReadStatusBalanceByStockId(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(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
}
}
public bool WIP_StockJournalConsistencyCheck( int stockId, List<int> statusIdEffected = null)
{
if (statusIdEffected == null)
{
statusIdEffected = new List<int>();
}
// if no list supplied, build list of all used status' for stockId
if (statusIdEffected.Count == 0)
{
using (SqlCommand cmd = _connection.CreateCommand() as SqlCommand)
{
cmd.Transaction = _transaction as SqlTransaction;
cmd.CommandText = @"
SELECT
tblStockJournalPost.StockStatusID
FROM
tblStockJournal
INNER JOIN tblStockJournalPost
ON tblStockJournal.StockJournalID = tblStockJournalPost.StockJournalID
WHERE
tblStockJournal.StockID=@stockId
GROUP BY
tblStockJournalPost.StockStatusID;";
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<int, bool>();
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 = _connection.CreateCommand() as SqlCommand)
{
cmd.Transaction = _transaction as SqlTransaction;
cmd.CommandText = sqlString;
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 = _connection.CreateCommand() as SqlCommand)
{
cmd.Transaction = _transaction as SqlTransaction;
cmd.CommandText = @"
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;";
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;
}
}
}

View File

@@ -16,10 +16,10 @@ namespace bnhtrade.Core.Data.Database.Repository.Implementation
{
}
public List<Model.Stock.Status> ReadStatus(List<int> statusIds = null, List<int> statusTypeIds = null)
public Dictionary<int, Model.Stock.Status> ReadStatus(List<int> statusIds = null, List<int> statusTypeIds = null)
{
var sqlBuilder = new SqlWhereBuilder();
var returnList = new List<Model.Stock.Status>();
var returnList = new Dictionary<int, Model.Stock.Status>();
//build sql query
string sql = @"
@@ -91,7 +91,7 @@ namespace bnhtrade.Core.Data.Database.Repository.Implementation
, recordCreated
);
returnList.Add(newItem);
returnList.Add(statusId, newItem);
}
}
}

View File

@@ -8,8 +8,9 @@ namespace bnhtrade.Core.Data.Database.Repository.Interface
{
internal interface IStockJournalRepository
{
public int StockJournalInsert(int journalTypeId, int stockId, List<(int statusId, int quantity)> journalPosts,
DateTime entryDate, bool isNewStock = false);
int InsertStockJournalHeader(int stockId, int journalTypeId, DateTime entryDate, bool isLocked);
int InsertStockJournalPost(int stockJournalId, int stockStatusId, int quantity);
(int, DateTime) ReadJournalStockIdAndEntryDate(int stockJournalId);
int ReadJournalTypeIdByStockId(int stockId);
int ReadStatusBalanceBySku(string sku, int statusId);
int ReadStatusBalanceByStockNumber(int stockNumber, int statusId);
@@ -17,9 +18,6 @@ namespace bnhtrade.Core.Data.Database.Repository.Interface
int ReadJournalEntryCountByStockId(int stockId);
int? ReadTypeIdStatusCreditId(int stockJournalTypeId);
Dictionary<string, int> ReadStatusBalanceByStatusId(int statusId);
void StockJournalDelete(int stockJournalId);
void StockJournalPostInsert(int stockId, int stockJournalId, List<(int statusId, int quantity)> journalPosts, bool isNewStock = false);
void StockJournalPostDelete(int stockJournalId);
bool WIP_StockJournalConsistencyCheck(int stockId, List<int> statusIdEffected = null);
DateTime? ReadMostRecentEntryDateForStatusDebit(int stockId, List<int> stockStatusIdList);
}
}

View File

@@ -8,7 +8,7 @@ namespace bnhtrade.Core.Data.Database.Repository.Interface
{
internal interface IStockStatusRepository
{
List<Model.Stock.Status> ReadStatus(List<int> statusIds = null, List<int> statusTypeIds = null);
Dictionary<int, Model.Stock.Status> ReadStatus(List<int> statusIds = null, List<int> statusTypeIds = null);
Dictionary<int, Model.Stock.StatusType> ReadStatusType();
}
}

View File

@@ -1,9 +1,11 @@
using bnhtrade.Core.Data.Database.UnitOfWork;
using Amazon.Runtime.Internal.Transform;
using bnhtrade.Core.Data.Database.UnitOfWork;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using static System.Formats.Asn1.AsnWriter;
namespace bnhtrade.Core.Logic.Inventory
{
@@ -13,32 +15,507 @@ namespace bnhtrade.Core.Logic.Inventory
internal StockJournalService(IUnitOfWork unitOfWork) : base(unitOfWork) { }
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.
*
* Future me here, refactored the code to use a UnitOfWork pattern, wil need to check that this is still the case.
*/
// balance and status IsCredit checks made by post insert function
//consitancy check is required?
bool consistencyRequired = true;
return WithUnitOfWork(uow =>
{
// get date of most recent debit for status' that I will be crediting
if (isNewStock == false)
{
var debitStatusIds = new List<int>();
foreach (var post in journalPosts)
{
if (post.quantity > 0)
{
debitStatusIds.Add(post.statusId);
}
}
var mostRecentDebitDate = uow.StockJournalRepository.ReadMostRecentEntryDateForStatusDebit(stockId, debitStatusIds);
if (mostRecentDebitDate.HasValue && mostRecentDebitDate >= entryDate)
{
consistencyRequired = true;
}
else
{
consistencyRequired = false;
}
}
// create journal entry
int stockJournalId = uow.StockJournalRepository.InsertStockJournalHeader(stockId, journalTypeId, entryDate, isNewStock);
// insert journal posts into database
//new Data.Database.Stock
var insertCount = StockJournalPostInsert(uow, stockId, stockJournalId, journalPosts, isNewStock);
// consistency check
bool consistencyResult = true;
if (consistencyRequired)
{
consistencyResult = false;
// build list of effected status'
var statusIdEffected = new List<int>();
foreach (var item in journalPosts)
{
if (item.quantity < 0)
{
statusIdEffected.Add(item.statusId);
}
}
// run check
consistencyResult = WIP_StockJournalConsistencyCheck(stockId, statusIdEffected);
}
if (consistencyResult)
{
// commit
CommitIfOwned(uow);
return stockJournalId;
}
else
{
throw new Exception("Unable to insert stock journal entry, consistancy check failed.");
}
});
}
private int StockJournalPostInsert(IUnitOfWork uow, 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 for now, it's 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<int, int>();
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 statusList = new StockStatusService(uow).GetStatus(dicStatusQty.Keys.ToList());
var dicStatusIsCreditOnly = new Dictionary<int, bool>();
foreach (var status in statusList.Values.ToList())
{
dicStatusIsCreditOnly.Add(status.StatusId, status.IsCreditOnly);
}
// 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().ReadStatusBalanceByStockId(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
var postIdList = new List<int>()
foreach (var post in journalPosts)
{
postIdList.Add(uow.StockJournalRepository.InsertStockJournalPost(stockJournalId, post.statusId, post.quantity));
}
return postIdList.Count();
}
public void StockJournalDelete(int stockJournalId)
{
WithUnitOfWork(uow =>
{
if (stockJournalId <= 0)
// get date for journal entry
var idAndDate = uow.StockJournalRepository.ReadJournalStockIdAndEntryDate(stockJournalId);
DateTime entryDate = idAndDate.Item2;
int stockId = idAndDate.Item1;
// is consistancy check required
bool consistancyCheck;
// build list of debits that are to be deleted
var debitList = new List<int>();
using (SqlCommand cmd = new SqlCommand(@"
SELECT tblStockJournalPost.StockStatusID
FROM tblStockJournalPost
WHERE (((tblStockJournalPost.StockJournalID)=@stockJournalId) AND ((tblStockJournalPost.Quantity)>0));
", conn))
{
throw new ArgumentException("Stock journal ID must be greater than zero", nameof(stockJournalId));
// 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 = Stock.StockJournal.WIP_StockJournalConsistencyCheck(sqlConnectionString, stockId, debitList);
}
if (consistencyResult)
{
// commit
scope.Complete();
}
else
{
throw new Exception("Unable to delete stock journal entry, consistancy check failed.");
}
uow.StockJournalRepository.StockJournalDelete(stockJournalId);
CommitIfOwned(uow);
});
}
private void StockJournalPostDelete(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 WIP_StockJournalConsistencyCheck(int stockId, List<int> statusIdEffected = null)
{
return WithUnitOfWork(uow =>
if (statusIdEffected == null)
{
if (stockId <= 0)
statusIdEffected = new List<int>();
}
// if no list supplied, build list of all used status' for stockId
if (statusIdEffected.Count == 0)
{
throw new ArgumentException("Stock ID must be greater than zero", nameof(stockId));
using (SqlCommand cmd = _connection.CreateCommand() as SqlCommand)
{
cmd.Transaction = _transaction as SqlTransaction;
cmd.CommandText = @"
SELECT
tblStockJournalPost.StockStatusID
FROM
tblStockJournal
INNER JOIN tblStockJournalPost
ON tblStockJournal.StockJournalID = tblStockJournalPost.StockJournalID
WHERE
tblStockJournal.StockID=@stockId
GROUP BY
tblStockJournalPost.StockStatusID;";
cmd.Parameters.AddWithValue("@stockId", stockId);
using (var reader = cmd.ExecuteReader())
{
if (reader.HasRows)
{
while (reader.Read())
{
statusIdEffected.Add(reader.GetInt32(0));
}
return uow.StockJournalRepository.WIP_StockJournalConsistencyCheck(stockId, statusIdEffected);
});
}
else
{
throw new Exception("No stock journal entries exist for StockID=" + stockId);
}
}
}
// build the sql string to build creditCreate bool
var dicStatusCreditOnly = new Dictionary<int, bool>();
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 = _connection.CreateCommand() as SqlCommand)
{
cmd.Transaction = _transaction as SqlTransaction;
cmd.CommandText = sqlString;
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 = _connection.CreateCommand() as SqlCommand)
{
cmd.Transaction = _transaction as SqlTransaction;
cmd.CommandText = @"
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;";
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;
}
}
}

View File

@@ -100,7 +100,7 @@ namespace bnhtrade.Core.Logic.Inventory
var journalPosts = new List<(int statusId, int quantity)>();
journalPosts.Add((statusDebitId, quantity));
journalPosts.Add((statusCreditId.Value, (quantity * -1)));
int stockJournalId = uow.StockJournalRepository.StockJournalInsert(stockJournalTypeId, stockId, journalPosts, stockJournalEntryDate, true);
int stockJournalId = new StockJournalService(uow).StockJournalInsert(stockJournalTypeId, stockId, journalPosts, stockJournalEntryDate, true);
// update the stock table
count = uow.StockRepository.UpdateStockJournalId(stockId, stockJournalId);
@@ -191,7 +191,7 @@ namespace bnhtrade.Core.Logic.Inventory
}
// delete stock journal entry
uow.StockJournalRepository.StockJournalDelete(stockJournalId);
new StockJournalService(uow).StockJournalDelete(stockJournalId);
// delete stock table entry
count = uow.StockRepository.DeleteStock(stockId);

View File

@@ -13,6 +13,14 @@ namespace bnhtrade.Core.Logic.Inventory
internal StockStatusService(IUnitOfWork unitOfWork) : base(unitOfWork) { }
public Dictionary<int, Model.Stock.Status> GetStatus(List<int> statusIds = null, List<int> statusTypeIds = null)
{
return WithUnitOfWork(uow =>
{
return uow.StockStatusRepository.ReadStatus(statusIds, statusTypeIds);
});
}
/// <summary>
/// Return the avaliable balance of a status. Uses a more efficent sql/code. However, balance requests should
/// generally also involve a date/time (i.e. the system does allow a stock transaction in the future, therefore

View File

@@ -100,7 +100,7 @@ namespace bnhtrade.Core.Logic.Stock
var sku = readSku.BySkuNumber(statusTransaction.SkuNumber);
// get the status obj
var status = uow.StockStatusRepository.ReadStatus(new List<int> { statusTransaction.StockStatusId })[0];
var status = new Logic.Inventory.StockStatusService(uow).GetStatus(new List<int> { statusTransaction.StockStatusId }).Values.First();
return new Model.Stock.StatusBalance(status, sku, entryList);
}