using BakeryErp.Infrastructure.Database; namespace BakeryErp.Infrastructure.Repositories; public sealed class InventoryRepository { private readonly DatabaseOptions _options; public InventoryRepository(DatabaseOptions options) { _options = options; } public IReadOnlyList ListBalances() { return LocalDataStoreFile.Load(_options) .StockBalances .OrderBy(static stock => stock.WarehouseName) .ThenBy(static stock => stock.ItemSku) .ToList(); } public IReadOnlyList 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); }