using GB5Shared.DaprCache; using GB5Shared.DTO.Framework.AutoNumber; using GB5Shared.DTO.Framework.Criteria; using GB5Shared.DTO.Framework.Login; using GB5Shared.EntityHandler; using GB5Shared.GenerateAutoNumber; using GB5Shared.QueryExecutor; using GB5Shared.Resource.Response; using GB5Shared.Telemetry; using Microsoft.Extensions.Logging; using MMBLL.StockLedgerCost; using MMBLL.StockPosition; using MMDAL.CustomCode.Production; using MMDAL.DTO.Production; using Newtonsoft.Json; using static GB5Shared.GB5Constant.Constant; namespace MMBLL.Production { public class ProductionBLL : IProductionBLL { private readonly IProductionDAL _dal; private readonly IStockPositionBLL _stockPosition; private readonly IStockLedgerCostBLL _stockLedgerCost; private readonly AutoNumber _autoNumber; private readonly IQueryExecutor _qe; private readonly KeyInvalidate _keyInvalidate; private readonly BaseEntityAppService _entityApp; private readonly ILogger _logger; private readonly VoucherNumberService _voucherNumberService; public ProductionBLL( IProductionDAL dal, IStockPositionBLL stockPosition, IStockLedgerCostBLL stockLedgerCost, AutoNumber autoNumber, IQueryExecutor qe, KeyInvalidate keyInvalidate, BaseEntityAppService entityApp, ILogger logger, VoucherNumberService voucherNumberService) { _dal = dal; _stockPosition = stockPosition; _stockLedgerCost = stockLedgerCost; _autoNumber = autoNumber; _qe = qe; _keyInvalidate = keyInvalidate; _entityApp = entityApp; _logger = logger; _voucherNumberService = voucherNumberService; } // ── Get ─────────────────────────────────────────────────────────────── public async Task GetProduction(int productionId, LoginDTO login, CancellationToken ct) { var header = await _dal.GetProduction(productionId, login, ct).ConfigureAwait(false); if (header is null) return JsonConvert.SerializeObject(null); header.Details = (await _dal.GetProductionDetails(productionId, login, ct).ConfigureAwait(false)) .ToList(); return JsonConvert.SerializeObject(header); } // ── Production Entry queue ─────────────────────────────────────────── // Groups TPOSTEDINDENTDETAIL's flat (WO x Stage) rows into the nested // { WorkOrders: [ { ..., Stages: [...] } ] } shape productionentry.component.ts's // mapWOResponse()/mapRoutingResponse()/mapProcessOptions() already expect. public async Task GetProductionEntryQueue( int ouId, int workCenterId, bool showCompleted, int nestingPlanId, LoginDTO login, CancellationToken ct) { try { var rows = (await _dal.GetProductionEntryQueue(ouId, workCenterId, showCompleted, nestingPlanId, login, ct) .ConfigureAwait(false)).ToList(); // Group by CombineIndentId, NOT IndentId — a single WO's routing stages each get // their own TINDENT/IndentId in TPOSTEDINDENTDETAIL (confirmed live on GB5DEMO), // sharing one CombineIndentId as the true parent-WO key. var workOrders = rows .GroupBy(r => r.CombineIndentId) .Select(g => { var ordered = g.OrderBy(r => r.StageSlNo).ToList(); var first = ordered[0]; // TPOSTEDINDENTDETAIL.ISLASTPROCESS is inverted: 0=Yes(last), 1=No — // confirmed live on GB5DEMO (the true final stage carries 0, not 1). var lastProcessRow = ordered.FirstOrDefault(r => r.IsLastProcess == 0) ?? ordered[^1]; var stages = ordered.Select(r => new { StageNo = r.StageSlNo, StageCode = r.Stage, StageName = string.IsNullOrEmpty(r.ProcessName) ? r.Step : r.ProcessName, ProcessCode = r.ProcessCode, Workcenter = r.WorkCenterName, WorkcenterCode = r.WorkCenterCode, AvailLabel = $"Avail: {r.AvailableToWork:0.####}", AvailQty = r.AvailableToWork, Good = r.GoodQuantity, Rejected = r.RejectedQuantity, Rework = r.ReworkQuantity, Waste = r.OtherQuantity, StoppageCount = r.StoppageCount, IsSubcontract = r.ExecutionMode == 1, ScSent = r.MmSent, ScReceived = r.MmReceived, // TPOSTEDINDENTDETAIL has no separate "with subcontractor" column — // derived as Sent minus Received. ScWithSubcontractor = r.MmSent - r.MmReceived, ProductionDetailId = r.ProductionDetailId, IndentDetailId = r.IndentDetailId, NestingPlanId = r.NestingPlanId, ProcessGroupId = -1, ItemId = r.ItemId, SkuId = r.SkuId, UomId = r.UomId, }); return new { WONo = first.WoNo, ItemName = first.ItemName, ItemCode = first.ItemCode, SONo = first.SoNo, SKU = first.SkuCode, RoutingCode = first.RoutingCode, RoutingName = first.RoutingName, PlannedQty = first.Planned, DoneQty = lastProcessRow.Produced, TotalStages = ordered.Count, CompletedStages = ordered.Count(r => r.ReleaseStatus == 4 || r.ReleaseStatus == 5), IndentId = first.CombineIndentId, ItemId = first.ItemId, SkuId = first.SkuId, UomId = first.UomId, Stages = stages, }; }); return JsonConvert.SerializeObject(new { WorkOrders = workOrders }); } catch (Exception ex) { GB5Trace.MarkFailed("get-production-entry-queue-failed", ex); _logger.LogError(ex, "GetProductionEntryQueue failed for OUId {OUId}", ouId); throw; } } public async Task GetProductionList( int ouId, DateTime? fromDate, DateTime? toDate, int firstNumber, int maxResult, LoginDTO login, CancellationToken ct) { var (items, total) = await _dal .GetProductionList(ouId, fromDate, toDate, firstNumber, maxResult, login, ct) .ConfigureAwait(false); return JsonConvert.SerializeObject(new { Items = items, TotalCount = total }); } // ── Save ────────────────────────────────────────────────────────────── public async Task SaveProduction(ProductionSaveDTO dto, LoginDTO login, CancellationToken ct) { try { GB5Trace.Step("validate-production", new { dto.ProductionId }); if (dto.OUId == 0) throw new ArgumentException("OUId is required."); if (dto.PeriodId == 0) throw new ArgumentException("PeriodId is required."); if (dto.ShiftId == 0) throw new ArgumentException("ShiftId is required."); if (dto.Details.Count == 0) throw new ArgumentException("At least one production detail is required."); if (dto.Details.Any(d => d.ProductionQuantity <= 0)) throw new ArgumentException("Production quantity must be > 0 for all detail lines."); bool isNew = dto.ProductionId == 0; // ===================================================== // Generate Production Number // ===================================================== if (isNew) { string? productionNumber = await _voucherNumberService.GetNextVoucherNumberAsync( dto.BizTransactionTypeId, dto.ProductionDate, login, transaction: null); if (!string.IsNullOrWhiteSpace(productionNumber)) { dto.ProductionNumber = productionNumber; } else { throw new InvalidOperationException( "Production number could not be generated."); } } // ===================================================== // Audit fields // ===================================================== dto.CreatedById = login.UserId; dto.CreatedOn = DateTime.UtcNow; if (isNew) { dto.ModifiedById = login.UserId; dto.ModifiedOn = DateTime.UtcNow; } int detailCount = dto.Details.Count; int resourceCount = dto.Resources?.Count ?? 0; int stoppageCount = dto.Stoppages?.Count ?? 0; int reasonCount = dto.Reasons?.Count ?? 0; // Single AutoNumber call for the whole save (header + details + resources + stoppages + reasons) // to avoid multiple interleaved updates to the same MAUTONUMBER row per request. int totalCount = (isNew ? 1 : 0) + detailCount + resourceCount + stoppageCount + reasonCount; var num = await _autoNumber .GetNumberAsync(totalCount, AUTONUMBERCONSTANT.PRODUCTION, login) .ConfigureAwait(false); int nextId = num.StartNumber; if (isNew) dto.ProductionId = nextId++; // Assign IDs to detail lines for (short i = 0; i < dto.Details.Count; i++) { dto.Details[i].ProductionDetailId = nextId++; dto.Details[i].ProductionId = dto.ProductionId; dto.Details[i].SlNo = (short)(i + 1); } // Assign resource IDs if any //===================================================== // Generate Resource IDs and map to Detail //===================================================== if (dto.Resources != null && dto.Resources.Count > 0) { foreach (var resource in dto.Resources) { resource.ProductionResourceId = nextId++; resource.ProductionId = dto.ProductionId; var detail = dto.Details.FirstOrDefault(d => d.SlNo == resource.SlNo); if (detail == null) { throw new ArgumentException( $"No Production Detail found for Resource SlNo = {resource.SlNo}"); } resource.ProductionDetailId = detail.ProductionDetailId; } } GB5Trace.Step("save-production", new { dto.ProductionId, isNew }); await _entityApp.ExecuteSaveAsync( EntityConstant.OBJECTPRODUCTION, EventTypeConstant.SAVEPRODUCTIONTRANSACTIONEVENTTYPEID, dto, login, async tx => { if (isNew) await _dal.SaveProductionHeader(dto, login, tx, ct).ConfigureAwait(false); else { await _dal.DeleteProductionChildren(dto.ProductionId, login, tx, ct).ConfigureAwait(false); await _dal.UpdateProductionHeader(dto, login, tx, ct).ConfigureAwait(false); } await _dal.BulkSaveProductionDetails(dto.Details, login.ClientId, login, tx, ct) .ConfigureAwait(false); if (dto.Resources.Count > 0) await _dal.BulkSaveProductionResources(dto.Resources, login.ClientId, login, tx, ct) .ConfigureAwait(false); //===================================================== // Generate Stoppage IDs and map to Detail //===================================================== if (dto.Stoppages != null && dto.Stoppages.Count > 0) { int nextStoppageId = nextId; foreach (var stoppage in dto.Stoppages) { stoppage.ProductionStoppageId = nextStoppageId++; stoppage.ProductionId = dto.ProductionId; var detail = dto.Details.FirstOrDefault(d => d.SlNo == stoppage.SlNo); if (detail == null) { throw new ArgumentException( $"No Production Detail found for Stoppage SlNo = {stoppage.SlNo}"); } stoppage.ProductionDetailId = detail.ProductionDetailId; await _dal.SaveProductionStoppage(stoppage,login,tx,ct).ConfigureAwait(false); } } //===================================================== // Generate ItemReason IDs and map to Detail // (Quality reason breakdown — Rejected/Rework split by cause) //===================================================== if (dto.Reasons != null && dto.Reasons.Count > 0) { int nextReasonId = nextId; // TITEMREASON's unique constraint is scoped per (header, detail) — // SlNo must be a running counter within each detail line, not the // caller's WO-stage matching key (DetailSlNo). var slNoByDetail = new Dictionary(); foreach (var reason in dto.Reasons) { var detail = dto.Details.FirstOrDefault(d => d.SlNo == reason.DetailSlNo); if (detail == null) { throw new ArgumentException( $"No Production Detail found for Reason DetailSlNo = {reason.DetailSlNo}"); } short rowSlNo = slNoByDetail.TryGetValue(detail.ProductionDetailId, out var last) ? (short)(last + 1) : (short)1; slNoByDetail[detail.ProductionDetailId] = rowSlNo; reason.ItemReasonId = nextReasonId++; reason.ObjectHeaderTypeId = EntityConstant.OBJECTPRODUCTION; reason.ObjectHeaderId = dto.ProductionId; reason.ObjectTypeId = EntityConstant.OBJECTPRODUCTIONDETAIL; reason.ObjectId = detail.ProductionDetailId; reason.BizTransactionTypeId = dto.BizTransactionTypeId; reason.ItemId = detail.ItemId; reason.SkuId = detail.SkuId; reason.DocumentDate = dto.ProductionDate; reason.SlNo = rowSlNo; await _dal.SaveItemReason(reason, login, tx, ct).ConfigureAwait(false); } } // Delta stock posting — only for details with stock posting enabled if (dto.IsStockPost != 3) // 3=None { foreach (var detail in dto.Details.Where(d => d.StockPostType > 0)) { await _stockPosition .EnsurePositionExistsAsync( detail.ItemId, detail.SkuId, dto.ToStoreId, dto.OUId, login, tx, ct) .ConfigureAwait(false); var ledgerPost = new ProductionStockLedgerPostDTO { StockLedgerNumber = dto.ProductionNumber, ProductionDate = dto.ProductionDate, ProductionNumber = dto.ProductionNumber, OUId = dto.OUId, BizTransactionTypeId = dto.BizTransactionTypeId, ProductionId = dto.ProductionId, ProductionDetailId = detail.ProductionDetailId, ObjectTypeId = EntityConstant.OBJECTPRODUCTION, ObjectDetailTypeId = EntityConstant.OBJECTPRODUCTIONDETAIL, AllocationId = detail.AllocationId > 0 ? detail.AllocationId : -1, StoreId = dto.ToStoreId, WorkCenterId = dto.WorkCenterId, ItemId = detail.ItemId, SkuId = detail.SkuId, // TSTOCKLEDGER STOCKPOSTTYPE: 0=ADD, 1=LESS; output=ADD (0) StockPostType = 0, ProductionQuantity = detail.ProductionQuantity, UomId = detail.UomId, OutputWeight = detail.OutputWeight, GoodQuantity = detail.GoodQuantity, RejectedQuantity = detail.RejectedQuantity, ReworkQuantity = detail.ReworkQuantity, OtherQuantity = detail.OtherQuantity, IndentDetailId = detail.IndentDetailId > 0 ? detail.IndentDetailId : -1, TenantId = login.ClientId }; await _dal.InsertStockLedgerFromProduction(ledgerPost, login, tx, ct) .ConfigureAwait(false); // Delta-update TSTOCKPOSITION quantities (postSign=+1 for output/add) await _stockPosition.UpdatePositionQuantitiesAsync( detail.ItemId, detail.SkuId, dto.ToStoreId, dto.OUId, goodQuantity: detail.GoodQuantity, shadowGoodQuantity: 0, rejectedQuantity: detail.RejectedQuantity, reworkQuantity: detail.ReworkQuantity, otherQuantity: detail.OtherQuantity, wipQuantity: 0, unlinkedWipGoodQty: 0, unlinkedWipReworkQty: 0, postSign: 1, login, tx, ct).ConfigureAwait(false); } } // Update WO status to InProgress for newly posted productions foreach (var detail in dto.Details.Where(d => d.IndentDetailId > 0)) { await _dal.UpdateWOReleaseStatus(detail.IndentDetailId, 2, login, tx, ct) .ConfigureAwait(false); } // Phase 5: SignalR push (OperatorConsoleHub + ReceiveQueueUpdate) added here return dto.ProductionId; }, new Dictionary { { "ProductionId", dto.ProductionId } }, isNewEntity: isNew); // Production is never cached (volatile transactional data) GB5Trace.Step("event-publish", new { EventTypeConstant.SAVEPRODUCTIONTRANSACTIONEVENTTYPEID }); return isNew ? $"{SuccessResponse.SaveSuccessMessage} {dto.ProductionId}" : SuccessResponse.UpdateSuccessMessage; } catch (Exception ex) { GB5Trace.MarkFailed("save-production-failed", ex); _logger.LogError(ex, "SaveProduction failed for ProductionId {ProductionId}", dto.ProductionId); throw; } } // ── Delete ──────────────────────────────────────────────────────────── public async Task DeleteProduction(int productionId, LoginDTO login, CancellationToken ct) { try { GB5Trace.Step("delete-production", new { productionId }); var tx = await _qe.BeginTransactionAsync(login); try { await _dal.DeleteProductionChildren(productionId, login, tx, ct).ConfigureAwait(false); await _dal.SoftDeleteProduction(productionId, login, tx, ct).ConfigureAwait(false); await _qe.CommitAsync(tx); } catch { await _qe.RollbackAsync(tx); throw; } GB5Trace.Step("event-publish", new { EventTypeConstant.DELETEPRODUCTIONTRANSACTIONEVENTTYPEID }); return SuccessResponse.DeleteSuccessMessage; } catch (Exception ex) { GB5Trace.MarkFailed("delete-production-failed", ex); _logger.LogError(ex, "DeleteProduction failed for ProductionId {ProductionId}", productionId); throw; } } // ── Save stoppage ───────────────────────────────────────────────────── public async Task SaveStoppage( ProductionStoppageSaveDTO dto, LoginDTO login, CancellationToken ct) { try { GB5Trace.Step("validate-stoppage", new { dto.ProductionId, dto.ProductionDetailId }); if (dto.ProductionId == 0) throw new ArgumentException("ProductionId is required."); if (dto.ProductionDetailId == 0) throw new ArgumentException("ProductionDetailId is required."); if (dto.StoppageReasonId == 0) throw new ArgumentException("StoppageReasonId is required."); if (dto.StopPageDuration == 0) throw new ArgumentException("StopPageDuration must be > 0."); bool isNew = dto.ProductionStoppageId == 0; if (isNew) { // Stoppages don't have their own auto-number in Constant.cs (they use PRODUCTIONSTOPPAGE) // Use negative sequential ID pattern per existing table conventions // For now, use PRODUCTIONDETAIL block as a proxy until a dedicated const is added var num = await _autoNumber .GetNumberAsync(1, AUTONUMBERCONSTANT.PRODUCTION, login) .ConfigureAwait(false); dto.ProductionStoppageId = num.StartNumber; } GB5Trace.Step("save-stoppage", new { dto.ProductionStoppageId, isNew }); var tx = await _qe.BeginTransactionAsync(login); try { await _dal.SaveProductionStoppage(dto, login, tx, ct).ConfigureAwait(false); await _qe.CommitAsync(tx); } catch { await _qe.RollbackAsync(tx); throw; } // Phase 5: SignalR push ReceiveStoppageAlert to WC + supervisor groups added here return isNew ? $"{SuccessResponse.SaveSuccessMessage} {dto.ProductionStoppageId}" : SuccessResponse.UpdateSuccessMessage; } catch (Exception ex) { GB5Trace.MarkFailed("save-stoppage-failed", ex); _logger.LogError(ex, "SaveStoppage failed for ProductionId {ProductionId}", dto.ProductionId); throw; } } // ── Update WO status ────────────────────────────────────────────────── public async Task UpdateWorkOrderStatus( int indentDetailId, byte releaseStatus, LoginDTO login, CancellationToken ct) { try { GB5Trace.Step("update-wo-status", new { indentDetailId, releaseStatus }); if (indentDetailId == 0) throw new ArgumentException("IndentDetailId is required."); var tx = await _qe.BeginTransactionAsync(login); try { await _dal.UpdateWOReleaseStatus(indentDetailId, releaseStatus, login, tx, ct) .ConfigureAwait(false); await _qe.CommitAsync(tx); } catch { await _qe.RollbackAsync(tx); throw; } // Phase 5: SignalR push ReceiveQueueUpdate added here return SuccessResponse.UpdateSuccessMessage; } catch (Exception ex) { GB5Trace.MarkFailed("update-wo-status-failed", ex); _logger.LogError(ex, "UpdateWorkOrderStatus failed for IndentDetailId {Id}", indentDetailId); throw; } } public async Task GetSelectListProduction(int FirstNumber, int MaxResult, CriteriaDTO CriteriaDTO, LoginDTO LoginDTO) { try { return await _dal.GetSelectListProduction(FirstNumber, MaxResult, CriteriaDTO, LoginDTO); } catch (Exception ex) { throw new Exception($"Error in ProductionBLL.GetSelectListProduction: {ex.Message}", ex); } } public async Task GetRejectionReport(int Type, int SubType, int FirstNumber, int MaxResult, CriteriaDTO CriteriaDTO, LoginDTO LoginDTO, bool IsCount = false) { try { return await _dal.GetRejectionReport(Type, SubType, FirstNumber, MaxResult, CriteriaDTO, LoginDTO, IsCount); } catch (Exception ex) { throw new Exception($"Error in ProductionBLL.GetRejectionReport: {ex.Message}", ex); } } } }