Spaces:
Sleeping
Sleeping
| using BakeryErp.Infrastructure.Database; | |
| namespace BakeryErp.Infrastructure.Repositories; | |
| public sealed class InventoryRepository | |
| { | |
| private readonly DatabaseOptions _options; | |
| public InventoryRepository(DatabaseOptions options) | |
| { | |
| _options = options; | |
| } | |
| public IReadOnlyList<LocalStockBalance> ListBalances() | |
| { | |
| return LocalDataStoreFile.Load(_options) | |
| .StockBalances | |
| .OrderBy(static stock => stock.WarehouseName) | |
| .ThenBy(static stock => stock.ItemSku) | |
| .ToList(); | |
| } | |
| public IReadOnlyList<LocalInventoryTransaction> ListTransactions() | |
| { | |
| return LocalDataStoreFile.Load(_options) | |
| .InventoryTransactions | |
| .OrderByDescending(static transaction => transaction.OccurredAt) | |
| .ToList(); | |
| } | |
| public LocalInventoryTransaction? FindTransaction(Guid id) | |
| { | |
| return LocalDataStoreFile.Load(_options) | |
| .InventoryTransactions | |
| .FirstOrDefault(transaction => transaction.Id == id); | |
| } | |
| public InventoryReport GetReport() | |
| { | |
| var store = LocalDataStoreFile.Load(_options); | |
| var balances = store.StockBalances; | |
| var transactions = store.InventoryTransactions; | |
| return new InventoryReport( | |
| balances.Count, | |
| balances.Count(static stock => stock.QuantityOnHand <= stock.SafetyStockQuantity), | |
| transactions.Count(static transaction => transaction.Status == LocalInventoryTransactionStatus.PendingApproval), | |
| transactions.Count(static transaction => transaction.Status == LocalInventoryTransactionStatus.Approved), | |
| balances.Sum(static stock => stock.QuantityOnHand)); | |
| } | |
| public SaveInventoryTransactionResult SaveTransaction(LocalInventoryTransaction transaction) | |
| { | |
| var store = LocalDataStoreFile.Load(_options); | |
| var transactionNumber = transaction.TransactionNumber.Trim().ToUpperInvariant(); | |
| if (string.IsNullOrWhiteSpace(transactionNumber) || | |
| string.IsNullOrWhiteSpace(transaction.WarehouseName) || | |
| string.IsNullOrWhiteSpace(transaction.ItemName) || | |
| string.IsNullOrWhiteSpace(transaction.Unit)) | |
| { | |
| return SaveInventoryTransactionResult.ValidationError("異動單號、倉庫、品項與單位為必填。"); | |
| } | |
| if (transaction.Quantity == 0) | |
| { | |
| return SaveInventoryTransactionResult.ValidationError("數量不可為 0。"); | |
| } | |
| var duplicatedNumber = store.InventoryTransactions.Any(existing => | |
| existing.Id != transaction.Id && | |
| string.Equals(existing.TransactionNumber, transactionNumber, StringComparison.OrdinalIgnoreCase)); | |
| if (duplicatedNumber) | |
| { | |
| return SaveInventoryTransactionResult.ValidationError("異動單號已存在,請改用其他單號。"); | |
| } | |
| var existingTransaction = store.InventoryTransactions.FirstOrDefault(existing => existing.Id == transaction.Id); | |
| if (existingTransaction is null) | |
| { | |
| transaction.Id = transaction.Id == Guid.Empty ? Guid.NewGuid() : transaction.Id; | |
| transaction.TransactionNumber = transactionNumber; | |
| transaction.WarehouseName = transaction.WarehouseName.Trim(); | |
| transaction.ItemSku = BlankToEmpty(transaction.ItemSku).ToUpperInvariant(); | |
| transaction.ItemName = transaction.ItemName.Trim(); | |
| transaction.Unit = transaction.Unit.Trim(); | |
| transaction.SourceDocumentNumber = BlankToNull(transaction.SourceDocumentNumber); | |
| transaction.Note = BlankToNull(transaction.Note); | |
| transaction.Status = LocalInventoryTransactionStatus.PendingApproval; | |
| transaction.CreatedAt = DateTime.UtcNow; | |
| store.InventoryTransactions.Add(transaction); | |
| } | |
| else | |
| { | |
| if (existingTransaction.Status == LocalInventoryTransactionStatus.Approved) | |
| { | |
| return SaveInventoryTransactionResult.ValidationError("已審核的庫存異動不可編輯。"); | |
| } | |
| existingTransaction.TransactionNumber = transactionNumber; | |
| existingTransaction.WarehouseName = transaction.WarehouseName.Trim(); | |
| existingTransaction.ItemSku = BlankToEmpty(transaction.ItemSku).ToUpperInvariant(); | |
| existingTransaction.ItemName = transaction.ItemName.Trim(); | |
| existingTransaction.TransactionType = transaction.TransactionType; | |
| existingTransaction.Quantity = transaction.Quantity; | |
| existingTransaction.Unit = transaction.Unit.Trim(); | |
| existingTransaction.SourceDocumentNumber = BlankToNull(transaction.SourceDocumentNumber); | |
| existingTransaction.Note = BlankToNull(transaction.Note); | |
| existingTransaction.OccurredAt = transaction.OccurredAt; | |
| existingTransaction.UpdatedAt = DateTime.UtcNow; | |
| } | |
| LocalDataStoreFile.Save(_options, store); | |
| return SaveInventoryTransactionResult.Success(); | |
| } | |
| public ApproveInventoryTransactionResult Approve(Guid id) | |
| { | |
| var store = LocalDataStoreFile.Load(_options); | |
| var transaction = store.InventoryTransactions.FirstOrDefault(existing => existing.Id == id); | |
| if (transaction is null) | |
| { | |
| return ApproveInventoryTransactionResult.ValidationError("找不到庫存異動單。"); | |
| } | |
| if (transaction.Status == LocalInventoryTransactionStatus.Approved) | |
| { | |
| return ApproveInventoryTransactionResult.Success(); | |
| } | |
| var delta = GetQuantityDelta(transaction); | |
| var balance = store.StockBalances.FirstOrDefault(stock => | |
| string.Equals(stock.WarehouseName, transaction.WarehouseName, StringComparison.OrdinalIgnoreCase) && | |
| string.Equals(stock.ItemSku, transaction.ItemSku, StringComparison.OrdinalIgnoreCase)); | |
| if (balance is null) | |
| { | |
| balance = new LocalStockBalance | |
| { | |
| Id = Guid.NewGuid(), | |
| WarehouseName = transaction.WarehouseName, | |
| ItemSku = transaction.ItemSku, | |
| ItemName = transaction.ItemName, | |
| Unit = transaction.Unit, | |
| SafetyStockQuantity = 0 | |
| }; | |
| store.StockBalances.Add(balance); | |
| } | |
| if (balance.QuantityOnHand + delta < 0) | |
| { | |
| return ApproveInventoryTransactionResult.ValidationError("審核後庫存會小於 0,請確認數量。"); | |
| } | |
| balance.ItemName = transaction.ItemName; | |
| balance.Unit = transaction.Unit; | |
| balance.QuantityOnHand += delta; | |
| balance.LastTransactionAt = DateTime.UtcNow; | |
| transaction.Status = LocalInventoryTransactionStatus.Approved; | |
| transaction.ApprovedAt = DateTime.UtcNow; | |
| transaction.UpdatedAt = DateTime.UtcNow; | |
| LocalDataStoreFile.Save(_options, store); | |
| return ApproveInventoryTransactionResult.Success(); | |
| } | |
| public void Cancel(Guid id) | |
| { | |
| var store = LocalDataStoreFile.Load(_options); | |
| var transaction = store.InventoryTransactions.FirstOrDefault(existing => existing.Id == id); | |
| if (transaction is null || transaction.Status == LocalInventoryTransactionStatus.Approved) | |
| { | |
| return; | |
| } | |
| transaction.Status = LocalInventoryTransactionStatus.Cancelled; | |
| transaction.UpdatedAt = DateTime.UtcNow; | |
| LocalDataStoreFile.Save(_options, store); | |
| } | |
| public static string GetTransactionTypeName(LocalInventoryTransactionType type) | |
| { | |
| return type switch | |
| { | |
| LocalInventoryTransactionType.GoodsReceipt => "進貨入庫", | |
| LocalInventoryTransactionType.StockOut => "出庫", | |
| LocalInventoryTransactionType.StockAdjustment => "庫存調整", | |
| LocalInventoryTransactionType.Scrap => "報廢", | |
| LocalInventoryTransactionType.StoreReturn => "門市退貨", | |
| _ => type.ToString() | |
| }; | |
| } | |
| public static string GetStatusName(LocalInventoryTransactionStatus status) | |
| { | |
| return status switch | |
| { | |
| LocalInventoryTransactionStatus.Draft => "草稿", | |
| LocalInventoryTransactionStatus.PendingApproval => "待審核", | |
| LocalInventoryTransactionStatus.Approved => "已審核", | |
| LocalInventoryTransactionStatus.Cancelled => "已取消", | |
| _ => status.ToString() | |
| }; | |
| } | |
| private static decimal GetQuantityDelta(LocalInventoryTransaction transaction) | |
| { | |
| var absoluteQuantity = Math.Abs(transaction.Quantity); | |
| return transaction.TransactionType switch | |
| { | |
| LocalInventoryTransactionType.GoodsReceipt => absoluteQuantity, | |
| LocalInventoryTransactionType.StoreReturn => absoluteQuantity, | |
| LocalInventoryTransactionType.StockOut => -absoluteQuantity, | |
| LocalInventoryTransactionType.Scrap => -absoluteQuantity, | |
| LocalInventoryTransactionType.StockAdjustment => transaction.Quantity, | |
| _ => transaction.Quantity | |
| }; | |
| } | |
| private static string BlankToEmpty(string? value) | |
| { | |
| return string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim(); | |
| } | |
| private static string? BlankToNull(string? value) | |
| { | |
| return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); | |
| } | |
| } | |
| public sealed record InventoryReport( | |
| int StockItemCount, | |
| int LowStockCount, | |
| int PendingApprovalCount, | |
| int ApprovedTransactionCount, | |
| decimal TotalQuantityOnHand); | |
| public sealed record SaveInventoryTransactionResult(bool IsSuccess, string? ErrorMessage) | |
| { | |
| public static SaveInventoryTransactionResult Success() => new(true, null); | |
| public static SaveInventoryTransactionResult ValidationError(string message) => new(false, message); | |
| } | |
| public sealed record ApproveInventoryTransactionResult(bool IsSuccess, string? ErrorMessage) | |
| { | |
| public static ApproveInventoryTransactionResult Success() => new(true, null); | |
| public static ApproveInventoryTransactionResult ValidationError(string message) => new(false, message); | |
| } | |