diff --git a/internal/common/service/common.fifo.service.go b/internal/common/service/common.fifo.service.go index 35aa2a5a..5b7adc2e 100644 --- a/internal/common/service/common.fifo.service.go +++ b/internal/common/service/common.fifo.service.go @@ -192,6 +192,16 @@ func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*St if req.Quantity < 0 { return nil, errors.New("quantity must be zero or greater") } + if s.logger.IsLevelEnabled(logrus.DebugLevel) { + s.logger.WithFields(logrus.Fields{ + "usable_key": req.UsableKey.String(), + "usable_id": req.UsableID, + "requested_quantity": req.Quantity, + "allow_pending": req.AllowPending, + "product_warehouse_id": req.ProductWarehouseID, + }).Debug("fifo consume request") + } + cfg, ok := fifo.Usable(req.UsableKey) if !ok { @@ -220,6 +230,19 @@ func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*St currentPending := ctxRow.PendingQty currentTotal := currentUsage + currentPending delta := req.Quantity - currentTotal + if s.logger.IsLevelEnabled(logrus.DebugLevel) { + s.logger.WithFields(logrus.Fields{ + "usable_key": req.UsableKey.String(), + "usable_id": req.UsableID, + "product_warehouse_id": productWarehouseID, + "current_usage_qty": currentUsage, + "current_pending_qty": currentPending, + "current_total_qty": currentTotal, + "requested_quantity": req.Quantity, + "calculated_delta": delta, + "input_warehouse_match": req.ProductWarehouseID == 0 || req.ProductWarehouseID == productWarehouseID, + }).Debug("fifo consume context") + } var ( usageDelta float64 @@ -285,6 +308,20 @@ func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*St result.ReleasedQuantity = releasedAmount result.UsageQuantity = currentUsage + usageDelta result.PendingQuantity = currentPending + pendingDelta + if s.logger.IsLevelEnabled(logrus.DebugLevel) { + s.logger.WithFields(logrus.Fields{ + "usable_key": req.UsableKey.String(), + "usable_id": req.UsableID, + "product_warehouse_id": productWarehouseID, + "usage_delta": usageDelta, + "pending_delta": pendingDelta, + "released_quantity": releasedAmount, + "added_allocations": len(addedAlloc), + "final_usage_qty": result.UsageQuantity, + "final_pending_qty": result.PendingQuantity, + "final_requested_qty": result.RequestedQuantity, + }).Debug("fifo consume result") + } return nil }) @@ -299,6 +336,13 @@ func (s *fifoService) ReleaseUsage(ctx context.Context, req StockReleaseRequest) if req.UsableID == 0 || strings.TrimSpace(req.UsableKey.String()) == "" { return errors.New("usable key and id are required") } + if s.logger.IsLevelEnabled(logrus.DebugLevel) { + s.logger.WithFields(logrus.Fields{ + "usable_key": req.UsableKey.String(), + "usable_id": req.UsableID, + "reason": req.Reason, + }).Debug("fifo release request") + } return s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error { cfg, ok := fifo.Usable(req.UsableKey) @@ -310,6 +354,16 @@ func (s *fifoService) ReleaseUsage(ctx context.Context, req StockReleaseRequest) if err != nil { return err } + if s.logger.IsLevelEnabled(logrus.DebugLevel) { + s.logger.WithFields(logrus.Fields{ + "usable_key": req.UsableKey.String(), + "usable_id": req.UsableID, + "product_warehouse_id": ctxRow.ProductWarehouseID, + "current_usage_qty": ctxRow.UsageQty, + "current_pending_qty": ctxRow.PendingQty, + "current_total_qty": ctxRow.UsageQty + ctxRow.PendingQty, + }).Debug("fifo release context") + } var usageDelta, pendingDelta float64 if ctxRow.UsageQty > 0 { @@ -326,6 +380,15 @@ func (s *fifoService) ReleaseUsage(ctx context.Context, req StockReleaseRequest) return err } + if s.logger.IsLevelEnabled(logrus.DebugLevel) { + s.logger.WithFields(logrus.Fields{ + "usable_key": req.UsableKey.String(), + "usable_id": req.UsableID, + "usage_delta": usageDelta, + "pending_delta": pendingDelta, + }).Debug("fifo release applied") + } + return s.allocations.ReleaseByUsable(ctx, req.UsableKey.String(), req.UsableID, req.Reason, func(db *gorm.DB) *gorm.DB { return s.txOrDB(tx, db) }) diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index f15f37df..47d41648 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -194,13 +194,17 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e StockLogId: newLog.Id, ProductWarehouseId: productWarehouse.Id, } + if err := s.AdjustmentStockRepository.WithTx(tx).CreateOne(ctx, adjustmentStock, nil); err != nil { + s.Log.Errorf("Failed to create adjustment stock: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create adjustment stock record") + } if transactionType == string(utils.StockLogTransactionTypeIncrease) { // Adjustment INCREASE → Replenish stock (Stockable) note := fmt.Sprintf("Stock Adjustment IN #%d", newLog.Id) - replenishResult, err := s.FifoSvc.Replenish(ctx, common.StockReplenishRequest{ + _, err := s.FifoSvc.Replenish(ctx, common.StockReplenishRequest{ StockableKey: "ADJUSTMENT_IN", - StockableID: newLog.Id, + StockableID: adjustmentStock.Id, ProductWarehouseID: uint(productWarehouse.Id), Quantity: req.Quantity, Note: ¬e, @@ -210,15 +214,11 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to replenish stock via FIFO: %v", err)) } - // Update stockable tracking fields - adjustmentStock.TotalQty = replenishResult.AddedQuantity - adjustmentStock.TotalUsed = 0 - } else { // Adjustment DECREASE → Consume stock (Usable) - consumeResult, err := s.FifoSvc.Consume(ctx, common.StockConsumeRequest{ + _, err := s.FifoSvc.Consume(ctx, common.StockConsumeRequest{ UsableKey: "ADJUSTMENT_OUT", - UsableID: newLog.Id, + UsableID: adjustmentStock.Id, ProductWarehouseID: uint(productWarehouse.Id), Quantity: req.Quantity, AllowPending: false, // Don't allow pending for adjustment @@ -227,16 +227,6 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e if err != nil { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to consume stock via FIFO: %v", err)) } - - // Update usable tracking fields - adjustmentStock.UsageQty = consumeResult.UsageQuantity - adjustmentStock.PendingQty = consumeResult.PendingQuantity - } - - // Save AdjustmentStock record - if err := s.AdjustmentStockRepository.WithTx(tx).CreateOne(ctx, adjustmentStock, nil); err != nil { - s.Log.Errorf("Failed to create adjustment stock: %+v", err) - return fiber.NewError(fiber.StatusInternalServerError, "Failed to create adjustment stock record") } // Update ProductWarehouse quantity (for backward compatibility/reporting) diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index d75060ad..941d4507 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -295,16 +295,17 @@ func (r *RecordingRepositoryImpl) GetTotalChickinByProjectFlockKandang(tx *gorm. func (r *RecordingRepositoryImpl) GetFeedUsageInGrams(tx *gorm.DB, recordingID uint) (float64, error) { var rows []struct { - UsageQty float64 + TotalQty float64 UomName string } if err := tx. Table("recording_stocks"). - Select("COALESCE(recording_stocks.usage_qty, 0) AS usage_qty, LOWER(uoms.name) AS uom_name"). + Select("COALESCE(recording_stocks.usage_qty, 0) + COALESCE(recording_stocks.pending_qty, 0) AS total_qty, LOWER(uoms.name) AS uom_name"). Joins("JOIN product_warehouses ON product_warehouses.id = recording_stocks.product_warehouse_id"). Joins("JOIN products ON products.id = product_warehouses.product_id"). Joins("JOIN uoms ON uoms.id = products.uom_id"). + Joins("JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = ? AND UPPER(flags.name) = ?", entity.FlagableTypeProduct, "PAKAN"). Where("recording_stocks.recording_id = ?", recordingID). Scan(&rows).Error; err != nil { return 0, err @@ -312,16 +313,16 @@ func (r *RecordingRepositoryImpl) GetFeedUsageInGrams(tx *gorm.DB, recordingID u var total float64 for _, row := range rows { - if row.UsageQty <= 0 { + if row.TotalQty <= 0 { continue } switch strings.TrimSpace(row.UomName) { case "kilogram", "kg", "kilograms", "kilo": - total += row.UsageQty * 1000 + total += row.TotalQty * 1000 case "gram", "g", "grams": - total += row.UsageQty + total += row.TotalQty default: - total += row.UsageQty + total += row.TotalQty } } return total, nil diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 946aa5b3..c9ca74f5 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -247,11 +247,13 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent } mappedStocks := recordingutil.MapStocks(createdRecording.Id, req.Stocks) + stockDesired := resetStockQuantitiesForFIFO(mappedStocks, s.FifoSvc != nil) if err := s.Repository.CreateStocks(tx, mappedStocks); err != nil { s.Log.Errorf("Failed to persist stocks: %+v", err) return err } + applyStockDesiredQuantities(mappedStocks, stockDesired, s.FifoSvc != nil) if err := s.consumeRecordingStocks(ctx, tx, mappedStocks); err != nil { return err } @@ -324,6 +326,49 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin hasDepletionChanges := req.Depletions != nil hasEggChanges := req.Eggs != nil + var existingStocks []entity.RecordingStock + if hasStockChanges { + existingStocks, err = s.Repository.ListStocks(tx, recordingEntity.Id) + if err != nil { + s.Log.Errorf("Failed to list existing stocks: %+v", err) + return err + } + if s.Log != nil && s.Log.IsLevelEnabled(logrus.DebugLevel) { + s.Log.WithFields(logrus.Fields{ + "recording_id": recordingEntity.Id, + "existing": summarizeExistingStocks(existingStocks), + "incoming": summarizeIncomingStocks(req.Stocks), + }).Debug("recording update stock comparison") + } + if stocksMatch(existingStocks, req.Stocks) { + hasStockChanges = false + } + } + + var existingDepletions []entity.RecordingDepletion + if hasDepletionChanges { + existingDepletions, err = s.Repository.ListDepletions(tx, recordingEntity.Id) + if err != nil { + s.Log.Errorf("Failed to list existing depletions: %+v", err) + return err + } + if depletionsMatch(existingDepletions, req.Depletions) { + hasDepletionChanges = false + } + } + + var existingEggs []entity.RecordingEgg + if hasEggChanges { + existingEggs, err = s.Repository.ListEggs(tx, recordingEntity.Id) + if err != nil { + s.Log.Errorf("Failed to list existing eggs: %+v", err) + return err + } + if eggsMatch(existingEggs, req.Eggs) { + hasEggChanges = false + } + } + if !hasStockChanges && !hasDepletionChanges && !hasEggChanges { return nil } @@ -355,39 +400,12 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } if hasStockChanges { - existingStocks, err := s.Repository.ListStocks(tx, recordingEntity.Id) - if err != nil { - s.Log.Errorf("Failed to list existing stocks: %+v", err) - return err - } - - if err := s.releaseRecordingStocks(ctx, tx, existingStocks); err != nil { - return err - } - - if err := s.Repository.DeleteStocks(tx, recordingEntity.Id); err != nil { - s.Log.Errorf("Failed to clear stocks: %+v", err) - return err - } - - mappedStocks := recordingutil.MapStocks(recordingEntity.Id, req.Stocks) - if err := s.Repository.CreateStocks(tx, mappedStocks); err != nil { - s.Log.Errorf("Failed to update stocks: %+v", err) - return err - } - - if err := s.consumeRecordingStocks(ctx, tx, mappedStocks); err != nil { + if err := s.syncRecordingStocks(ctx, tx, recordingEntity.Id, existingStocks, req.Stocks); err != nil { return err } } if hasDepletionChanges { - existingDepletions, err := s.Repository.ListDepletions(tx, recordingEntity.Id) - if err != nil { - s.Log.Errorf("Failed to list existing depletions: %+v", err) - return err - } - if err := s.Repository.DeleteDepletions(tx, recordingEntity.Id); err != nil { s.Log.Errorf("Failed to clear depletions: %+v", err) return err @@ -406,12 +424,6 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } if hasEggChanges { - existingEggs, err := s.Repository.ListEggs(tx, recordingEntity.Id) - if err != nil { - s.Log.Errorf("Failed to list existing eggs: %+v", err) - return err - } - if err := s.Repository.DeleteEggs(tx, recordingEntity.Id); err != nil { s.Log.Errorf("Failed to clear eggs: %+v", err) return err @@ -429,7 +441,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } } - if hasStockChanges || hasDepletionChanges { + if hasStockChanges || hasDepletionChanges || hasEggChanges { if err := s.computeAndUpdateMetrics(ctx, tx, recordingEntity); err != nil { s.Log.Errorf("Failed to recompute recording metrics: %+v", err) return err @@ -680,12 +692,27 @@ func (s *recordingService) consumeRecordingStocks(ctx context.Context, tx *gorm. if stock.UsageQty != nil { desired = *stock.UsageQty } + var pending float64 + if stock.PendingQty != nil { + pending = *stock.PendingQty + } + desiredTotal := desired + pending + + if s.Log != nil && s.Log.IsLevelEnabled(logrus.DebugLevel) { + s.Log.WithFields(logrus.Fields{ + "recording_stock_id": stock.Id, + "product_warehouse_id": stock.ProductWarehouseId, + "desired_usage_qty": desired, + "desired_pending_qty": pending, + "desired_total_qty": desiredTotal, + }).Debug("recording fifo consume start") + } result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ UsableKey: recordingStockUsableKey, UsableID: stock.Id, ProductWarehouseID: stock.ProductWarehouseId, - Quantity: desired, + Quantity: desiredTotal, AllowPending: true, Tx: tx, }) @@ -694,6 +721,17 @@ func (s *recordingService) consumeRecordingStocks(ctx context.Context, tx *gorm. return err } + if s.Log != nil && s.Log.IsLevelEnabled(logrus.DebugLevel) { + s.Log.WithFields(logrus.Fields{ + "recording_stock_id": stock.Id, + "product_warehouse_id": stock.ProductWarehouseId, + "result_usage_qty": result.UsageQuantity, + "result_pending_qty": result.PendingQuantity, + "released_qty": result.ReleasedQuantity, + "added_allocations": len(result.AddedAllocations), + }).Debug("recording fifo consume result") + } + if err := s.Repository.UpdateStockUsage(tx, stock.Id, result.UsageQuantity, result.PendingQuantity); err != nil { return err } @@ -716,6 +754,23 @@ func (s *recordingService) releaseRecordingStocks(ctx context.Context, tx *gorm. continue } + var usage float64 + var pending float64 + if stock.UsageQty != nil { + usage = *stock.UsageQty + } + if stock.PendingQty != nil { + pending = *stock.PendingQty + } + if s.Log != nil && s.Log.IsLevelEnabled(logrus.DebugLevel) { + s.Log.WithFields(logrus.Fields{ + "recording_stock_id": stock.Id, + "product_warehouse_id": stock.ProductWarehouseId, + "current_usage_qty": usage, + "current_pending_qty": pending, + }).Debug("recording fifo release start") + } + if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ UsableKey: recordingStockUsableKey, UsableID: stock.Id, @@ -771,6 +826,288 @@ func (s *recordingService) adjustProductWarehouseQuantities(ctx context.Context, return s.ProductWarehouseRepo.AdjustQuantities(ctx, deltas, func(*gorm.DB) *gorm.DB { return tx }) } +type desiredStock struct { + Usage float64 + Pending float64 +} + +func resetStockQuantitiesForFIFO(stocks []entity.RecordingStock, enabled bool) []desiredStock { + desired := make([]desiredStock, len(stocks)) + for i := range stocks { + if stocks[i].UsageQty != nil { + desired[i].Usage = *stocks[i].UsageQty + } + if stocks[i].PendingQty != nil { + desired[i].Pending = *stocks[i].PendingQty + } + if !enabled { + continue + } + zero := 0.0 + stocks[i].UsageQty = &zero + stocks[i].PendingQty = &zero + } + return desired +} + +func applyStockDesiredQuantities(stocks []entity.RecordingStock, desired []desiredStock, enabled bool) { + if !enabled { + return + } + for i := range stocks { + if i >= len(desired) { + break + } + usage := desired[i].Usage + pending := desired[i].Pending + stocks[i].UsageQty = &usage + stocks[i].PendingQty = &pending + } +} + +func (s *recordingService) syncRecordingStocks( + ctx context.Context, + tx *gorm.DB, + recordingID uint, + existing []entity.RecordingStock, + incoming []validation.Stock, +) error { + if s.FifoSvc == nil { + if err := s.Repository.DeleteStocks(tx, recordingID); err != nil { + return err + } + mapped := recordingutil.MapStocks(recordingID, incoming) + return s.Repository.CreateStocks(tx, mapped) + } + + existingByWarehouse := make(map[uint][]entity.RecordingStock) + for _, stock := range existing { + existingByWarehouse[stock.ProductWarehouseId] = append(existingByWarehouse[stock.ProductWarehouseId], stock) + } + + stocksToConsume := make([]entity.RecordingStock, 0, len(incoming)) + for _, item := range incoming { + list := existingByWarehouse[item.ProductWarehouseId] + var stock entity.RecordingStock + if len(list) > 0 { + stock = list[0] + existingByWarehouse[item.ProductWarehouseId] = list[1:] + } else { + zero := 0.0 + stock = entity.RecordingStock{ + RecordingId: recordingID, + ProductWarehouseId: item.ProductWarehouseId, + UsageQty: &zero, + PendingQty: &zero, + } + if err := tx.Create(&stock).Error; err != nil { + return err + } + } + + desired := item.Qty + stock.UsageQty = &desired + if item.PendingQty != nil { + pending := *item.PendingQty + stock.PendingQty = &pending + } + stocksToConsume = append(stocksToConsume, stock) + } + + var leftovers []entity.RecordingStock + for _, list := range existingByWarehouse { + leftovers = append(leftovers, list...) + } + if len(leftovers) > 0 { + if err := s.releaseRecordingStocks(ctx, tx, leftovers); err != nil { + return err + } + ids := make([]uint, 0, len(leftovers)) + for _, stock := range leftovers { + if stock.Id != 0 { + ids = append(ids, stock.Id) + } + } + if len(ids) > 0 { + if err := tx.Where("id IN ?", ids).Delete(&entity.RecordingStock{}).Error; err != nil { + return err + } + } + } + + if len(stocksToConsume) == 0 { + return nil + } + return s.consumeRecordingStocks(ctx, tx, stocksToConsume) +} + +type eggTotals struct { + Qty int + Weight float64 +} + +type stockTotals struct { + Usage float64 + Pending float64 + Total float64 +} + +func summarizeExistingStocks(stocks []entity.RecordingStock) map[uint]stockTotals { + totals := make(map[uint]stockTotals) + for _, stock := range stocks { + var usage float64 + var pending float64 + if stock.UsageQty != nil { + usage = *stock.UsageQty + } + if stock.PendingQty != nil { + pending = *stock.PendingQty + } + current := totals[stock.ProductWarehouseId] + current.Usage += usage + current.Pending += pending + current.Total += usage + pending + totals[stock.ProductWarehouseId] = current + } + return totals +} + +func summarizeIncomingStocks(stocks []validation.Stock) map[uint]stockTotals { + totals := make(map[uint]stockTotals) + for _, stock := range stocks { + var pending float64 + if stock.PendingQty != nil { + pending = *stock.PendingQty + } + current := totals[stock.ProductWarehouseId] + current.Usage += stock.Qty + current.Pending += pending + current.Total += stock.Qty + pending + totals[stock.ProductWarehouseId] = current + } + return totals +} + +func stocksMatch(existing []entity.RecordingStock, incoming []validation.Stock) bool { + hasPending := false + for _, item := range incoming { + if item.PendingQty != nil { + hasPending = true + break + } + } + + existingUsage := make(map[uint]float64) + existingTotal := make(map[uint]float64) + for _, stock := range existing { + var usage float64 + var pending float64 + if stock.UsageQty != nil { + usage = *stock.UsageQty + } + if stock.PendingQty != nil { + pending = *stock.PendingQty + } + existingUsage[stock.ProductWarehouseId] += usage + existingTotal[stock.ProductWarehouseId] += usage + pending + } + + incomingUsage := make(map[uint]float64) + incomingTotal := make(map[uint]float64) + for _, item := range incoming { + var pending float64 + if item.PendingQty != nil { + pending = *item.PendingQty + } + incomingUsage[item.ProductWarehouseId] += item.Qty + incomingTotal[item.ProductWarehouseId] += item.Qty + pending + } + + if hasPending { + return floatMapsMatch(existingTotal, incomingTotal) + } + return floatMapsMatch(existingUsage, incomingUsage) +} + +func depletionsMatch(existing []entity.RecordingDepletion, incoming []validation.Depletion) bool { + existingTotals := make(map[uint]float64) + for _, dep := range existing { + existingTotals[dep.ProductWarehouseId] += dep.Qty + } + + incomingTotals := make(map[uint]float64) + for _, dep := range incoming { + incomingTotals[dep.ProductWarehouseId] += dep.Qty + } + + return floatMapsMatch(existingTotals, incomingTotals) +} + +func eggsMatch(existing []entity.RecordingEgg, incoming []validation.Egg) bool { + existingTotals := make(map[uint]eggTotals) + for _, egg := range existing { + weight := 0.0 + if egg.Weight != nil { + weight = *egg.Weight + } + current := existingTotals[egg.ProductWarehouseId] + current.Qty += egg.Qty + current.Weight += float64(egg.Qty) * weight + existingTotals[egg.ProductWarehouseId] = current + } + + incomingTotals := make(map[uint]eggTotals) + for _, egg := range incoming { + weight := 0.0 + if egg.Weight != nil { + weight = *egg.Weight + } + current := incomingTotals[egg.ProductWarehouseId] + current.Qty += egg.Qty + current.Weight += float64(egg.Qty) * weight + incomingTotals[egg.ProductWarehouseId] = current + } + + if len(existingTotals) != len(incomingTotals) { + return false + } + + for key, existingTotal := range existingTotals { + incomingTotal, ok := incomingTotals[key] + if !ok { + return false + } + if existingTotal.Qty != incomingTotal.Qty { + return false + } + if !floatNearlyEqual(existingTotal.Weight, incomingTotal.Weight) { + return false + } + } + + return true +} + +func floatMapsMatch(a, b map[uint]float64) bool { + if len(a) != len(b) { + return false + } + for key, value := range a { + other, ok := b[key] + if !ok { + return false + } + if !floatNearlyEqual(value, other) { + return false + } + } + return true +} + +func floatNearlyEqual(a, b float64) bool { + return math.Abs(a-b) <= 0.000001 +} + func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm.DB, recording *entity.Recording) error { day := 0 if recording.Day != nil {