From 8ad923a90ac78585d4a89114001507f7db68ebe4 Mon Sep 17 00:00:00 2001 From: ragilap Date: Mon, 9 Feb 2026 16:48:42 +0700 Subject: [PATCH 01/18] [FEAT/BE] Add saparator type search get all productwarehouse --- .../services/product_warehouse.service.go | 54 ++++++++++++++----- .../product_warehouse.validation.go | 2 +- 2 files changed, 43 insertions(+), 13 deletions(-) diff --git a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go index 7132644e..188c4506 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -100,9 +100,13 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) offset := (params.Page - 1) * params.Limit + var marketingTypes []string if params.Type != "" { - if !utils.IsValidMarketingType(params.Type) { - return nil, 0, fiber.NewError(fiber.StatusBadRequest, "Invalid marketing type") + marketingTypes = utils.ParseQueryArray(params.Type) + for _, t := range marketingTypes { + if !utils.IsValidMarketingType(t) { + return nil, 0, fiber.NewError(fiber.StatusBadRequest, "Invalid marketing type") + } } } @@ -135,16 +139,42 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) db = db.Where("warehouse_id = ?", params.WarehouseId) } - if params.Type != "" { - switch params.Type { - case string(utils.MarketingTypeAyamPullet): - db = s.Repository.ApplyFlagsFilter(db, []string{string(utils.FlagDOC), string(utils.FlagPullet), string(utils.FlagLayer)}) - case string(utils.MarketingTypeAyam): - db = s.Repository.ApplyFlagsFilter(db, []string{string(utils.FlagAyamAfkir), string(utils.FlagAyamCulling), string(utils.FlagAyamMati)}) - case string(utils.MarketingTypeTelur): - db = s.Repository.ApplyFlagsFilter(db, []string{string(utils.FlagTelur), string(utils.FlagTelurUtuh), string(utils.FlagTelurPecah), string(utils.FlagTelurPutih), string(utils.FlagTelurRetak)}) - case string(utils.MarketingTypeTrading): - db = s.Repository.ApplyFlagsFilter(db, []string{string(utils.FlagPakan), string(utils.FlagPreStarter), string(utils.FlagStarter), string(utils.FlagFinisher), string(utils.FlagOVK), string(utils.FlagObat), string(utils.FlagVitamin), string(utils.FlagKimia), string(utils.FlagEkspedisi)}) + if len(marketingTypes) > 0 { + flagSet := make(map[string]struct{}) + for _, t := range marketingTypes { + switch t { + case string(utils.MarketingTypeAyamPullet): + flagSet[string(utils.FlagDOC)] = struct{}{} + flagSet[string(utils.FlagPullet)] = struct{}{} + flagSet[string(utils.FlagLayer)] = struct{}{} + case string(utils.MarketingTypeAyam): + flagSet[string(utils.FlagAyamAfkir)] = struct{}{} + flagSet[string(utils.FlagAyamCulling)] = struct{}{} + flagSet[string(utils.FlagAyamMati)] = struct{}{} + case string(utils.MarketingTypeTelur): + flagSet[string(utils.FlagTelur)] = struct{}{} + flagSet[string(utils.FlagTelurUtuh)] = struct{}{} + flagSet[string(utils.FlagTelurPecah)] = struct{}{} + flagSet[string(utils.FlagTelurPutih)] = struct{}{} + flagSet[string(utils.FlagTelurRetak)] = struct{}{} + case string(utils.MarketingTypeTrading): + flagSet[string(utils.FlagPakan)] = struct{}{} + flagSet[string(utils.FlagPreStarter)] = struct{}{} + flagSet[string(utils.FlagStarter)] = struct{}{} + flagSet[string(utils.FlagFinisher)] = struct{}{} + flagSet[string(utils.FlagOVK)] = struct{}{} + flagSet[string(utils.FlagObat)] = struct{}{} + flagSet[string(utils.FlagVitamin)] = struct{}{} + flagSet[string(utils.FlagKimia)] = struct{}{} + flagSet[string(utils.FlagEkspedisi)] = struct{}{} + } + } + if len(flagSet) > 0 { + flags := make([]string, 0, len(flagSet)) + for f := range flagSet { + flags = append(flags, f) + } + db = s.Repository.ApplyFlagsFilter(db, flags) } } diff --git a/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go b/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go index 7e7da7a6..5d1f4e0a 100644 --- a/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go +++ b/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go @@ -20,5 +20,5 @@ type Query struct { Flags string `query:"flags" validate:"omitempty"` KandangId uint `query:"kandang_id" validate:"omitempty,number,min=1"` TransferContext string `query:"transfer_context" validate:"omitempty,oneof=inventory_transfer"` - Type string `query:"type" validate:"omitempty,oneof=AYAM TELUR TRADING AYAM_PULLET"` + Type string `query:"type" validate:"omitempty"` } From cad9328e5da251c41d6084242ca029dc48ff7b88 Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 10 Feb 2026 15:21:48 +0700 Subject: [PATCH 02/18] [FEAT/BE] Add restrict purchase chickin --- internal/entities/purchase_item.go | 1 + .../modules/purchases/dto/purchase.dto.go | 2 + .../purchases/services/purchase.service.go | 157 ++++++++++++++++++ 3 files changed, 160 insertions(+) diff --git a/internal/entities/purchase_item.go b/internal/entities/purchase_item.go index 724c6376..a29d4173 100644 --- a/internal/entities/purchase_item.go +++ b/internal/entities/purchase_item.go @@ -21,6 +21,7 @@ type PurchaseItem struct { Price float64 `gorm:"type:numeric(15,3);default:0"` TotalPrice float64 `gorm:"type:numeric(15,3);default:0"` ExpenseNonstockId *uint64 + HasChickin bool `gorm:"-" json:"-"` // Relations ExpenseNonstock *ExpenseNonstock `gorm:"foreignKey:ExpenseNonstockId;references:Id"` diff --git a/internal/modules/purchases/dto/purchase.dto.go b/internal/modules/purchases/dto/purchase.dto.go index c8df2294..849ffd63 100644 --- a/internal/modules/purchases/dto/purchase.dto.go +++ b/internal/modules/purchases/dto/purchase.dto.go @@ -67,6 +67,7 @@ type PurchaseItemDTO struct { VehicleNumber *string `json:"vehicle_number"` TransportPerItem *float64 `json:"transport_per_item,omitempty"` ExpeditionVendor *supplierDTO.SupplierRelationDTO `json:"expedition_vendor,omitempty"` + HasChickin bool `json:"has_chickin"` } type PoExpeditionDTO struct { @@ -100,6 +101,7 @@ func ToPurchaseItemDTO(item entity.PurchaseItem) PurchaseItemDTO { TravelNumber: item.TravelNumber, TravelDocumentPath: item.TravelNumberDocs, VehicleNumber: item.VehicleNumber, + HasChickin: item.HasChickin, } if item.Product != nil && item.Product.Id != 0 { diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index 703c04b9..6ecc6e1f 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -255,6 +255,39 @@ func (s *purchaseService) GetOne(c *fiber.Ctx, id uint) (*entity.Purchase, error if err := s.attachLatestApproval(c.Context(), purchase); err != nil { s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", id, err) } + if len(purchase.Items) > 0 { + itemIDs := make([]uint, 0, len(purchase.Items)) + for i := range purchase.Items { + if purchase.Items[i].Id == 0 { + continue + } + itemIDs = append(itemIDs, purchase.Items[i].Id) + } + if len(itemIDs) > 0 { + var usedIDs []uint + if err := s.PurchaseRepo.DB().WithContext(c.Context()). + Model(&entity.StockAllocation{}). + Distinct("stockable_id"). + Where("stockable_type = ? AND stockable_id IN ? AND usable_type = ? AND status IN ?", + fifo.StockableKeyPurchaseItems.String(), + itemIDs, + fifo.UsableKeyProjectChickin.String(), + []string{entity.StockAllocationStatusActive, entity.StockAllocationStatusPending}, + ). + Pluck("stockable_id", &usedIDs).Error; err != nil { + return nil, err + } + usedSet := make(map[uint]struct{}, len(usedIDs)) + for _, id := range usedIDs { + usedSet[id] = struct{}{} + } + for i := range purchase.Items { + if _, ok := usedSet[purchase.Items[i].Id]; ok { + purchase.Items[i].HasChickin = true + } + } + } + } s.applyTravelDocumentURLs(c.Context(), purchase) return purchase, nil @@ -498,6 +531,54 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid return nil, utils.BadRequest("Items must not be empty for staff approval") } + if action == entity.ApprovalActionApproved { + itemIDs := make([]uint, 0, len(purchase.Items)) + itemByID := make(map[uint]entity.PurchaseItem, len(purchase.Items)) + for i := range purchase.Items { + if purchase.Items[i].Id == 0 { + continue + } + itemIDs = append(itemIDs, purchase.Items[i].Id) + itemByID[purchase.Items[i].Id] = purchase.Items[i] + } + if len(itemIDs) > 0 { + var usedIDs []uint + if err := s.PurchaseRepo.DB().WithContext(ctx). + Model(&entity.StockAllocation{}). + Distinct("stockable_id"). + Where("stockable_type = ? AND stockable_id IN ? AND usable_type = ? AND status IN ?", + fifo.StockableKeyPurchaseItems.String(), + itemIDs, + fifo.UsableKeyProjectChickin.String(), + []string{entity.StockAllocationStatusActive, entity.StockAllocationStatusPending}, + ). + Pluck("stockable_id", &usedIDs).Error; err != nil { + return nil, err + } + if len(usedIDs) > 0 { + usedSet := make(map[uint]struct{}, len(usedIDs)) + for _, id := range usedIDs { + usedSet[id] = struct{}{} + } + for _, payload := range req.Items { + if payload.PurchaseItemID == 0 || payload.Qty == nil { + continue + } + if _, used := usedSet[payload.PurchaseItemID]; !used { + continue + } + item, ok := itemByID[payload.PurchaseItemID] + if !ok { + continue + } + if *payload.Qty != item.SubQty { + return nil, utils.BadRequest("Purchase sudah chickin, qty tidak bisa diubah") + } + } + } + } + } + payload, err := s.buildStaffAdjustmentPayload(c.Context(), purchase, req, syncReceiving) if err != nil { return nil, err @@ -745,6 +826,54 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation req.Items[idx].TravelDocumentPath = &uploadedURL } } + if action == entity.ApprovalActionApproved { + itemIDs := make([]uint, 0, len(purchase.Items)) + itemByID := make(map[uint]entity.PurchaseItem, len(purchase.Items)) + for i := range purchase.Items { + if purchase.Items[i].Id == 0 { + continue + } + itemIDs = append(itemIDs, purchase.Items[i].Id) + itemByID[purchase.Items[i].Id] = purchase.Items[i] + } + if len(itemIDs) > 0 { + var usedIDs []uint + if err := s.PurchaseRepo.DB().WithContext(ctx). + Model(&entity.StockAllocation{}). + Distinct("stockable_id"). + Where("stockable_type = ? AND stockable_id IN ? AND usable_type = ? AND status IN ?", + fifo.StockableKeyPurchaseItems.String(), + itemIDs, + fifo.UsableKeyProjectChickin.String(), + []string{entity.StockAllocationStatusActive, entity.StockAllocationStatusPending}, + ). + Pluck("stockable_id", &usedIDs).Error; err != nil { + return nil, err + } + if len(usedIDs) > 0 { + usedSet := make(map[uint]struct{}, len(usedIDs)) + for _, id := range usedIDs { + usedSet[id] = struct{}{} + } + for _, payload := range req.Items { + if _, used := usedSet[payload.PurchaseItemID]; !used { + continue + } + item, ok := itemByID[payload.PurchaseItemID] + if !ok { + continue + } + receivedQty := item.SubQty + if payload.ReceivedQty != nil { + receivedQty = *payload.ReceivedQty + } + if receivedQty != item.TotalQty { + return nil, utils.BadRequest("Purchase sudah chickin, qty penerimaan tidak bisa diubah") + } + } + } + } + } itemMap := make(map[uint]*entity.PurchaseItem, len(purchase.Items)) for i := range purchase.Items { @@ -1367,6 +1496,30 @@ func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint) error { } transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + itemIDs := make([]uint, 0, len(itemsToDelete)) + for _, item := range itemsToDelete { + if item.Id == 0 { + continue + } + itemIDs = append(itemIDs, item.Id) + } + if len(itemIDs) > 0 { + var count int64 + if err := tx.Model(&entity.StockAllocation{}). + Where("stockable_type = ? AND stockable_id IN ? AND usable_type = ? AND status IN ?", + fifo.StockableKeyPurchaseItems.String(), + itemIDs, + fifo.UsableKeyProjectChickin.String(), + []string{entity.StockAllocationStatusActive, entity.StockAllocationStatusPending}, + ). + Count(&count).Error; err != nil { + return err + } + if count > 0 { + return utils.BadRequest("Purchase already chickin, failed to delete purchase") + } + } + if err := s.rollbackPurchaseStock(ctx, tx, itemsToDelete, note, actorID); err != nil { return err } @@ -1383,6 +1536,10 @@ func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint) error { return nil }) if transactionErr != nil { + var fe *fiber.Error + if errors.As(transactionErr, &fe) { + return fe + } if errors.Is(transactionErr, gorm.ErrRecordNotFound) { return utils.NotFound("Purchase not found") } From ad46f8aca0df0824981f0e3b8a97a0fa015d3167 Mon Sep 17 00:00:00 2001 From: ragilap Date: Wed, 11 Feb 2026 09:57:51 +0700 Subject: [PATCH 03/18] [FEAT/BE] recording reject add --- .../recordings/services/recording.service.go | 90 +++++++++++-------- 1 file changed, 53 insertions(+), 37 deletions(-) diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index b9b1126e..f789144c 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -692,6 +692,13 @@ func (s recordingService) Approval(c *fiber.Ctx, req *validation.Approve) ([]ent ); err != nil { return err } + + if action == entity.ApprovalActionRejected { + note := fmt.Sprintf("Recording-Reject#%d", id) + if err := s.rollbackRecordingInventory(ctx, tx, id, note, actorID); err != nil { + return err + } + } } return nil @@ -729,43 +736,7 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { note := fmt.Sprintf("Recording-Delete#%d", id) return s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { - oldDepletions, err := s.Repository.ListDepletions(tx, id) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - s.Log.Errorf("Failed to list depletions before delete: %+v", err) - return err - } - if s.FifoSvc != nil { - if err := s.releaseRecordingDepletions(ctx, tx, oldDepletions, note, actorID); err != nil { - return err - } - } - - oldEggs, err := s.Repository.ListEggs(tx, id) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - s.Log.Errorf("Failed to list eggs before delete: %+v", err) - return err - } - if s.FifoSvc != nil { - if err := ensureRecordingEggsUnused(oldEggs); err != nil { - return err - } - } - - oldStocks, err := s.Repository.ListStocks(tx, id) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - s.Log.Errorf("Failed to list stocks before delete: %+v", err) - return err - } - - if err := s.releaseRecordingStocks(ctx, tx, oldStocks, note, actorID); err != nil { - return err - } - - if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(oldDepletions, nil, oldEggs, nil)); err != nil { - return err - } - - if err := s.logRecordingEggRollback(ctx, tx, oldEggs, note, actorID); err != nil { + if err := s.rollbackRecordingInventory(ctx, tx, id, note, actorID); err != nil { return err } @@ -781,6 +752,51 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { }) } +func (s *recordingService) rollbackRecordingInventory(ctx context.Context, tx *gorm.DB, recordingID uint, note string, actorID uint) error { + if recordingID == 0 || tx == nil { + return nil + } + + oldDepletions, err := s.Repository.ListDepletions(tx, recordingID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to list depletions: %+v", err) + return err + } + + oldEggs, err := s.Repository.ListEggs(tx, recordingID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to list eggs: %+v", err) + return err + } + if s.FifoSvc != nil { + if err := ensureRecordingEggsUnused(oldEggs); err != nil { + return err + } + if err := s.releaseRecordingDepletions(ctx, tx, oldDepletions, note, actorID); err != nil { + return err + } + } + + oldStocks, err := s.Repository.ListStocks(tx, recordingID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to list stocks: %+v", err) + return err + } + if err := s.releaseRecordingStocks(ctx, tx, oldStocks, note, actorID); err != nil { + return err + } + + if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(oldDepletions, nil, oldEggs, nil)); err != nil { + return err + } + + if err := s.logRecordingEggRollback(ctx, tx, oldEggs, note, actorID); err != nil { + return err + } + + return nil +} + // === Persistence Helpers === func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []validation.Stock, depletions []validation.Depletion, eggs []validation.Egg) error { From 71ce855feb8f48eb706d014aad3b5e8107014120 Mon Sep 17 00:00:00 2001 From: ragilap Date: Thu, 12 Feb 2026 14:11:52 +0700 Subject: [PATCH 04/18] [FEAT/BE] change current stock with pending in product warehouse --- .../services/product-stock.service.go | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/internal/modules/inventory/product-stocks/services/product-stock.service.go b/internal/modules/inventory/product-stocks/services/product-stock.service.go index 63ae97ac..c2463cdc 100644 --- a/internal/modules/inventory/product-stocks/services/product-stock.service.go +++ b/internal/modules/inventory/product-stocks/services/product-stock.service.go @@ -175,5 +175,47 @@ func (s productStockService) GetOne(c *fiber.Ctx, id uint) (*entity.Product, err s.Log.Errorf("Failed get product by id: %+v", err) return nil, err } + + if len(product.ProductWarehouses) > 0 { + ids := make([]uint, 0, len(product.ProductWarehouses)) + for _, pw := range product.ProductWarehouses { + if pw.Id != 0 { + ids = append(ids, pw.Id) + } + } + + if len(ids) > 0 { + type pendingUsageRow struct { + ProductWarehouseId uint + PendingQty float64 + } + + var rows []pendingUsageRow + if err := s.ProductRepository.DB().WithContext(c.Context()). + Table("recording_stocks"). + Select("product_warehouse_id, COALESCE(SUM(pending_qty), 0) AS pending_qty"). + Where("pending_qty > 0"). + Where("product_warehouse_id IN ?", ids). + Group("product_warehouse_id"). + Scan(&rows).Error; err != nil { + s.Log.Errorf("Failed to load pending usage for product warehouses: %+v", err) + return nil, err + } + + if len(rows) > 0 { + pendingMap := make(map[uint]float64, len(rows)) + for _, row := range rows { + pendingMap[row.ProductWarehouseId] = row.PendingQty + } + + for i := range product.ProductWarehouses { + pw := &product.ProductWarehouses[i] + if pending, ok := pendingMap[pw.Id]; ok && pending != 0 { + pw.Quantity -= pending + } + } + } + } + } return product, nil } From 7f623c0c1f000b2d497852789881861a69a82f08 Mon Sep 17 00:00:00 2001 From: "Hafizh A. Y" Date: Wed, 18 Feb 2026 11:34:38 +0700 Subject: [PATCH 05/18] fix(BE): fix report closing keuangan duplicate ovk, and closing keuangan devided by last recording --- .../repository/common.hpp.repository.go | 3 +- .../services/closingKeuangan.service.go | 32 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/internal/common/repository/common.hpp.repository.go b/internal/common/repository/common.hpp.repository.go index 2c8187fb..d1dc51f0 100644 --- a/internal/common/repository/common.hpp.repository.go +++ b/internal/common/repository/common.hpp.repository.go @@ -139,12 +139,11 @@ func (r *HppRepositoryImpl) GetOvkUsageCost(ctx context.Context, projectFlockKan Select("COALESCE(SUM(rs.usage_qty * COALESCE(pi.price, 0)), 0)"). Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id"). Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id"). - Joins("JOIN flags AS f ON f.flagable_id = pw.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct). Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String()). Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). Where("r.record_datetime <= ?", *date). - Where("f.name IN ?", flags). + Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flags). Scan(&total).Error if err != nil { return 0, err diff --git a/internal/modules/closings/services/closingKeuangan.service.go b/internal/modules/closings/services/closingKeuangan.service.go index 804ca023..757d553c 100644 --- a/internal/modules/closings/services/closingKeuangan.service.go +++ b/internal/modules/closings/services/closingKeuangan.service.go @@ -294,6 +294,9 @@ func (s closingKeuanganService) calculateProductionData(c *fiber.Ctx, projectFlo func (s closingKeuanganService) buildHPPSection(c *fiber.Ctx, projectFlock *entity.ProjectFlock, projectFlockKandangs []entity.ProjectFlockKandang, costs *CostData, production *ProductionData) dto.HPPSection { actualPopulation := production.TotalPopulationIn - production.TotalDepletion + if lastPopulation, ok := s.getLastPopulationFromRecordings(c, projectFlockKandangs); ok { + actualPopulation = lastPopulation + } totalWeightProduced := production.TotalWeightProduced totalEggWeightKg := production.TotalEggWeightKg @@ -529,6 +532,35 @@ func (s closingKeuanganService) buildProfitLossSection(projectFlock *entity.Proj return dto.ToProfitLossSection(plItems, plSummary) } +func (s closingKeuanganService) getLastPopulationFromRecordings(c *fiber.Ctx, projectFlockKandangs []entity.ProjectFlockKandang) (float64, bool) { + if s.RecordingRepo == nil || len(projectFlockKandangs) == 0 { + return 0, false + } + + total := 0.0 + recordedCount := 0 + for _, kandang := range projectFlockKandangs { + latest, err := s.RecordingRepo.GetLatestByProjectFlockKandangID(c.Context(), kandang.Id) + if err != nil { + s.Log.Errorf("Failed to fetch latest recording for project_flock_kandang_id=%d: %+v", kandang.Id, err) + return 0, false + } + if latest == nil || latest.TotalChickQty == nil { + continue + } + recordedCount++ + if *latest.TotalChickQty > 0 { + total += *latest.TotalChickQty + } + } + + if recordedCount != len(projectFlockKandangs) { + return 0, false + } + + return total, true +} + func containsFlag(flags []entity.Flag, name string) bool { for _, flag := range flags { if flag.Name == name { From 36ba4f34bbc79be6786fce5908559a98171bb7fd Mon Sep 17 00:00:00 2001 From: ragilap Date: Wed, 18 Feb 2026 11:49:25 +0700 Subject: [PATCH 06/18] [FEAT/BE] fixing fifo fallback recording,fixing backdate and fixing product category --- ...d_depletions_recording_total_used.down.sql | 9 + ...add_depletions_recording_total_used.up.sql | 17 + internal/entities/recording_depletion.go | 1 + .../controllers/closing.controller.go | 8 +- .../closings/dto/closingSapronak.dto.go | 58 +- .../repositories/closing.repository.go | 15 +- .../closings/services/sapronak.service.go | 82 +- .../chickins/services/chickin.service.go | 25 + .../projectflock_kandang.repository.go | 12 + .../services/projectflock.service.go | 57 +- .../controllers/recording.controller.go | 37 +- .../modules/production/recordings/module.go | 77 +- .../repositories/recording.repository.go | 342 +++++ .../recordings/services/recording.service.go | 1167 ++++++----------- .../services/recording_fifo.service.go | 582 ++++++-- .../validations/recording.validation.go | 9 + .../purchases/services/purchase.service.go | 9 + internal/utils/fifo/constants.go | 1 + internal/utils/recording/recording_helpers.go | 330 +++++ internal/utils/recording/util.recording.go | 87 ++ 20 files changed, 2036 insertions(+), 889 deletions(-) create mode 100644 internal/database/migrations/20260216125014_add_depletions_recording_total_used.down.sql create mode 100644 internal/database/migrations/20260216125014_add_depletions_recording_total_used.up.sql create mode 100644 internal/utils/recording/recording_helpers.go diff --git a/internal/database/migrations/20260216125014_add_depletions_recording_total_used.down.sql b/internal/database/migrations/20260216125014_add_depletions_recording_total_used.down.sql new file mode 100644 index 00000000..9677a9fe --- /dev/null +++ b/internal/database/migrations/20260216125014_add_depletions_recording_total_used.down.sql @@ -0,0 +1,9 @@ +BEGIN; + +ALTER TABLE recording_depletions + DROP CONSTRAINT IF EXISTS chk_recording_depletions_pending_zero; + +ALTER TABLE recording_depletions + DROP COLUMN IF EXISTS total_used_qty; + +COMMIT; diff --git a/internal/database/migrations/20260216125014_add_depletions_recording_total_used.up.sql b/internal/database/migrations/20260216125014_add_depletions_recording_total_used.up.sql new file mode 100644 index 00000000..59b2aa9a --- /dev/null +++ b/internal/database/migrations/20260216125014_add_depletions_recording_total_used.up.sql @@ -0,0 +1,17 @@ +BEGIN; + +ALTER TABLE recording_depletions + ADD COLUMN IF NOT EXISTS total_used_qty numeric(15, 3) NOT NULL DEFAULT 0; + +UPDATE recording_depletions +SET pending_qty = 0 +WHERE pending_qty IS NULL OR pending_qty <> 0; + +ALTER TABLE recording_depletions + ADD CONSTRAINT chk_recording_depletions_pending_zero + CHECK (pending_qty = 0); + +ALTER TABLE recording_depletions + DROP COLUMN IF EXISTS usage_qty; + +COMMIT; diff --git a/internal/entities/recording_depletion.go b/internal/entities/recording_depletion.go index 8e0c7afe..ae0b6746 100644 --- a/internal/entities/recording_depletion.go +++ b/internal/entities/recording_depletion.go @@ -6,6 +6,7 @@ type RecordingDepletion struct { ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` SourceProductWarehouseId *uint `gorm:"column:source_product_warehouse_id"` Qty float64 `gorm:"column:qty;not null"` + UsageQty float64 `gorm:"column:usage_qty"` PendingQty float64 `gorm:"column:pending_qty"` Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index b1b02886..76668160 100644 --- a/internal/modules/closings/controllers/closing.controller.go +++ b/internal/modules/closings/controllers/closing.controller.go @@ -347,12 +347,12 @@ func (u *ClosingController) GetSapronakByProject(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id") } - result, err := u.SapronakService.GetSapronakByProject(c, uint(projectID), flag) + result, productFlags, err := u.SapronakService.GetSapronakByProject(c, uint(projectID), flag) if err != nil { return err } - payload := dto.ToSapronakProjectAggregatedFromReports(result, flag) + payload := dto.ToSapronakProjectAggregatedFromReports(result, flag, productFlags) return c.Status(fiber.StatusOK). JSON(response.Success{ @@ -377,12 +377,12 @@ func (u *ClosingController) GetSapronakByKandang(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_kandang_id") } - result, err := u.SapronakService.GetSapronakByKandang(c, uint(projectID), uint(pfkID), flag) + result, productFlags, err := u.SapronakService.GetSapronakByKandang(c, uint(projectID), uint(pfkID), flag) if err != nil { return err } - payload := dto.ToSapronakProjectAggregatedFromReport(result, flag) + payload := dto.ToSapronakProjectAggregatedFromReport(result, flag, productFlags) return c.Status(fiber.StatusOK). JSON(response.Success{ diff --git a/internal/modules/closings/dto/closingSapronak.dto.go b/internal/modules/closings/dto/closingSapronak.dto.go index 30b4d945..d4cb0d0d 100644 --- a/internal/modules/closings/dto/closingSapronak.dto.go +++ b/internal/modules/closings/dto/closingSapronak.dto.go @@ -1,6 +1,7 @@ package dto import ( + "sort" "strings" "time" ) @@ -64,7 +65,7 @@ type SapronakCategoryRowDTO struct { QtyOut float64 `json:"qty_out"` QtyUsed float64 `json:"qty_used"` Description string `json:"description"` - ProductCategory string `json:"product_category"` + ProductCategory []string `json:"product_category"` UnitPrice float64 `json:"unit_price"` TotalAmount float64 `json:"total_amount"` Notes string `json:"notes"` @@ -127,7 +128,7 @@ type UomSummaryDTO struct { // === Mapper Functions for Aggregated Sapronak Response === -func ToSapronakProjectAggregatedFromReports(reports []SapronakReportDTO, flag string) SapronakProjectAggregatedDTO { +func ToSapronakProjectAggregatedFromReports(reports []SapronakReportDTO, flag string, productFlags map[uint][]string) SapronakProjectAggregatedDTO { result := SapronakProjectAggregatedDTO{} if len(reports) == 0 { @@ -135,10 +136,10 @@ func ToSapronakProjectAggregatedFromReports(reports []SapronakReportDTO, flag st } rep := reports[0] - return ToSapronakProjectAggregatedFromReport(&rep, flag) + return ToSapronakProjectAggregatedFromReport(&rep, flag, productFlags) } -func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag string) SapronakProjectAggregatedDTO { +func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag string, productFlags map[uint][]string) SapronakProjectAggregatedDTO { result := SapronakProjectAggregatedDTO{} if report == nil { @@ -175,6 +176,53 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin return t.Format("02-Jan-2006") } + flagOrder := map[string]int{ + "DOC": 0, + "PAKAN": 0, + "OVK": 0, + "PULLET": 0, + } + + buildFlagList := func(productID uint, fallback string) []string { + rawFlags := productFlags[productID] + if len(rawFlags) == 0 { + if fallback == "" { + return []string{} + } + return []string{fallback} + } + seen := make(map[string]struct{}, len(rawFlags)) + ordered := make([]string, 0, len(rawFlags)) + for _, f := range rawFlags { + flagName := strings.ToUpper(strings.TrimSpace(f)) + if flagName == "" { + continue + } + if _, ok := seen[flagName]; ok { + continue + } + seen[flagName] = struct{}{} + ordered = append(ordered, flagName) + } + sort.SliceStable(ordered, func(i, j int) bool { + li := ordered[i] + lj := ordered[j] + ri, iok := flagOrder[li] + rj, jok := flagOrder[lj] + if iok != jok { + if iok { + return true + } + return false + } + if iok && jok && ri != rj { + return ri < rj + } + return li < lj + }) + return ordered + } + for _, group := range report.Groups { flagKey := normalizeFlag(group.Flag) ptr := byFlag[flagKey] @@ -206,7 +254,7 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin Date: formatDate(item.Tanggal), ReferenceNumber: item.NoReferensi, Description: item.ProductName, - ProductCategory: item.ProductName, + ProductCategory: buildFlagList(item.ProductID, flagKey), UnitPrice: item.Harga, Notes: "-", } diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index 5fd6b7e9..ecd96b0a 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -991,8 +991,8 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C query := r.withCtx(ctx). Table("stock_allocations AS sa"). Select(` - pw.product_id AS product_id, - p.name AS product_name, + p_resolve.id AS product_id, + p_resolve.name AS product_name, f.name AS flag, COALESCE( pi.received_date, @@ -1013,10 +1013,9 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C ) AS reference, 0 AS qty_in, COALESCE(SUM(sa.qty), 0) AS qty_out, - COALESCE(pi.price, p.product_price, 0) AS price + COALESCE(pi.price, p_resolve.product_price, 0) AS price `). Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id"). - Joins("JOIN products p ON p.id = pw.product_id"). Joins("LEFT JOIN recording_stocks rs ON rs.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyRecordingStock.String()). Joins("LEFT JOIN recordings r ON r.id = rs.recording_id"). Joins("LEFT JOIN project_chickins pc_used ON pc_used.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyProjectChickin.String()). @@ -1026,9 +1025,11 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C Joins("LEFT JOIN stock_transfers st ON st.id = std.stock_transfer_id"). Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()). Joins("LEFT JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id"). + Joins("LEFT JOIN product_warehouses pw_ltt ON pw_ltt.id = ltt.product_warehouse_id"). Joins("LEFT JOIN adjustment_stocks ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyAdjustmentIn.String()). Joins("LEFT JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()). Joins("LEFT JOIN project_chickins pc ON pc.id = pfp.project_chickin_id"). + Joins("LEFT JOIN products p_resolve ON p_resolve.id = COALESCE(pi.product_id, pw_ltt.product_id, pw.product_id)"). Where("sa.status = ?", entity.StockAllocationStatusActive). Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()). Where("f.name IN ?", sapronakFlagsAll). @@ -1040,12 +1041,12 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C fifo.UsableKeyRecordingStock.String(), projectFlockKandangID, fifo.UsableKeyProjectChickin.String(), projectFlockKandangID, ) - query = r.joinSapronakProductFlag(query, "p"). + query = r.joinSapronakProductFlag(query, "p_resolve"). Group(` - pw.product_id, p.name, f.name, + p_resolve.id, p_resolve.name, f.name, pi.received_date, st.transfer_date, lt.transfer_date, ast.created_at, pc.chick_in_date, r.record_datetime, po.po_number, st.movement_number, lt.transfer_number, ast.id, pc.id, r.id, - pi.price, p.product_price + pi.price, p_resolve.product_price `) return scanAndGroupDetails(query) diff --git a/internal/modules/closings/services/sapronak.service.go b/internal/modules/closings/services/sapronak.service.go index 4dff148b..ba79db1d 100644 --- a/internal/modules/closings/services/sapronak.service.go +++ b/internal/modules/closings/services/sapronak.service.go @@ -18,8 +18,8 @@ import ( ) type SapronakService interface { - GetSapronakByProject(ctx *fiber.Ctx, projectFlockID uint, flag string) ([]dto.SapronakReportDTO, error) - GetSapronakByKandang(ctx *fiber.Ctx, projectFlockID uint, pfkID uint, flag string) (*dto.SapronakReportDTO, error) + GetSapronakByProject(ctx *fiber.Ctx, projectFlockID uint, flag string) ([]dto.SapronakReportDTO, map[uint][]string, error) + GetSapronakByKandang(ctx *fiber.Ctx, projectFlockID uint, pfkID uint, flag string) (*dto.SapronakReportDTO, map[uint][]string, error) } type sapronakService struct { @@ -42,9 +42,9 @@ func NewSapronakService( } } -func (s sapronakService) GetSapronakByProject(c *fiber.Ctx, projectFlockID uint, flag string) ([]dto.SapronakReportDTO, error) { +func (s sapronakService) GetSapronakByProject(c *fiber.Ctx, projectFlockID uint, flag string) ([]dto.SapronakReportDTO, map[uint][]string, error) { if projectFlockID == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_id is required") + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_id is required") } reports, err := s.computeSapronakReports(c.Context(), &validation.CountSapronakQuery{ ProjectFlockID: projectFlockID, @@ -52,19 +52,27 @@ func (s sapronakService) GetSapronakByProject(c *fiber.Ctx, projectFlockID uint, Flag: flag, }) if err != nil { - return nil, err + return nil, nil, err } if len(reports) <= 1 { - return reports, nil + flags, err := s.collectProductFlags(c.Context(), reports) + if err != nil { + return nil, nil, err + } + return reports, flags, nil } combined := s.combineSapronakReports(reports, projectFlockID) - return []dto.SapronakReportDTO{combined}, nil + flags, err := s.collectProductFlags(c.Context(), []dto.SapronakReportDTO{combined}) + if err != nil { + return nil, nil, err + } + return []dto.SapronakReportDTO{combined}, flags, nil } -func (s sapronakService) GetSapronakByKandang(c *fiber.Ctx, projectFlockID uint, pfkID uint, flag string) (*dto.SapronakReportDTO, error) { +func (s sapronakService) GetSapronakByKandang(c *fiber.Ctx, projectFlockID uint, pfkID uint, flag string) (*dto.SapronakReportDTO, map[uint][]string, error) { if projectFlockID == 0 || pfkID == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_id and project_flock_kandang_id are required") + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_id and project_flock_kandang_id are required") } results, err := s.computeSapronakReports(c.Context(), &validation.CountSapronakQuery{ @@ -74,16 +82,20 @@ func (s sapronakService) GetSapronakByKandang(c *fiber.Ctx, projectFlockID uint, Flag: flag, }) if err != nil { - return nil, err + return nil, nil, err } for _, res := range results { if res.ProjectFlockID == projectFlockID && res.ProjectFlockKandangID == pfkID { - return &res, nil + flags, err := s.collectProductFlags(c.Context(), []dto.SapronakReportDTO{res}) + if err != nil { + return nil, nil, err + } + return &res, flags, nil } } - return nil, fiber.NewError(fiber.StatusNotFound, "Sapronak for kandang not found") + return nil, nil, fiber.NewError(fiber.StatusNotFound, "Sapronak for kandang not found") } func (s sapronakService) computeSapronakReports(ctx context.Context, params *validation.CountSapronakQuery) ([]dto.SapronakReportDTO, error) { @@ -136,6 +148,52 @@ func (s sapronakService) computeSapronakReports(ctx context.Context, params *val return results, nil } +func (s sapronakService) collectProductFlags(ctx context.Context, reports []dto.SapronakReportDTO) (map[uint][]string, error) { + productIDs := make(map[uint]struct{}) + for _, report := range reports { + for _, group := range report.Groups { + for _, item := range group.Items { + if item.ProductID > 0 { + productIDs[item.ProductID] = struct{}{} + } + } + } + } + if len(productIDs) == 0 { + return map[uint][]string{}, nil + } + + ids := make([]uint, 0, len(productIDs)) + for id := range productIDs { + ids = append(ids, id) + } + + products, err := s.Repository.GetProductsWithFlagsByIDs(ctx, ids) + if err != nil { + return nil, err + } + + result := make(map[uint][]string, len(products)) + for _, product := range products { + if len(product.Flags) == 0 { + continue + } + flags := make([]string, 0, len(product.Flags)) + for _, flag := range product.Flags { + name := strings.TrimSpace(flag.Name) + if name == "" { + continue + } + flags = append(flags, strings.ToUpper(name)) + } + if len(flags) > 0 { + result[product.Id] = flags + } + } + + return result, nil +} + func (s sapronakService) loadProjectFlockKandangs(ctx context.Context, params *validation.CountSapronakQuery) ([]entity.ProjectFlockKandang, error) { db := s.ProjectFlockKandangRepo.DB().WithContext(ctx). Preload("ProjectFlock"). diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index a011c579..7d2e7a7f 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -36,6 +36,7 @@ type ChickinService interface { UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.ProjectChickin, error) DeleteOne(ctx *fiber.Ctx, id uint) error Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectChickin, error) + EnsureChickInExists(ctx context.Context, projectFlockKandangID uint) error } type chickinService struct { @@ -731,6 +732,30 @@ func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, return nil } +func (s chickinService) EnsureChickInExists(ctx context.Context, projectFlockKandangID uint) error { + if projectFlockKandangID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid") + } + + populations, err := s.ProjectflockPopulationRepo.GetByProjectFlockKandangID(ctx, projectFlockKandangID) + if err != nil { + s.Log.Errorf("Failed to check project flock population for project_flock_kandang_id=%d: %+v", projectFlockKandangID, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal memeriksa data chick in") + } + + if len(populations) == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Project flock belum memiliki chick in yang disetujui sehingga belum dapat membuat recording") + } + + for _, population := range populations { + if population.TotalQty > 0 { + return nil + } + } + + return fiber.NewError(fiber.StatusBadRequest, "Chick in project flock belum disetujui sehingga belum dapat membuat recording") +} + func (s *chickinService) adjustProductWarehouseQuantities(ctx context.Context, tx *gorm.DB, deltas map[uint]float64) error { if len(deltas) == 0 { return nil diff --git a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go index 002c2c58..4383ee4a 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go @@ -13,6 +13,7 @@ import ( type ProjectFlockKandangRepository interface { GetByID(ctx context.Context, id uint) (*entity.ProjectFlockKandang, error) + GetByIDLight(ctx context.Context, id uint) (*entity.ProjectFlockKandang, error) GetByProjectFlockAndKandang(ctx context.Context, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, error) GetActiveByKandangID(ctx context.Context, kandangID uint) (*entity.ProjectFlockKandang, error) UpdateClosedAt(ctx context.Context, id uint, t *time.Time) error @@ -342,6 +343,17 @@ func (r *projectFlockKandangRepositoryImpl) GetByID(ctx context.Context, id uint return record, nil } +// GetByIDLight loads only the minimal relations needed for recording flows. +func (r *projectFlockKandangRepositoryImpl) GetByIDLight(ctx context.Context, id uint) (*entity.ProjectFlockKandang, error) { + record := new(entity.ProjectFlockKandang) + if err := r.db.WithContext(ctx). + Preload("ProjectFlock"). + First(record, id).Error; err != nil { + return nil, err + } + return record, nil +} + func (r *projectFlockKandangRepositoryImpl) GetByProjectFlockAndKandang(ctx context.Context, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, error) { record := new(entity.ProjectFlockKandang) if err := r.db.WithContext(ctx). diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index 224f43bf..4caf7540 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -48,6 +48,7 @@ type ProjectflockService interface { GetProjectPeriods(ctx *fiber.Ctx, projectIDs []uint) (map[uint]int, error) Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlock, error) Resubmit(ctx *fiber.Ctx, req *validation.Resubmit, id uint) (*entity.ProjectFlock, error) + EnsureProjectFlockApproved(ctx context.Context, projectFlockID uint) error } type projectflockService struct { @@ -112,6 +113,32 @@ func (s projectflockService) approvalQueryModifier() func(*gorm.DB) *gorm.DB { } } +func (s projectflockService) EnsureProjectFlockApproved(ctx context.Context, projectFlockID uint) error { + if projectFlockID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid") + } + + approvalSvc := s.ApprovalSvc + if approvalSvc == nil { + approvalSvc = commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(s.Repository.DB())) + } + + latest, err := approvalSvc.LatestByTarget(ctx, s.approvalWorkflow, projectFlockID, nil) + if err != nil { + s.Log.Errorf("Failed to check project flock %d approval status: %+v", projectFlockID, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal memeriksa status project flock") + } + + if latest == nil { + return fiber.NewError(fiber.StatusBadRequest, "Project flock masih dalam status pengajuan sehingga belum dapat membuat recording") + } + if latest.StepNumber != uint16(utils.ProjectFlockStepAktif) || latest.Action == nil || *latest.Action != entity.ApprovalActionApproved { + return fiber.NewError(fiber.StatusBadRequest, "Project flock masih dalam status pengajuan sehingga belum dapat membuat recording") + } + + return nil +} + func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, map[uint]*flockDTO.FlockRelationDTO, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, nil, err @@ -459,6 +486,15 @@ func (s projectflockService) GetProjectFlockKandangPopulation(ctx *fiber.Ctx, pr return 0, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required") } + total, err := s.PopulationRepo.GetAvailableQtyByProjectFlockKandangID(ctx.Context(), projectFlockKandangID) + if err != nil { + s.Log.Errorf("Failed to fetch project flock kandang population %d: %+v", projectFlockKandangID, err) + return 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang population") + } + if total > 0 { + return total, nil + } + if s.RecordingRepo != nil { latest, err := s.RecordingRepo.GetLatestByProjectFlockKandangID(ctx.Context(), projectFlockKandangID) if err != nil { @@ -470,12 +506,6 @@ func (s projectflockService) GetProjectFlockKandangPopulation(ctx *fiber.Ctx, pr } } - total, err := s.PopulationRepo.GetAvailableQtyByProjectFlockKandangID(ctx.Context(), projectFlockKandangID) - if err != nil { - s.Log.Errorf("Failed to fetch project flock kandang population %d: %+v", projectFlockKandangID, err) - return 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang population") - } - return total, nil } @@ -550,21 +580,22 @@ func (s projectflockService) GetProjectFlockKandangByParams(ctx *fiber.Ctx, idSt } func (s projectflockService) GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID uint) (float64, error) { + if s.PopulationRepo == nil { + return 0, fiber.NewError(fiber.StatusInternalServerError, "Project flock population repository is not configured") + } - wh, err := s.WarehouseRepo.GetByKandangID(ctx.Context(), kandangID) + pfk, err := s.PivotRepo.GetActiveByKandangID(ctx.Context(), kandangID) if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found") + } return 0, err } - productWarehouses, err := s.ProductWarehouseRepo.GetByCategoryCodeAndWarehouseID(ctx.Context(), "DOC", wh.Id) + total, err := s.PopulationRepo.GetAvailableQtyByProjectFlockKandangID(ctx.Context(), pfk.Id) if err != nil { return 0, err } - - total := 0.0 - for _, pw := range productWarehouses { - total += pw.Quantity - } return total, nil } diff --git a/internal/modules/production/recordings/controllers/recording.controller.go b/internal/modules/production/recordings/controllers/recording.controller.go index 51e3100d..7801b16f 100644 --- a/internal/modules/production/recordings/controllers/recording.controller.go +++ b/internal/modules/production/recordings/controllers/recording.controller.go @@ -27,9 +27,14 @@ func NewRecordingController(recordingService service.RecordingService) *Recordin func (u *RecordingController) GetAll(c *fiber.Ctx) error { projectFlockID := c.QueryInt("project_flock_kandang_id", 0) + page := c.QueryInt("page", 1) + limit := c.QueryInt("limit", 10) + offset := (page - 1) * limit + query := &validation.Query{ - Page: c.QueryInt("page", 1), - Limit: c.QueryInt("limit", 10), + Page: page, + Limit: limit, + Offset: offset, Search: c.Query("search"), } if projectFlockID > 0 { @@ -79,25 +84,27 @@ func (u *RecordingController) GetOne(c *fiber.Ctx) error { } func (u *RecordingController) GetNextDay(c *fiber.Ctx) error { - projectFlockID := c.QueryInt("project_flock_kandang_id", 0) - if projectFlockID <= 0 { + req := new(validation.GetRecordingNextDay) + + if err := c.QueryParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid query params") + } + if req.ProjectFlockKandangId == 0 { return fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required") } - - recordTime := time.Now().UTC() - if recordDate := strings.TrimSpace(c.Query("record_date")); recordDate != "" { - parsed, err := time.Parse("2006-01-02", recordDate) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, "record_date must be in YYYY-MM-DD format") - } - recordTime = parsed.UTC() + if req.RecordTime == nil || strings.TrimSpace(*req.RecordTime) == "" { + return fiber.NewError(fiber.StatusBadRequest, "record_date is required") } - - nextDay, err := u.RecordingService.GetNextDay(c, uint(projectFlockID), recordTime) + recordTime, err := time.Parse("2006-01-02", strings.TrimSpace(*req.RecordTime)) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "record_time must be in YYYY-MM-DD format") + } + req.RecordTimeValue = &recordTime + nextDay, err := u.RecordingService.GetNextDay(c, req) if err != nil { return err } - + projectFlockID := req.ProjectFlockKandangId return c.Status(fiber.StatusOK). JSON(response.Success{ Code: fiber.StatusOK, diff --git a/internal/modules/production/recordings/module.go b/internal/modules/production/recordings/module.go index 23c97788..6dd74a1b 100644 --- a/internal/modules/production/recordings/module.go +++ b/internal/modules/production/recordings/module.go @@ -11,9 +11,17 @@ import ( commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + rFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/repositories" + rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" + rNonstock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/repositories" rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" sProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/services" + rProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" + rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + rChickin "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" + sChickin "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/services" rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + sProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" sRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services" rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" @@ -28,9 +36,18 @@ type RecordingModule struct{} func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { recordingRepo := rRecording.NewRecordingRepository(db) + projectFlockRepo := rProjectFlock.NewProjectflockRepository(db) projectFlockKandangRepo := rProjectFlock.NewProjectFlockKandangRepository(db) projectFlockPopulationRepo := rProjectFlock.NewProjectFlockPopulationRepository(db) + projectBudgetRepo := rProjectFlock.NewProjectBudgetRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + flockRepo := rFlock.NewFlockRepository(db) + kandangRepo := rKandang.NewKandangRepository(db) + warehouseRepo := rWarehouse.NewWarehouseRepository(db) + nonstockRepo := rNonstock.NewNonstockRepository(db) + productRepo := rProduct.NewProductRepository(db) + chickinRepo := rChickin.NewChickinRepository(db) + chickinDetailRepo := rChickin.NewChickinDetailRepository(db) stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) stockLogRepo := rStockLogs.NewStockLogRepository(db) productionStandardRepo := rProductionStandard.NewProductionStandardRepository(db) @@ -53,14 +70,30 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate ProductWarehouseID: "product_warehouse_id", TotalQuantity: "total_qty", TotalUsedQuantity: "total_used", - CreatedAt: "created_at", + CreatedAt: "(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_eggs.recording_id)", }, - OrderBy: []string{"created_at ASC", "id ASC"}, + OrderBy: []string{"(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_eggs.recording_id) ASC", "id ASC"}, }); err != nil { if !strings.Contains(strings.ToLower(err.Error()), "already registered") { panic(fmt.Sprintf("failed to register recording egg stockable workflow: %v", err)) } } + if err := fifoService.RegisterStockable(fifo.StockableConfig{ + Key: fifo.StockableKeyRecordingDepletion, + Table: "recording_depletions", + Columns: fifo.StockableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + TotalQuantity: "qty", + TotalUsedQuantity: "total_used_qty", + CreatedAt: "(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_depletions.recording_id)", + }, + OrderBy: []string{"(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_depletions.recording_id) ASC", "id ASC"}, + }); err != nil { + if !strings.Contains(strings.ToLower(err.Error()), "already registered") { + panic(fmt.Sprintf("failed to register recording depletion stockable workflow: %v", err)) + } + } if err := fifoService.RegisterUsable(fifo.UsableConfig{ Key: fifo.UsableKeyRecordingStock, Table: "recording_stocks", @@ -69,7 +102,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate ProductWarehouseID: "product_warehouse_id", UsageQuantity: "usage_qty", PendingQuantity: "pending_qty", - CreatedAt: "id", + CreatedAt: "(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_stocks.recording_id)", }, }); err != nil { if !strings.Contains(strings.ToLower(err.Error()), "already registered") { @@ -82,9 +115,9 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate Columns: fifo.UsableColumns{ ID: "id", ProductWarehouseID: "source_product_warehouse_id", - UsageQuantity: "qty", + UsageQuantity: "usage_qty", PendingQuantity: "pending_qty", - CreatedAt: "id", + CreatedAt: "(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_depletions.recording_id)", }, ExcludedStockables: []fifo.StockableKey{ fifo.StockableKeyTransferToLayingIn, @@ -104,9 +137,41 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowRecording, utils.RecordingApprovalSteps); err != nil { panic(fmt.Sprintf("failed to register recording approval workflow: %v", err)) } + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowProjectFlock, utils.ProjectFlockApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register project flock approval workflow: %v", err)) + } userRepo := rUser.NewUserRepository(db) + projectFlockService := sProjectFlock.NewProjectflockService( + projectFlockRepo, + flockRepo, + kandangRepo, + projectFlockKandangRepo, + warehouseRepo, + productWarehouseRepo, + projectBudgetRepo, + nonstockRepo, + projectFlockPopulationRepo, + recordingRepo, + approvalService, + validate, + ) + + chickinService := sChickin.NewChickinService( + chickinRepo, + kandangRepo, + warehouseRepo, + productWarehouseRepo, + productRepo, + projectFlockRepo, + projectFlockKandangRepo, + projectFlockPopulationRepo, + chickinDetailRepo, + validate, + fifoService, + ) + recordingService := sRecording.NewRecordingService( recordingRepo, projectFlockKandangRepo, @@ -117,6 +182,8 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate fifoService, stockLogRepo, productionStandardService, + projectFlockService, + chickinService, validate, ) userService := sUser.NewUserService(userRepo, validate) diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index 3ed66f87..a7978c16 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -17,8 +17,13 @@ type RecordingRepository interface { repository.BaseRepository[entity.Recording] WithRelations(db *gorm.DB) *gorm.DB + WithRelationsList(db *gorm.DB) *gorm.DB + ApplyListFilters(db *gorm.DB, search string, projectFlockKandangId uint) *gorm.DB + ApplyListCountFilters(db *gorm.DB, search string, projectFlockKandangId uint) *gorm.DB ApplySearchFilters(db *gorm.DB, rawSearch string) *gorm.DB + GetAllWithFilters(ctx context.Context, offset, limit int, search string, projectFlockKandangId uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.Recording, int64, error) GetLatestByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (*entity.Recording, error) + ListByProjectFlockKandangID(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, from *time.Time) ([]entity.Recording, error) GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error) CreateStocks(tx *gorm.DB, stocks []entity.RecordingStock) error @@ -40,9 +45,13 @@ type RecordingRepository interface { SumRecordingDepletions(tx *gorm.DB, recordingID uint) (float64, error) GetCumulativeDepletionByProjectFlockKandangUntil(tx *gorm.DB, projectFlockKandangId uint, recordTime time.Time) (float64, error) + GetCumulativeDepletionByRecordingIDs(tx *gorm.DB, recordingIDs []uint) (map[uint]float64, error) GetUniformityMeanBwByWeek(tx *gorm.DB, projectFlockKandangId uint, week int) (float64, bool, error) FindPreviousRecording(tx *gorm.DB, projectFlockKandangId uint, currentDay int) (*entity.Recording, error) + GetPreviousTotalChickByRecordingIDs(tx *gorm.DB, recordingIDs []uint) (map[uint]*float64, error) GetTotalChick(tx *gorm.DB, projectFlockKandangId uint) (int64, error) + GetRemainingPopulationByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint) (float64, error) + GetTotalChickByProjectFlockKandangIDs(tx *gorm.DB, projectFlockKandangIds []uint) (map[uint]int64, error) GetTotalChickinByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint) (float64, error) GetFeedUsageInGrams(tx *gorm.DB, recordingID uint) (float64, error) GetEggSummaryByRecording(tx *gorm.DB, recordingID uint) (totalQty float64, totalWeightGrams float64, err error) @@ -55,6 +64,10 @@ type RecordingRepository interface { GetLatestAvgWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (avgWeight float64, err error) GetTotalEggProductionWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeightKg float64, err error) GetAverageTargetMetricsByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint, includeTargets bool) (RecordingTargetAverages, error) + ResyncProjectFlockPopulationUsage(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error + ValidateFeedProductWarehouses(ctx context.Context, ids []uint) (uint, error) + ValidateEggProductWarehouses(ctx context.Context, ids []uint) (uint, error) + ValidateDepletionProductWarehouses(ctx context.Context, ids []uint) (uint, error) } type RecordingRepositoryImpl struct { @@ -113,6 +126,64 @@ func (r *RecordingRepositoryImpl) WithRelations(db *gorm.DB) *gorm.DB { Preload("Eggs.ProductWarehouse.Warehouse.Location") } +func (r *RecordingRepositoryImpl) WithRelationsList(db *gorm.DB) *gorm.DB { + return db. + Preload("CreatedUser"). + Preload("ProjectFlockKandang"). + Preload("ProjectFlockKandang.Kandang"). + Preload("ProjectFlockKandang.Kandang.Location"). + Preload("ProjectFlockKandang.ProjectFlock"). + Preload("ProjectFlockKandang.ProjectFlock.ProductionStandard") +} + +func (r *RecordingRepositoryImpl) ApplyListFilters(db *gorm.DB, search string, projectFlockKandangId uint) *gorm.DB { + db = r.WithRelationsList(db) + db = db. + Joins("JOIN project_flock_kandangs pfk ON pfk.id = recordings.project_flock_kandangs_id"). + Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id") + if projectFlockKandangId != 0 { + db = db.Where("recordings.project_flock_kandangs_id = ?", projectFlockKandangId) + } + db = r.ApplySearchFilters(db, search) + return db.Order("recordings.record_datetime DESC").Order("recordings.created_at DESC") +} + +func (r *RecordingRepositoryImpl) ApplyListCountFilters(db *gorm.DB, search string, projectFlockKandangId uint) *gorm.DB { + db = db. + Joins("JOIN project_flock_kandangs pfk ON pfk.id = recordings.project_flock_kandangs_id"). + Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id") + if projectFlockKandangId != 0 { + db = db.Where("recordings.project_flock_kandangs_id = ?", projectFlockKandangId) + } + db = r.ApplySearchFilters(db, search) + return db +} + +func (r *RecordingRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, search string, projectFlockKandangId uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.Recording, int64, error) { + var ( + records []entity.Recording + total int64 + ) + + countQ := r.ApplyListCountFilters(r.DB().WithContext(ctx).Model(&entity.Recording{}), search, projectFlockKandangId) + if modifier != nil { + countQ = modifier(countQ) + } + if err := countQ.Count(&total).Error; err != nil { + return nil, 0, err + } + + listQ := r.ApplyListFilters(r.DB().WithContext(ctx).Model(&entity.Recording{}), search, projectFlockKandangId) + if modifier != nil { + listQ = modifier(listQ) + } + if err := listQ.Offset(offset).Limit(limit).Find(&records).Error; err != nil { + return nil, 0, err + } + + return records, total, nil +} + func (r *RecordingRepositoryImpl) ApplySearchFilters(db *gorm.DB, rawSearch string) *gorm.DB { normalized := strings.ToLower(strings.TrimSpace(rawSearch)) if normalized == "" { @@ -170,6 +241,27 @@ func (r *RecordingRepositoryImpl) GetLatestByProjectFlockKandangID(ctx context.C return &record, nil } +func (r *RecordingRepositoryImpl) ListByProjectFlockKandangID(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, from *time.Time) ([]entity.Recording, error) { + if projectFlockKandangId == 0 { + return nil, errors.New("project_flock_kandang_id is required") + } + + db := tx.WithContext(ctx). + Model(&entity.Recording{}). + Where("project_flock_kandangs_id = ?", projectFlockKandangId). + Where("deleted_at IS NULL") + + if from != nil { + db = db.Where("record_datetime >= ?", *from) + } + + var records []entity.Recording + if err := db.Order("record_datetime ASC").Order("created_at ASC").Find(&records).Error; err != nil { + return nil, err + } + return records, nil +} + func (r *RecordingRepositoryImpl) GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error) { var days []int if err := tx.Model(&entity.Recording{}). @@ -332,6 +424,41 @@ func (r *RecordingRepositoryImpl) GetCumulativeDepletionByProjectFlockKandangUnt return total, err } +func (r *RecordingRepositoryImpl) GetCumulativeDepletionByRecordingIDs(tx *gorm.DB, recordingIDs []uint) (map[uint]float64, error) { + result := make(map[uint]float64) + if len(recordingIDs) == 0 { + return result, nil + } + + type row struct { + RecordingID uint `gorm:"column:recording_id"` + Total float64 `gorm:"column:total_qty"` + } + var rows []row + + err := tx. + Table("recordings r"). + Select("r.id AS recording_id, COALESCE(SUM(rd.qty), 0) AS total_qty"). + Joins(` + LEFT JOIN recordings r2 + ON r2.project_flock_kandangs_id = r.project_flock_kandangs_id + AND r2.record_datetime <= r.record_datetime + AND r2.deleted_at IS NULL`). + Joins("LEFT JOIN recording_depletions rd ON rd.recording_id = r2.id"). + Where("r.id IN ?", recordingIDs). + Where("r.deleted_at IS NULL"). + Group("r.id"). + Scan(&rows).Error + if err != nil { + return nil, err + } + + for _, row := range rows { + result[row.RecordingID] = row.Total + } + return result, nil +} + func (r *RecordingRepositoryImpl) GetUniformityMeanBwByWeek(tx *gorm.DB, projectFlockKandangId uint, week int) (float64, bool, error) { if projectFlockKandangId == 0 || week <= 0 { return 0, false, nil @@ -381,6 +508,46 @@ func (r *RecordingRepositoryImpl) FindPreviousRecording(tx *gorm.DB, projectFloc return &prev, nil } +func (r *RecordingRepositoryImpl) GetPreviousTotalChickByRecordingIDs(tx *gorm.DB, recordingIDs []uint) (map[uint]*float64, error) { + result := make(map[uint]*float64) + if len(recordingIDs) == 0 { + return result, nil + } + + type row struct { + RecordingID uint `gorm:"column:recording_id"` + PrevTotalChickQty *float64 `gorm:"column:prev_total_chick_qty"` + } + var rows []row + + err := tx. + Table("recordings r"). + Select(` + r.id AS recording_id, + ( + SELECT r2.total_chick_qty + FROM recordings r2 + WHERE r2.project_flock_kandangs_id = r.project_flock_kandangs_id + AND r2.day IS NOT NULL + AND r.day IS NOT NULL + AND r2.day < r.day + AND r2.deleted_at IS NULL + ORDER BY r2.day DESC + LIMIT 1 + ) AS prev_total_chick_qty`). + Where("r.id IN ?", recordingIDs). + Where("r.deleted_at IS NULL"). + Scan(&rows).Error + if err != nil { + return nil, err + } + + for _, row := range rows { + result[row.RecordingID] = row.PrevTotalChickQty + } + return result, nil +} + func (r *RecordingRepositoryImpl) GetTotalChick(tx *gorm.DB, projectFlockKandangId uint) (int64, error) { var total float64 err := tx. @@ -400,6 +567,57 @@ func (r *RecordingRepositoryImpl) GetTotalChick(tx *gorm.DB, projectFlockKandang return int64(math.Round(total)), nil } +func (r *RecordingRepositoryImpl) GetRemainingPopulationByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint) (float64, error) { + var total float64 + err := tx. + Table("project_flock_populations"). + Select("COALESCE(SUM(project_flock_populations.total_qty - project_flock_populations.total_used_qty), 0) AS total_qty"). + Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id"). + Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangId). + Scan(&total).Error + if err != nil { + return 0, err + } + if total < 0 { + total = 0 + } + return total, nil +} + +func (r *RecordingRepositoryImpl) GetTotalChickByProjectFlockKandangIDs(tx *gorm.DB, projectFlockKandangIds []uint) (map[uint]int64, error) { + result := make(map[uint]int64) + if len(projectFlockKandangIds) == 0 { + return result, nil + } + + type row struct { + ProjectFlockKandangId uint `gorm:"column:project_flock_kandang_id"` + Total float64 `gorm:"column:total_qty"` + } + var rows []row + + err := tx. + Table("project_flock_populations pfp"). + Select("project_chickins.project_flock_kandang_id, COALESCE(SUM(pfp.total_qty - pfp.total_used_qty), 0) AS total_qty"). + Joins("JOIN project_chickins ON project_chickins.id = pfp.project_chickin_id"). + Where("project_chickins.project_flock_kandang_id IN ?", projectFlockKandangIds). + Group("project_chickins.project_flock_kandang_id"). + Scan(&rows).Error + if err != nil { + return nil, err + } + + for _, row := range rows { + total := math.Round(row.Total) + if total < 0 { + total = 0 + } + result[row.ProjectFlockKandangId] = int64(total) + } + + return result, nil +} + func (r *RecordingRepositoryImpl) GetTotalChickinByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint) (float64, error) { if projectFlockKandangId == 0 { return 0, nil @@ -585,6 +803,130 @@ func (r *RecordingRepositoryImpl) GetAverageTargetMetricsByProjectFlockKandangID return result, nil } +func (r *RecordingRepositoryImpl) ResyncProjectFlockPopulationUsage(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error { + if projectFlockKandangID == 0 { + return nil + } + + idsSubquery := ` + SELECT pfp.id + FROM project_flock_populations pfp + JOIN project_chickins pc ON pc.id = pfp.project_chickin_id + WHERE pc.project_flock_kandang_id = ? + ` + + updateWithAlloc := ` + UPDATE project_flock_populations p + SET total_used_qty = COALESCE(a.used, 0) + FROM ( + SELECT stockable_id, SUM(qty) AS used + FROM stock_allocations + WHERE stockable_type = 'PROJECT_FLOCK_POPULATION' + AND status = 'ACTIVE' + GROUP BY stockable_id + ) a + WHERE p.id = a.stockable_id + AND p.id IN (` + idsSubquery + `) + ` + + resetMissing := ` + UPDATE project_flock_populations p + SET total_used_qty = 0 + WHERE p.id IN (` + idsSubquery + `) + AND NOT EXISTS ( + SELECT 1 + FROM stock_allocations sa + WHERE sa.stockable_type = 'PROJECT_FLOCK_POPULATION' + AND sa.status = 'ACTIVE' + AND sa.stockable_id = p.id + ) + ` + + db := r.DB().WithContext(ctx) + if tx != nil { + db = tx.WithContext(ctx) + } + + if err := db.Exec(updateWithAlloc, projectFlockKandangID).Error; err != nil { + return err + } + if err := db.Exec(resetMissing, projectFlockKandangID).Error; err != nil { + return err + } + return nil +} + +func (r *RecordingRepositoryImpl) ValidateFeedProductWarehouses(ctx context.Context, ids []uint) (uint, error) { + if len(ids) == 0 { + return 0, nil + } + var invalidIDs []uint + if err := r.DB().WithContext(ctx). + Table("product_warehouses pw"). + Where("pw.id IN ?", ids). + Where(`NOT EXISTS ( + SELECT 1 FROM flags f + WHERE f.flagable_type = 'products' + AND f.flagable_id = pw.product_id + AND UPPER(f.name) = 'PAKAN' + )`). + Pluck("pw.id", &invalidIDs).Error; err != nil { + return 0, err + } + if len(invalidIDs) > 0 { + return invalidIDs[0], nil + } + return 0, nil +} + +func (r *RecordingRepositoryImpl) ValidateEggProductWarehouses(ctx context.Context, ids []uint) (uint, error) { + if len(ids) == 0 { + return 0, nil + } + eggFlags := []string{"TELUR-UTUH", "TELUR-PECAH", "TELUR-PUTIH", "TELUR-RETAK", "TELUR"} + var invalidIDs []uint + if err := r.DB().WithContext(ctx). + Table("product_warehouses pw"). + Where("pw.id IN ?", ids). + Where(`NOT EXISTS ( + SELECT 1 FROM flags f + WHERE f.flagable_type = 'products' + AND f.flagable_id = pw.product_id + AND UPPER(f.name) IN ? + )`, eggFlags). + Pluck("pw.id", &invalidIDs).Error; err != nil { + return 0, err + } + if len(invalidIDs) > 0 { + return invalidIDs[0], nil + } + return 0, nil +} + +func (r *RecordingRepositoryImpl) ValidateDepletionProductWarehouses(ctx context.Context, ids []uint) (uint, error) { + if len(ids) == 0 { + return 0, nil + } + ayamFlags := []string{"AYAM-AFKIR", "AYAM-CULLING", "AYAM-MATI"} + var invalidIDs []uint + if err := r.DB().WithContext(ctx). + Table("product_warehouses pw"). + Where("pw.id IN ?", ids). + Where(`NOT EXISTS ( + SELECT 1 FROM flags f + WHERE f.flagable_type = 'products' + AND f.flagable_id = pw.product_id + AND UPPER(f.name) IN ? + )`, ayamFlags). + Pluck("pw.id", &invalidIDs).Error; err != nil { + return 0, err + } + if len(invalidIDs) > 0 { + return invalidIDs[0], nil + } + return 0, nil +} + func nextRecordingDay(days []int) int { if len(days) == 0 { return 1 diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index f789144c..6ef692b9 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -13,9 +13,10 @@ import ( entity "gitlab.com/mbugroup/lti-api.git/internal/entities" m "gitlab.com/mbugroup/lti-api.git/internal/middleware" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" - rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" sProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/services" + sChickin "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/services" rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + sProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" @@ -32,7 +33,7 @@ import ( type RecordingService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Recording, int64, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.Recording, error) - GetNextDay(ctx *fiber.Ctx, projectFlockKandangId uint, recordTime time.Time) (int, error) + GetNextDay(ctx *fiber.Ctx, req *validation.GetRecordingNextDay) (int, error) CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Recording, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Recording, error) DeleteOne(ctx *fiber.Ctx, id uint) error @@ -49,6 +50,8 @@ type recordingService struct { ApprovalRepo commonRepo.ApprovalRepository ApprovalSvc commonSvc.ApprovalService ProductionStandardSvc sProductionStandard.ProductionStandardService + ProjectFlockSvc sProjectFlock.ProjectflockService + ChickinSvc sChickin.ChickinService FifoSvc commonSvc.FifoService StockLogRepo rStockLogs.StockLogRepository } @@ -63,6 +66,8 @@ func NewRecordingService( fifoSvc commonSvc.FifoService, stockLogRepo rStockLogs.StockLogRepository, productionStandardSvc sProductionStandard.ProductionStandardService, + projectFlockSvc sProjectFlock.ProjectflockService, + chickinSvc sChickin.ChickinService, validate *validator.Validate, ) RecordingService { return &recordingService{ @@ -75,6 +80,8 @@ func NewRecordingService( ApprovalRepo: approvalRepo, ApprovalSvc: approvalSvc, ProductionStandardSvc: productionStandardSvc, + ProjectFlockSvc: projectFlockSvc, + ChickinSvc: chickinSvc, FifoSvc: fifoSvc, StockLogRepo: stockLogRepo, } @@ -92,28 +99,17 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti } } - limit := params.Limit - if limit == 0 { - limit = 10 - } - page := params.Page - if page == 0 { - page = 1 - } - offset := (page - 1) * limit - - recordings, total, err := s.Repository.GetAll(c.Context(), offset, limit, func(db *gorm.DB) *gorm.DB { - db = s.Repository.WithRelations(db) - db = db. - Joins("JOIN project_flock_kandangs pfk ON pfk.id = recordings.project_flock_kandangs_id"). - Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id") - db, scopeErr = m.ApplyLocationScope(c, db, "pf.location_id") - if params.ProjectFlockKandangId != 0 { - db = db.Where("project_flock_kandangs_id = ?", params.ProjectFlockKandangId) - } - db = s.Repository.ApplySearchFilters(db, params.Search) - return db.Order("recordings.record_datetime DESC").Order("recordings.created_at DESC") - }) + recordings, total, err := s.Repository.GetAllWithFilters( + c.Context(), + params.Offset, + params.Limit, + params.Search, + params.ProjectFlockKandangId, + func(db *gorm.DB) *gorm.DB { + db, scopeErr = m.ApplyLocationScope(c, db, "pf.location_id") + return db + }, + ) if scopeErr != nil { return nil, 0, scopeErr @@ -122,18 +118,63 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti s.Log.Errorf("Failed to get recordings: %+v", err) return nil, 0, err } - if err := s.attachLatestApprovals(c.Context(), recordings); err != nil { + if err := recordingutil.AttachLatestApprovals(c.Context(), recordings, s.ApprovalSvc, s.Log); err != nil { return nil, 0, err } - if err := s.attachProductionStandards(c.Context(), recordings); err != nil { + recordingPtrs := make([]*entity.Recording, 0, len(recordings)) + for i := range recordings { + recordingPtrs = append(recordingPtrs, &recordings[i]) + } + if err := recordingutil.AttachProductionStandards(c.Context(), s.Repository.DB(), true, s.Log, recordingPtrs...); err != nil { return nil, 0, err } - if err := s.attachCumulativeDepletions(c.Context(), recordings); err != nil { + dbCtx := s.Repository.DB().WithContext(c.Context()) + recordingIDs := make([]uint, 0, len(recordings)) + projectFlockIDs := make(map[uint]struct{}) + for i := range recordings { + if recordings[i].Id != 0 { + recordingIDs = append(recordingIDs, recordings[i].Id) + } + if recordings[i].ProjectFlockKandangId != 0 { + projectFlockIDs[recordings[i].ProjectFlockKandangId] = struct{}{} + } + } + + cumulativeMap, err := s.Repository.GetCumulativeDepletionByRecordingIDs(dbCtx, recordingIDs) + if err != nil { return nil, 0, err } - if err := s.attachDepletionRates(c.Context(), recordings); err != nil { + prevChickMap, err := s.Repository.GetPreviousTotalChickByRecordingIDs(dbCtx, recordingIDs) + if err != nil { return nil, 0, err } + pfkIDs := make([]uint, 0, len(projectFlockIDs)) + for id := range projectFlockIDs { + pfkIDs = append(pfkIDs, id) + } + totalChickMap, err := s.Repository.GetTotalChickByProjectFlockKandangIDs(dbCtx, pfkIDs) + if err != nil { + return nil, 0, err + } + + for i := range recordings { + if recordings[i].ProjectFlockKandangId != 0 && !recordings[i].RecordDatetime.IsZero() { + total := cumulativeMap[recordings[i].Id] + recordings[i].TotalDepletionCumQty = &total + } + + current := 0.0 + if recordings[i].TotalDepletionQty != nil { + current = *recordings[i].TotalDepletionQty + } + var prev *entity.Recording + if prevTotal, ok := prevChickMap[recordings[i].Id]; ok && prevTotal != nil { + prev = &entity.Recording{TotalChickQty: prevTotal} + } + totalChick := totalChickMap[recordings[i].ProjectFlockKandangId] + rate := recordingutil.ComputeDepletionRate(prev, current, totalChick) + recordings[i].DepletionRate = &rate + } return recordings, total, nil } @@ -152,31 +193,57 @@ func (s recordingService) GetOne(c *fiber.Ctx, id uint) (*entity.Recording, erro s.Log.Errorf("Failed get recording by id: %+v", err) return nil, err } - if err := s.attachLatestApproval(c.Context(), recording); err != nil { + if err := recordingutil.AttachLatestApproval(c.Context(), recording, s.ApprovalSvc, s.Log); err != nil { return nil, err } - if err := s.attachProductionStandard(c.Context(), recording); err != nil { + if err := recordingutil.AttachProductionStandards(c.Context(), s.Repository.DB(), false, s.Log, recording); err != nil { return nil, err } - if err := s.attachCumulativeDepletion(c.Context(), recording); err != nil { - return nil, err - } - if err := s.attachDepletionRate(c.Context(), recording); err != nil { - return nil, err + if recording.ProjectFlockKandangId != 0 { + dbCtx := s.Repository.DB().WithContext(c.Context()) + ids := []uint{recording.Id} + + cumulativeMap, err := s.Repository.GetCumulativeDepletionByRecordingIDs(dbCtx, ids) + if err != nil { + return nil, err + } + prevChickMap, err := s.Repository.GetPreviousTotalChickByRecordingIDs(dbCtx, ids) + if err != nil { + return nil, err + } + totalChickMap, err := s.Repository.GetTotalChickByProjectFlockKandangIDs(dbCtx, []uint{recording.ProjectFlockKandangId}) + if err != nil { + return nil, err + } + + if !recording.RecordDatetime.IsZero() { + total := cumulativeMap[recording.Id] + recording.TotalDepletionCumQty = &total + } + + current := 0.0 + if recording.TotalDepletionQty != nil { + current = *recording.TotalDepletionQty + } + var prev *entity.Recording + if prevTotal, ok := prevChickMap[recording.Id]; ok && prevTotal != nil { + prev = &entity.Recording{TotalChickQty: prevTotal} + } + totalChick := totalChickMap[recording.ProjectFlockKandangId] + rate := recordingutil.ComputeDepletionRate(prev, current, totalChick) + recording.DepletionRate = &rate } return recording, nil } -func (s recordingService) GetNextDay(c *fiber.Ctx, projectFlockKandangId uint, recordTime time.Time) (int, error) { - if projectFlockKandangId == 0 { - return 0, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required") - } - if err := m.EnsureProjectFlockKandangAccess(c, s.Repository.DB(), 0, projectFlockKandangId); err != nil { +func (s recordingService) GetNextDay(c *fiber.Ctx, req *validation.GetRecordingNextDay) (int, error) { + if err := s.Validate.Struct(req); err != nil { return 0, err } - - if recordTime.IsZero() { - recordTime = time.Now().UTC() + projectFlockKandangId := req.ProjectFlockKandangId + recordTime := req.RecordTimeValue.UTC() + if err := m.EnsureProjectFlockKandangAccess(c, s.Repository.DB(), 0, projectFlockKandangId); err != nil { + return 0, err } day, err := s.computeRecordingDay(c.Context(), projectFlockKandangId, recordTime) if err != nil { @@ -188,6 +255,9 @@ func (s recordingService) GetNextDay(c *fiber.Ctx, projectFlockKandangId uint, r } func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Recording, error) { + if err := s.requireFIFO(); err != nil { + return nil, err + } if err := s.Validate.Struct(req); err != nil { return nil, err } @@ -205,7 +275,7 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent recordTime = parsed.UTC() } - pfk, err := s.ProjectFlockKandangRepo.GetByID(ctx, req.ProjectFlockKandangId) + pfk, err := s.ProjectFlockKandangRepo.GetByIDLight(ctx, req.ProjectFlockKandangId) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang not found") @@ -217,10 +287,10 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent category := strings.ToUpper(pfk.ProjectFlock.Category) isLaying := category == strings.ToUpper(string(utils.ProjectFlockCategoryLaying)) - if err := s.ensureProjectFlockApproved(ctx, pfk.ProjectFlockId); err != nil { + if err := s.ProjectFlockSvc.EnsureProjectFlockApproved(ctx, pfk.ProjectFlockId); err != nil { return nil, err } - if err := s.ensureChickInExists(ctx, pfk.Id); err != nil { + if err := s.ChickinSvc.EnsureChickInExists(ctx, pfk.Id); err != nil { return nil, err } if s.ProductionStandardSvc != nil { @@ -241,6 +311,15 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent if err := s.ensureProductWarehousesExist(c, req.Stocks, req.Depletions, req.Eggs); err != nil { return nil, err } + if err := s.ensureFeedProductWarehouses(ctx, req.Stocks); err != nil { + return nil, err + } + if err := s.ensureDepletionProductWarehouses(ctx, req.Depletions); err != nil { + return nil, err + } + if err := s.ensureEggProductWarehouses(ctx, req.Eggs); err != nil { + return nil, err + } actorID, err := m.ActorIDFromContext(c) if err != nil { return nil, err @@ -287,21 +366,31 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent } mappedStocks := recordingutil.MapStocks(createdRecording.Id, req.Stocks) - stockDesired := resetStockQuantitiesForFIFO(mappedStocks, s.FifoSvc != nil) + stockDesired := resetStockQuantitiesForFIFO(mappedStocks) 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) - note := fmt.Sprintf("Recording-Create#%d", createdRecording.Id) + for i := range mappedStocks { + if i >= len(stockDesired) { + break + } + usage := stockDesired[i].Usage + pending := stockDesired[i].Pending + mappedStocks[i].UsageQty = &usage + mappedStocks[i].PendingQty = &pending + } + note := recordingutil.RecordingNote("Create", createdRecording.Id) if err := s.consumeRecordingStocks(ctx, tx, mappedStocks, note, actorID); err != nil { return err } mappedDepletions := recordingutil.MapDepletions(createdRecording.Id, req.Depletions) - depletionDesired := resetDepletionQuantitiesForFIFO(mappedDepletions, s.FifoSvc != nil) - if s.FifoSvc != nil && len(mappedDepletions) > 0 { + if len(mappedDepletions) > 0 { + if err := s.ensureDepletionWithinPopulation(ctx, tx, req.ProjectFlockKandangId, sumDepletionQty(mappedDepletions), 0); err != nil { + return err + } sourceWarehouseID, err := s.resolvePopulationWarehouseID(ctx, req.ProjectFlockKandangId) if err != nil { return err @@ -310,16 +399,17 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent mappedDepletions[i].SourceProductWarehouseId = &sourceWarehouseID } } + depletionDesired := resetDepletionQuantitiesForFIFO(mappedDepletions) if err := s.Repository.CreateDepletions(tx, mappedDepletions); err != nil { s.Log.Errorf("Failed to persist depletions: %+v", err) return err } - if s.FifoSvc != nil { - applyDepletionDesiredQuantities(mappedDepletions, depletionDesired, true) - note := fmt.Sprintf("Recording-Create#%d", createdRecording.Id) - if err := s.consumeRecordingDepletions(ctx, tx, mappedDepletions, note, actorID); err != nil { - return err - } + applyDepletionDesiredQuantities(mappedDepletions, depletionDesired) + if err := s.replenishRecordingDepletions(ctx, tx, mappedDepletions); err != nil { + return err + } + if err := s.consumeRecordingDepletions(ctx, tx, mappedDepletions, note, actorID); err != nil { + return err } mappedEggs := recordingutil.MapEggs(createdRecording.Id, createdRecording.CreatedBy, req.Eggs) @@ -327,22 +417,7 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent s.Log.Errorf("Failed to persist eggs: %+v", err) return err } - if s.FifoSvc != nil { - note := fmt.Sprintf("Recording-Create#%d", createdRecording.Id) - if err := s.replenishRecordingEggs(ctx, tx, mappedEggs, note, actorID); err != nil { - return err - } - } - - var warehouseDeltas map[uint]float64 - if s.FifoSvc != nil { - // FIFO replenish already adjusts egg warehouse quantities. - warehouseDeltas = buildWarehouseDeltas(nil, mappedDepletions, nil, nil) - } else { - warehouseDeltas = buildWarehouseDeltas(nil, mappedDepletions, nil, mappedEggs) - } - if err := s.adjustProductWarehouseQuantities(ctx, tx, warehouseDeltas); err != nil { - s.Log.Errorf("Failed to adjust product warehouses: %+v", err) + if err := s.replenishRecordingEggs(ctx, tx, mappedEggs, note, actorID); err != nil { return err } @@ -350,6 +425,10 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent s.Log.Errorf("Failed to compute recording metrics: %+v", err) return err } + if err := s.recalculateFrom(ctx, tx, createdRecording.ProjectFlockKandangId, createdRecording.RecordDatetime); err != nil { + s.Log.Errorf("Failed to recalculate recordings after create: %+v", err) + return err + } action := entity.ApprovalActionCreated if err := s.createRecordingApproval(ctx, tx, createdRecording.Id, utils.RecordingStepPengajuan, action, createdRecording.CreatedBy, nil); err != nil { @@ -367,6 +446,9 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent } func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Recording, error) { + if err := s.requireFIFO(); err != nil { + return nil, err + } if err := s.Validate.Struct(req); err != nil { return nil, err } @@ -391,6 +473,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin recording, err := repoTx.GetByID(ctx, id, func(db *gorm.DB) *gorm.DB { return s.Repository.WithRelations(db) }) + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, "Recording not found") @@ -398,45 +481,159 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin s.Log.Errorf("Failed to find recording: %+v", err) return err } - recordingEntity = recording + recordingEntity = recording hasStockChanges := req.Stocks != nil hasDepletionChanges := req.Depletions != nil hasEggChanges := req.Eggs != nil var existingStocks []entity.RecordingStock + var existingDepletions []entity.RecordingDepletion + var existingEggs []entity.RecordingEgg + + note := recordingutil.RecordingNote("Edit", recordingEntity.Id) + 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 stocksMatch(existingStocks, req.Stocks) { + existingUsage := recordingutil.StockUsageByWarehouse(existingStocks) + incomingUsage := recordingutil.StockUsageByWarehouseReq(req.Stocks) + match := recordingutil.FloatMapsEqual(existingUsage, incomingUsage) + if match { hasStockChanges = false + } else { + if err := s.ensureProductWarehousesExist(c, req.Stocks, nil, nil); err != nil { + return err + } + if err := s.ensureFeedProductWarehouses(ctx, req.Stocks); err != nil { + return err + } + if err := s.syncRecordingStocks(ctx, tx, recordingEntity.Id, existingStocks, req.Stocks, note, actorID); err != nil { + return err + } } } - 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) { + existingTotals := recordingutil.TotalsByWarehouse(existingDepletions, func(dep entity.RecordingDepletion) (uint, float64) { + return dep.ProductWarehouseId, dep.Qty + }) + incomingTotals := recordingutil.TotalsByWarehouse(req.Depletions, func(dep validation.Depletion) (uint, float64) { + return dep.ProductWarehouseId, dep.Qty + }) + match := recordingutil.FloatMapsEqual(existingTotals, incomingTotals) + if match { hasDepletionChanges = false + } else { + if err := s.ensureProductWarehousesExist(c, nil, req.Depletions, nil); err != nil { + return err + } + if err := s.ensureDepletionProductWarehouses(ctx, req.Depletions); err != nil { + return err + } + if err := s.releaseRecordingDepletions(ctx, tx, existingDepletions, note, actorID); err != nil { + return err + } + if err := s.reduceRecordingDepletions(ctx, tx, existingDepletions); err != nil { + return err + } + + if err := s.Repository.DeleteDepletions(tx, recordingEntity.Id); err != nil { + s.Log.Errorf("Failed to clear depletions: %+v", err) + return err + } + + mappedDepletions := recordingutil.MapDepletions(recordingEntity.Id, req.Depletions) + if len(mappedDepletions) > 0 { + if err := s.ensureDepletionWithinPopulation(ctx, tx, recordingEntity.ProjectFlockKandangId, sumDepletionQty(mappedDepletions), sumDepletionQty(existingDepletions)); err != nil { + return err + } + sourceWarehouseID, err := s.resolvePopulationWarehouseID(ctx, recordingEntity.ProjectFlockKandangId) + if err != nil { + return err + } + for i := range mappedDepletions { + mappedDepletions[i].SourceProductWarehouseId = &sourceWarehouseID + } + } + depletionDesired := resetDepletionQuantitiesForFIFO(mappedDepletions) + if err := s.Repository.CreateDepletions(tx, mappedDepletions); err != nil { + s.Log.Errorf("Failed to update depletions: %+v", err) + return err + } + applyDepletionDesiredQuantities(mappedDepletions, depletionDesired) + if err := s.replenishRecordingDepletions(ctx, tx, mappedDepletions); err != nil { + return err + } + if err := s.consumeRecordingDepletions(ctx, tx, mappedDepletions, note, actorID); err != nil { + return err + } } } - 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) { + existingTotals := recordingutil.EggTotalsByWarehouse(existingEggs, func(egg entity.RecordingEgg) (uint, int, *float64) { + return egg.ProductWarehouseId, egg.Qty, egg.Weight + }) + incomingTotals := recordingutil.EggTotalsByWarehouse(req.Eggs, func(egg validation.Egg) (uint, int, *float64) { + return egg.ProductWarehouseId, egg.Qty, egg.Weight + }) + match := recordingutil.EggTotalsEqual(existingTotals, incomingTotals) + if match { hasEggChanges = false + } else { + category := "" + if recordingEntity.ProjectFlockKandang != nil && recordingEntity.ProjectFlockKandang.ProjectFlock.Id != 0 { + category = strings.ToUpper(recordingEntity.ProjectFlockKandang.ProjectFlock.Category) + } + isLaying := category == strings.ToUpper(string(utils.ProjectFlockCategoryLaying)) + if !isLaying && len(req.Eggs) > 0 { + return fiber.NewError(fiber.StatusBadRequest, "Egg details permitted only for laying project flocks") + } + + if err := s.ensureProductWarehousesExist(c, nil, nil, req.Eggs); err != nil { + return err + } + if err := s.ensureEggProductWarehouses(ctx, req.Eggs); err != nil { + return err + } + if err := ensureRecordingEggsUnused(existingEggs); err != nil { + return err + } + if err := s.logRecordingEggUsage(ctx, tx, existingEggs, note, actorID); err != nil { + return err + } + if err := s.reduceRecordingEggs(ctx, tx, existingEggs); err != nil { + return err + } + + if err := s.Repository.DeleteEggs(tx, recordingEntity.Id); err != nil { + s.Log.Errorf("Failed to clear eggs: %+v", err) + return err + } + + mappedEggs := recordingutil.MapEggs(recordingEntity.Id, recordingEntity.CreatedBy, req.Eggs) + if err := s.Repository.CreateEggs(tx, mappedEggs); err != nil { + s.Log.Errorf("Failed to update eggs: %+v", err) + return err + } + + if err := s.replenishRecordingEggs(ctx, tx, mappedEggs, note, actorID); err != nil { + return err + } } } @@ -444,116 +641,9 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin return nil } - var category string - if recordingEntity.ProjectFlockKandang != nil && recordingEntity.ProjectFlockKandang.ProjectFlock.Id != 0 { - category = strings.ToUpper(recordingEntity.ProjectFlockKandang.ProjectFlock.Category) - } - isLaying := category == strings.ToUpper(string(utils.ProjectFlockCategoryLaying)) - if hasEggChanges { - if !isLaying && len(req.Eggs) > 0 { - return fiber.NewError(fiber.StatusBadRequest, "Egg details permitted only for laying project flocks") - } - } - - if hasStockChanges { - if err := s.ensureProductWarehousesExist(c, req.Stocks, nil, nil); err != nil { - return err - } - } - - if hasDepletionChanges || hasEggChanges { - if err := s.ensureProductWarehousesExist(c, nil, req.Depletions, req.Eggs); err != nil { - return err - } - } - - if hasStockChanges { - note := fmt.Sprintf("Recording-Edit#%d", recordingEntity.Id) - if err := s.syncRecordingStocks(ctx, tx, recordingEntity.Id, existingStocks, req.Stocks, note, actorID); err != nil { - return err - } - } - - if hasDepletionChanges { - if s.FifoSvc != nil { - note := fmt.Sprintf("Recording-Edit#%d", recordingEntity.Id) - if err := s.releaseRecordingDepletions(ctx, tx, existingDepletions, note, actorID); err != nil { - return err - } - } - - if err := s.Repository.DeleteDepletions(tx, recordingEntity.Id); err != nil { - s.Log.Errorf("Failed to clear depletions: %+v", err) - return err - } - - mappedDepletions := recordingutil.MapDepletions(recordingEntity.Id, req.Depletions) - depletionDesired := resetDepletionQuantitiesForFIFO(mappedDepletions, s.FifoSvc != nil) - if s.FifoSvc != nil && len(mappedDepletions) > 0 { - sourceWarehouseID, err := s.resolvePopulationWarehouseID(ctx, recordingEntity.ProjectFlockKandangId) - if err != nil { - return err - } - for i := range mappedDepletions { - mappedDepletions[i].SourceProductWarehouseId = &sourceWarehouseID - } - } - if err := s.Repository.CreateDepletions(tx, mappedDepletions); err != nil { - s.Log.Errorf("Failed to update depletions: %+v", err) - return err - } - - if s.FifoSvc != nil { - applyDepletionDesiredQuantities(mappedDepletions, depletionDesired, true) - note := fmt.Sprintf("Recording-Edit#%d", recordingEntity.Id) - if err := s.consumeRecordingDepletions(ctx, tx, mappedDepletions, note, actorID); err != nil { - return err - } - } - - if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(existingDepletions, mappedDepletions, nil, nil)); err != nil { - s.Log.Errorf("Failed to adjust product warehouses for depletions: %+v", err) - return err - } - } - - if hasEggChanges { - if s.FifoSvc != nil { - if err := ensureRecordingEggsUnused(existingEggs); err != nil { - return err - } - note := fmt.Sprintf("Recording-Edit#%d", recordingEntity.Id) - if err := s.logRecordingEggUsage(ctx, tx, existingEggs, note, actorID); err != nil { - return err - } - if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, nil, existingEggs, nil)); err != nil { - s.Log.Errorf("Failed to adjust product warehouses for 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 - } - - mappedEggs := recordingutil.MapEggs(recordingEntity.Id, recordingEntity.CreatedBy, req.Eggs) - if err := s.Repository.CreateEggs(tx, mappedEggs); err != nil { - s.Log.Errorf("Failed to update eggs: %+v", err) - return err - } - - if s.FifoSvc != nil { - note := fmt.Sprintf("Recording-Edit#%d", recordingEntity.Id) - if err := s.replenishRecordingEggs(ctx, tx, mappedEggs, note, actorID); err != nil { - return err - } - } else { - if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, nil, existingEggs, mappedEggs)); err != nil { - s.Log.Errorf("Failed to adjust product warehouses for eggs: %+v", err) - return err - } - } + if err := s.Repository.ResyncProjectFlockPopulationUsage(ctx, tx, recordingEntity.ProjectFlockKandangId); err != nil { + s.Log.Errorf("Failed to resync project flock population usage: %+v", err) + return err } if hasStockChanges || hasDepletionChanges || hasEggChanges { @@ -561,6 +651,10 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin s.Log.Errorf("Failed to recompute recording metrics: %+v", err) return err } + if err := s.recalculateFrom(ctx, tx, recordingEntity.ProjectFlockKandangId, recordingEntity.RecordDatetime); err != nil { + s.Log.Errorf("Failed to recalculate recordings after update: %+v", err) + return err + } } action := entity.ApprovalActionUpdated @@ -623,10 +717,10 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin if updatedRecording == nil { return s.GetOne(c, id) } - if err := s.attachLatestApproval(ctx, updatedRecording); err != nil { + if err := recordingutil.AttachLatestApproval(ctx, updatedRecording, s.ApprovalSvc, s.Log); err != nil { return nil, err } - if err := s.attachProductionStandard(ctx, updatedRecording); err != nil { + if err := recordingutil.AttachProductionStandards(ctx, s.Repository.DB(), false, s.Log, updatedRecording); err != nil { return nil, err } return updatedRecording, nil @@ -652,8 +746,13 @@ func (s recordingService) Approval(c *fiber.Ctx, req *validation.Approve) ([]ent default: return nil, fiber.NewError(fiber.StatusBadRequest, "action must be APPROVED or REJECTED") } + if action == entity.ApprovalActionRejected { + if err := s.requireFIFO(); err != nil { + return nil, err + } + } - ids := uniqueUintSlice(req.ApprovableIds) + ids := utils.UniqueUintSlice(req.ApprovableIds) if len(ids) == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "approvable_ids must contain at least one id") } @@ -694,10 +793,28 @@ func (s recordingService) Approval(c *fiber.Ctx, req *validation.Approve) ([]ent } if action == entity.ApprovalActionRejected { - note := fmt.Sprintf("Recording-Reject#%d", id) + note := recordingutil.RecordingNote("Reject", id) if err := s.rollbackRecordingInventory(ctx, tx, id, note, actorID); err != nil { return err } + recording, err := repoTx.GetByID(ctx, id, func(db *gorm.DB) *gorm.DB { + return s.Repository.WithRelations(db) + }) + if err != nil { + return err + } + if err := s.computeAndUpdateMetrics(ctx, tx, recording); err != nil { + s.Log.Errorf("Failed to recompute recording metrics after reject %d: %+v", id, err) + return err + } + if err := s.Repository.ResyncProjectFlockPopulationUsage(ctx, tx, recording.ProjectFlockKandangId); err != nil { + s.Log.Errorf("Failed to resync project flock population usage after reject %d: %+v", id, err) + return err + } + if err := s.recalculateFrom(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime); err != nil { + s.Log.Errorf("Failed to recalculate recordings after reject %d: %+v", id, err) + return err + } } } @@ -725,6 +842,9 @@ func (s recordingService) Approval(c *fiber.Ctx, req *validation.Approve) ([]ent } func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { + if err := s.requireFIFO(); err != nil { + return err + } if err := m.EnsureRecordingAccess(c, s.Repository.DB(), id); err != nil { return err } @@ -733,9 +853,18 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { if err != nil { return err } - note := fmt.Sprintf("Recording-Delete#%d", id) + note := recordingutil.RecordingNote("Delete", id) return s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + recording, err := s.Repository.WithTx(tx).GetByID(ctx, id, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Recording not found") + } + s.Log.Errorf("Failed to find recording: %+v", err) + return err + } + if err := s.rollbackRecordingInventory(ctx, tx, id, note, actorID); err != nil { return err } @@ -748,57 +877,20 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { return err } + if err := s.Repository.ResyncProjectFlockPopulationUsage(ctx, tx, recording.ProjectFlockKandangId); err != nil { + s.Log.Errorf("Failed to resync project flock population usage: %+v", err) + return err + } + + if err := s.recalculateFrom(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime); err != nil { + s.Log.Errorf("Failed to recalculate recordings after delete: %+v", err) + return err + } + return nil }) } -func (s *recordingService) rollbackRecordingInventory(ctx context.Context, tx *gorm.DB, recordingID uint, note string, actorID uint) error { - if recordingID == 0 || tx == nil { - return nil - } - - oldDepletions, err := s.Repository.ListDepletions(tx, recordingID) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - s.Log.Errorf("Failed to list depletions: %+v", err) - return err - } - - oldEggs, err := s.Repository.ListEggs(tx, recordingID) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - s.Log.Errorf("Failed to list eggs: %+v", err) - return err - } - if s.FifoSvc != nil { - if err := ensureRecordingEggsUnused(oldEggs); err != nil { - return err - } - if err := s.releaseRecordingDepletions(ctx, tx, oldDepletions, note, actorID); err != nil { - return err - } - } - - oldStocks, err := s.Repository.ListStocks(tx, recordingID) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - s.Log.Errorf("Failed to list stocks: %+v", err) - return err - } - if err := s.releaseRecordingStocks(ctx, tx, oldStocks, note, actorID); err != nil { - return err - } - - if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(oldDepletions, nil, oldEggs, nil)); err != nil { - return err - } - - if err := s.logRecordingEggRollback(ctx, tx, oldEggs, note, actorID); err != nil { - return err - } - - return nil -} - -// === Persistence Helpers === - func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []validation.Stock, depletions []validation.Depletion, eggs []validation.Egg) error { idSet := make(map[uint]struct{}) @@ -821,41 +913,72 @@ func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []v if len(idSet) == 0 { return nil } - + ids := make([]uint, 0, len(idSet)) for id := range idSet { - ok, err := s.ProductWarehouseRepo.ExistsByID(c.Context(), id) - if err != nil { - s.Log.Errorf("Failed to validate product warehouse %d: %+v", id, err) - return err - } - if !ok { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Product warehouse %d not found", id)) - } + ids = append(ids, id) + } + if err := recordingutil.EnsureProductWarehousesExist(c.Context(), s.ProductWarehouseRepo, ids); err != nil { + s.Log.Errorf("Failed to validate product warehouses: %+v", err) + return fiber.NewError(fiber.StatusBadRequest, err.Error()) } - return nil } -func (s *recordingService) resolvePopulationWarehouseID(ctx context.Context, projectFlockKandangID uint) (uint, error) { - if projectFlockKandangID == 0 { - return 0, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid") +func (s *recordingService) ensureEggProductWarehouses(ctx context.Context, eggs []validation.Egg) error { + if len(eggs) == 0 { + return nil } - populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(ctx, projectFlockKandangID) - if err != nil { - s.Log.Errorf("Failed to fetch populations for project_flock_kandang_id=%d: %+v", projectFlockKandangID, err) - return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data populasi") + ids := recordingutil.CollectWarehouseIDs(eggs, func(e validation.Egg) uint { return e.ProductWarehouseId }) + if err := s.validateWarehouseIDs(ctx, ids, func(ctx context.Context, ids []uint) error { + return recordingutil.EnsureEggProductWarehouses(ctx, s.Repository, ids) + }, "egg"); err != nil { + s.Log.Errorf("Failed to validate egg product warehouses: %+v", err) + return err } - for _, pop := range populations { - if pop.ProductWarehouseId > 0 && pop.TotalQty > 0 { - return pop.ProductWarehouseId, nil - } + return nil +} + +func (s *recordingService) ensureDepletionProductWarehouses(ctx context.Context, depletions []validation.Depletion) error { + if len(depletions) == 0 { + return nil } - for _, pop := range populations { - if pop.ProductWarehouseId > 0 { - return pop.ProductWarehouseId, nil - } + ids := recordingutil.CollectWarehouseIDs(depletions, func(d validation.Depletion) uint { return d.ProductWarehouseId }) + if err := s.validateWarehouseIDs(ctx, ids, func(ctx context.Context, ids []uint) error { + return recordingutil.EnsureDepletionProductWarehouses(ctx, s.Repository, ids) + }, "depletion"); err != nil { + s.Log.Errorf("Failed to validate depletion product warehouses: %+v", err) + return err } - return 0, fiber.NewError(fiber.StatusBadRequest, "Source product warehouse populasi tidak ditemukan") + return nil +} + +func (s *recordingService) ensureFeedProductWarehouses(ctx context.Context, stocks []validation.Stock) error { + if len(stocks) == 0 { + return nil + } + ids := recordingutil.CollectWarehouseIDs(stocks, func(st validation.Stock) uint { return st.ProductWarehouseId }) + if err := s.validateWarehouseIDs(ctx, ids, func(ctx context.Context, ids []uint) error { + return recordingutil.EnsureFeedProductWarehouses(ctx, s.Repository, ids) + }, "feed"); err != nil { + s.Log.Errorf("Failed to validate feed product warehouses: %+v", err) + return err + } + return nil +} + +func (s *recordingService) validateWarehouseIDs( + ctx context.Context, + ids []uint, + validator func(ctx context.Context, ids []uint) error, + label string, +) error { + if len(ids) == 0 || validator == nil { + return nil + } + if err := validator(ctx, ids); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + return nil } func (s *recordingService) computeRecordingDay(ctx context.Context, projectFlockKandangID uint, recordTime time.Time) (int, error) { @@ -892,152 +1015,6 @@ func (s *recordingService) computeRecordingDay(ctx context.Context, projectFlock return diff + 1, nil } -func buildWarehouseDeltas( - oldDepletions, newDepletions []entity.RecordingDepletion, - oldEggs, newEggs []entity.RecordingEgg, -) map[uint]float64 { - deltas := make(map[uint]float64) - for _, item := range oldDepletions { - accumulateWarehouseDelta(deltas, item.ProductWarehouseId, -item.Qty) - } - for _, item := range newDepletions { - accumulateWarehouseDelta(deltas, item.ProductWarehouseId, item.Qty) - } - for _, item := range oldEggs { - accumulateWarehouseDelta(deltas, item.ProductWarehouseId, -float64(item.Qty)) - } - for _, item := range newEggs { - accumulateWarehouseDelta(deltas, item.ProductWarehouseId, float64(item.Qty)) - } - return deltas -} - -func accumulateWarehouseDelta(deltas map[uint]float64, id uint, value float64) { - if id == 0 || value == 0 { - return - } - deltas[id] += value -} - -func (s *recordingService) adjustProductWarehouseQuantities(ctx context.Context, tx *gorm.DB, deltas map[uint]float64) error { - if len(deltas) == 0 { - return nil - } - return s.ProductWarehouseRepo.AdjustQuantities(ctx, deltas, func(*gorm.DB) *gorm.DB { return tx }) -} - -type eggTotals struct { - Qty int - Weight float64 -} - -func ensureRecordingEggsUnused(eggs []entity.RecordingEgg) error { - for _, egg := range eggs { - if egg.TotalUsed > 0 { - return fiber.NewError(fiber.StatusBadRequest, "Recording egg sudah digunakan sehingga tidak dapat diubah") - } - } - return nil -} - -func stocksMatch(existing []entity.RecordingStock, incoming []validation.Stock) bool { - - existingUsage := make(map[uint]float64) - for _, stock := range existing { - var usage float64 - if stock.UsageQty != nil { - usage = *stock.UsageQty - } - existingUsage[stock.ProductWarehouseId] += usage - } - - incomingUsage := make(map[uint]float64) - for _, item := range incoming { - incomingUsage[item.ProductWarehouseId] += item.Qty - } - - 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 += 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 += 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 { @@ -1099,39 +1076,30 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm recording.TotalDepletionCumQty = &cumDepletionQty var remainingChick float64 - if totalChick > 0 { - totalChickFloat := float64(totalChick) - if s.FifoSvc != nil { - // totalChick already represents available qty (total_qty - total_used_qty). - remainingChick = totalChickFloat - updates["total_chick_qty"] = remainingChick - recording.TotalChickQty = &remainingChick - - baseChick := initialChickin - if baseChick <= 0 { - baseChick = totalChickFloat + cumDepletionQty - } - cumRate := 0.0 - if baseChick > 0 { - cumRate = (cumDepletionQty / baseChick) * 100 - } - updates["cum_depletion_rate"] = cumRate - recording.CumDepletionRate = &cumRate - } else { - remainingChick = totalChickFloat - cumDepletionQty - if remainingChick < 0 { - remainingChick = 0 - } - updates["total_chick_qty"] = remainingChick - recording.TotalChickQty = &remainingChick - - cumRate := 0.0 - if totalChickFloat > 0 { - cumRate = (cumDepletionQty / totalChickFloat) * 100 - } - updates["cum_depletion_rate"] = cumRate - recording.CumDepletionRate = &cumRate + totalChickFloat := float64(totalChick) + if totalChick > 0 || initialChickin > 0 { + baseChick := initialChickin + if baseChick <= 0 { + baseChick = totalChickFloat + cumDepletionQty } + // Use remaining population at this record date (chickin - cumulative depletion). + if baseChick > 0 { + remainingChick = baseChick - cumDepletionQty + } else { + remainingChick = totalChickFloat + } + if remainingChick < 0 { + remainingChick = 0 + } + updates["total_chick_qty"] = remainingChick + recording.TotalChickQty = &remainingChick + + cumRate := 0.0 + if baseChick > 0 { + cumRate = (cumDepletionQty / baseChick) * 100 + } + updates["cum_depletion_rate"] = cumRate + recording.CumDepletionRate = &cumRate } else { updates["total_chick_qty"] = gorm.Expr("NULL") updates["cum_depletion_rate"] = gorm.Expr("NULL") @@ -1139,7 +1107,7 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm recording.CumDepletionRate = nil } - depletionRate := computeDepletionRate(prevRecording, currentDepletion, totalChick) + depletionRate := recordingutil.ComputeDepletionRate(prevRecording, currentDepletion, int64(remainingChick)) recording.DepletionRate = &depletionRate var feedIntake float64 @@ -1196,7 +1164,7 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm var fcrValue float64 isGrowing := false if s.ProjectFlockKandangRepo != nil { - if pfk, err := s.ProjectFlockKandangRepo.GetByID(ctx, recording.ProjectFlockKandangId); err == nil { + if pfk, err := s.ProjectFlockKandangRepo.GetByIDLight(ctx, recording.ProjectFlockKandangId); err == nil { if strings.EqualFold(pfk.ProjectFlock.Category, string(utils.ProjectFlockCategoryGrowing)) { isGrowing = true } @@ -1259,81 +1227,6 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm return nil } -func computeDepletionRate(prevRecording *entity.Recording, currentDepletion float64, totalChick int64) float64 { - base := 0.0 - if prevRecording != nil && prevRecording.TotalChickQty != nil && *prevRecording.TotalChickQty > 0 { - base = *prevRecording.TotalChickQty - } else if totalChick > 0 { - // totalChick is already remaining after today's depletion; add back current to approximate previous population. - base = float64(totalChick) + currentDepletion - } - if base <= 0 { - return 0 - } - return (currentDepletion / base) * 100 -} - -func (s *recordingService) attachCumulativeDepletion(ctx context.Context, recording *entity.Recording) error { - if recording == nil || recording.ProjectFlockKandangId == 0 || recording.RecordDatetime.IsZero() { - return nil - } - total, err := s.Repository.GetCumulativeDepletionByProjectFlockKandangUntil(s.Repository.DB().WithContext(ctx), recording.ProjectFlockKandangId, recording.RecordDatetime) - if err != nil { - return err - } - recording.TotalDepletionCumQty = &total - return nil -} - -func (s *recordingService) attachCumulativeDepletions(ctx context.Context, recordings []entity.Recording) error { - if len(recordings) == 0 { - return nil - } - for i := range recordings { - if err := s.attachCumulativeDepletion(ctx, &recordings[i]); err != nil { - return err - } - } - return nil -} - -func (s *recordingService) attachDepletionRate(ctx context.Context, recording *entity.Recording) error { - if recording == nil { - return nil - } - current := 0.0 - if recording.TotalDepletionQty != nil { - current = *recording.TotalDepletionQty - } - day := 0 - if recording.Day != nil { - day = *recording.Day - } - prev, err := s.Repository.FindPreviousRecording(s.Repository.DB().WithContext(ctx), recording.ProjectFlockKandangId, day) - if err != nil { - return err - } - totalChick, err := s.Repository.GetTotalChick(s.Repository.DB().WithContext(ctx), recording.ProjectFlockKandangId) - if err != nil { - return err - } - rate := computeDepletionRate(prev, current, totalChick) - recording.DepletionRate = &rate - return nil -} - -func (s *recordingService) attachDepletionRates(ctx context.Context, recordings []entity.Recording) error { - if len(recordings) == 0 { - return nil - } - for i := range recordings { - if err := s.attachDepletionRate(ctx, &recordings[i]); err != nil { - return err - } - } - return nil -} - func (s *recordingService) createRecordingApproval( ctx context.Context, db *gorm.DB, @@ -1362,233 +1255,3 @@ func (s *recordingService) createRecordingApproval( _, err := svc.CreateApproval(ctx, utils.ApprovalWorkflowRecording, recordingID, step, &action, actorID, notes) return err } - -func (s *recordingService) attachLatestApprovals(ctx context.Context, items []entity.Recording) error { - if len(items) == 0 || s.ApprovalSvc == nil { - return nil - } - - ids := make([]uint, 0, len(items)) - visited := make(map[uint]struct{}, len(items)) - for _, item := range items { - if item.Id == 0 { - continue - } - if _, ok := visited[item.Id]; ok { - continue - } - visited[item.Id] = struct{}{} - ids = append(ids, item.Id) - } - - if len(ids) == 0 { - return nil - } - - latestMap, err := s.ApprovalSvc.LatestByTargets(ctx, utils.ApprovalWorkflowRecording, ids, func(db *gorm.DB) *gorm.DB { - return db.Preload("ActionUser") - }) - if err != nil { - s.Log.Warnf("Unable to load latest approvals for recordings: %+v", err) - return nil - } - - if len(latestMap) == 0 { - return nil - } - - for i := range items { - if items[i].Id == 0 { - continue - } - if approval, ok := latestMap[items[i].Id]; ok { - items[i].LatestApproval = approval - } - } - - return nil -} - -func (s *recordingService) attachLatestApproval(ctx context.Context, item *entity.Recording) error { - if item == nil || item.Id == 0 || s.ApprovalSvc == nil { - return nil - } - - approvals, err := s.ApprovalSvc.ListByTarget(ctx, utils.ApprovalWorkflowRecording, item.Id, func(db *gorm.DB) *gorm.DB { - return db.Preload("ActionUser") - }) - if err != nil { - s.Log.Warnf("Unable to load approvals for recording %d: %+v", item.Id, err) - return nil - } - - if len(approvals) == 0 { - item.LatestApproval = nil - return nil - } - - latest := approvals[len(approvals)-1] - item.LatestApproval = &latest - return nil -} - -type productionStandardValues struct { - HenDay *float64 - HenHouse *float64 - FeedIntake *float64 - MaxDepletion *float64 - EggMass *float64 - EggWeight *float64 -} - -func (s *recordingService) attachProductionStandards(ctx context.Context, items []entity.Recording) error { - if len(items) == 0 { - return nil - } - - for i := range items { - if err := s.attachProductionStandard(ctx, &items[i]); err != nil { - s.Log.Warnf("Unable to load production standard for recording %d: %+v", items[i].Id, err) - } - } - return nil -} - -func (s *recordingService) attachProductionStandard(ctx context.Context, item *entity.Recording) error { - if item == nil || item.Id == 0 { - return nil - } - if item.Day == nil || *item.Day <= 0 { - return nil - } - if item.ProjectFlockKandang == nil || item.ProjectFlockKandang.ProjectFlock.Id == 0 { - return nil - } - - standardID := item.ProjectFlockKandang.ProjectFlock.ProductionStandardId - if standardID == 0 { - return nil - } - - category := strings.ToUpper(item.ProjectFlockKandang.ProjectFlock.Category) - weekBase := 1 - if category == string(utils.ProjectFlockCategoryLaying) { - weekBase = 18 - } - week := ((int(*item.Day) - 1) / 7) + weekBase - if week <= 0 { - return nil - } - - db := s.Repository.DB() - standardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db) - growthDetailRepo := rProductionStandard.NewStandardGrowthDetailRepository(db) - - var standard productionStandardValues - var standardFcr *float64 - detail, err := standardDetailRepo.GetByStandardIDAndWeek(ctx, standardID, week) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - if detail != nil { - standard.HenDay = detail.TargetHenDayProduction - standard.HenHouse = detail.TargetHenHouseProduction - standard.EggWeight = detail.TargetEggWeight - standard.EggMass = detail.TargetEggMass - if detail.StandardFCR != nil { - standardFcr = detail.StandardFCR - } - } - - growthDetail, err := growthDetailRepo.GetByStandardIDAndWeek(ctx, standardID, week) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - if growthDetail != nil { - standard.FeedIntake = growthDetail.FeedIntake - standard.MaxDepletion = growthDetail.MaxDepletion - } - - item.StandardHenDay = standard.HenDay - item.StandardHenHouse = standard.HenHouse - item.StandardFeedIntake = standard.FeedIntake - item.StandardMaxDepletion = standard.MaxDepletion - item.StandardEggMass = standard.EggMass - item.StandardEggWeight = standard.EggWeight - item.StandardFcr = standardFcr - - return nil -} - -func uniqueUintSlice(values []uint) []uint { - if len(values) == 0 { - return nil - } - - seen := make(map[uint]struct{}, len(values)) - result := make([]uint, 0, len(values)) - for _, v := range values { - if v == 0 { - continue - } - if _, ok := seen[v]; ok { - continue - } - seen[v] = struct{}{} - result = append(result, v) - } - return result -} - -func (s *recordingService) ensureProjectFlockApproved(ctx context.Context, projectFlockID uint) error { - if projectFlockID == 0 { - return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid") - } - - var ( - latest *entity.Approval - err error - ) - if s.ApprovalSvc != nil { - latest, err = s.ApprovalSvc.LatestByTarget(ctx, utils.ApprovalWorkflowProjectFlock, projectFlockID, nil) - } else { - latest, err = s.ApprovalRepo.LatestByTarget(ctx, utils.ApprovalWorkflowProjectFlock.String(), projectFlockID, nil) - } - if err != nil { - s.Log.Errorf("Failed to check project flock %d approval status: %+v", projectFlockID, err) - return fiber.NewError(fiber.StatusInternalServerError, "Gagal memeriksa status project flock") - } - - if latest == nil { - return fiber.NewError(fiber.StatusBadRequest, "Project flock masih dalam status pengajuan sehingga belum dapat membuat recording") - } - if latest.StepNumber != uint16(utils.ProjectFlockStepAktif) || latest.Action == nil || *latest.Action != entity.ApprovalActionApproved { - return fiber.NewError(fiber.StatusBadRequest, "Project flock masih dalam status pengajuan sehingga belum dapat membuat recording") - } - - return nil -} - -func (s *recordingService) ensureChickInExists(ctx context.Context, projectFlockKandangID uint) error { - if projectFlockKandangID == 0 { - return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid") - } - - populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(ctx, projectFlockKandangID) - if err != nil { - s.Log.Errorf("Failed to check project flock population for project_flock_kandang_id=%d: %+v", projectFlockKandangID, err) - return fiber.NewError(fiber.StatusInternalServerError, "Gagal memeriksa data chick in") - } - - if len(populations) == 0 { - return fiber.NewError(fiber.StatusBadRequest, "Project flock belum memiliki chick in yang disetujui sehingga belum dapat membuat recording") - } - - for _, population := range populations { - if population.TotalQty > 0 { - return nil - } - } - - return fiber.NewError(fiber.StatusBadRequest, "Chick in project flock belum disetujui sehingga belum dapat membuat recording") -} diff --git a/internal/modules/production/recordings/services/recording_fifo.service.go b/internal/modules/production/recordings/services/recording_fifo.service.go index 375b75ce..eb9e5094 100644 --- a/internal/modules/production/recordings/services/recording_fifo.service.go +++ b/internal/modules/production/recordings/services/recording_fifo.service.go @@ -3,43 +3,92 @@ package service import ( "context" "errors" + "fmt" + "math" "strings" + "time" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" - rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" - repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" - rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" - recordingutil "gitlab.com/mbugroup/lti-api.git/internal/utils/recording" "github.com/gofiber/fiber/v2" "gorm.io/gorm" ) -type RecordingFIFOIntegrationService interface { - ConsumeRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock, note string, actorID uint) error - ReleaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock, note string, actorID uint) error -} - var recordingStockUsableKey = fifo.UsableKeyRecordingStock var recordingDepletionUsableKey = fifo.UsableKeyRecordingDepletion -func NewRecordingFIFOIntegrationService( - repo repository.RecordingRepository, - productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, - fifoSvc commonSvc.FifoService, - stockLogRepo rStockLogs.StockLogRepository, -) RecordingFIFOIntegrationService { - return &recordingService{ - Log: utils.Log, - Repository: repo, - ProductWarehouseRepo: productWarehouseRepo, - FifoSvc: fifoSvc, - StockLogRepo: stockLogRepo, +const depletionUsageTolerance = 0.000001 + +func (s *recordingService) logStockTrace(action string, stock entity.RecordingStock, extra string) { + if s == nil || s.Log == nil { + return } + usage := 0.0 + if stock.UsageQty != nil { + usage = *stock.UsageQty + } + pending := 0.0 + if stock.PendingQty != nil { + pending = *stock.PendingQty + } + s.Log.Infof( + "[recording-stock] action=%s recording_id=%d stock_id=%d pw=%d usage=%.3f pending=%.3f %s", + action, + stock.RecordingId, + stock.Id, + stock.ProductWarehouseId, + usage, + pending, + extra, + ) +} + +func (s *recordingService) logEggTrace(action string, egg entity.RecordingEgg, extra string) { + if s == nil || s.Log == nil { + return + } + weight := 0.0 + if egg.Weight != nil { + weight = *egg.Weight + } + s.Log.Infof( + "[recording-egg] action=%s recording_id=%d egg_id=%d pw=%d qty=%d weight=%.3f total_qty=%.3f total_used=%.3f %s", + action, + egg.RecordingId, + egg.Id, + egg.ProductWarehouseId, + egg.Qty, + weight, + egg.TotalQty, + egg.TotalUsed, + extra, + ) +} + +func (s *recordingService) logDepletionTrace(action string, dep entity.RecordingDepletion, extra string) { + if s == nil || s.Log == nil { + return + } + sourceWarehouseID := uint(0) + if dep.SourceProductWarehouseId != nil { + sourceWarehouseID = *dep.SourceProductWarehouseId + } + s.Log.Infof( + "[recording-depletion] action=%s recording_id=%d depletion_id=%d source_pw=%d dest_pw=%d qty=%.3f usage=%.3f pending=%.3f %s", + action, + dep.RecordingId, + dep.Id, + sourceWarehouseID, + dep.ProductWarehouseId, + dep.Qty, + dep.UsageQty, + dep.PendingQty, + extra, + ) } func (s *recordingService) consumeRecordingStocks( @@ -49,9 +98,13 @@ func (s *recordingService) consumeRecordingStocks( note string, actorID uint, ) error { - if len(stocks) == 0 || s.FifoSvc == nil { + if len(stocks) == 0 { return nil } + if s.FifoSvc == nil { + s.Log.Errorf("FIFO service is not available for consuming recording stocks") + return errors.New("fifo service is not available") + } if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { return errors.New("stock log repository is not available") } @@ -60,6 +113,7 @@ func (s *recordingService) consumeRecordingStocks( if stock.Id == 0 { continue } + s.logStockTrace("consume:start", stock, "") var desired float64 if stock.UsageQty != nil { @@ -87,6 +141,7 @@ func (s *recordingService) consumeRecordingStocks( if err := s.Repository.UpdateStockUsage(tx, stock.Id, result.UsageQuantity, result.PendingQuantity); err != nil { return err } + s.logStockTrace("consume:done", stock, fmt.Sprintf("desired=%.3f used=%.3f pending=%.3f", desiredTotal, result.UsageQuantity, result.PendingQuantity)) logDecrease := result.UsageQuantity if result.PendingQuantity > 0 { @@ -129,9 +184,13 @@ func (s *recordingService) consumeRecordingDepletions( note string, actorID uint, ) error { - if len(depletions) == 0 || s.FifoSvc == nil { + if len(depletions) == 0 { return nil } + if s.FifoSvc == nil { + s.Log.Errorf("FIFO service is not available for consuming recording depletions") + return errors.New("fifo service is not available") + } if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { return errors.New("stock log repository is not available") } @@ -140,6 +199,7 @@ func (s *recordingService) consumeRecordingDepletions( if depletion.Id == 0 { continue } + s.logDepletionTrace("consume:start", depletion, "") sourceWarehouseID := uint(0) if depletion.SourceProductWarehouseId != nil { @@ -166,6 +226,7 @@ func (s *recordingService) consumeRecordingDepletions( if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, result.PendingQuantity); err != nil { return err } + s.logDepletionTrace("consume:done", depletion, fmt.Sprintf("desired=%.3f used=%.3f pending=%.3f", desired, result.UsageQuantity, result.PendingQuantity)) logDecrease := result.UsageQuantity if result.PendingQuantity > 0 { @@ -231,16 +292,6 @@ func (s *recordingService) consumeRecordingDepletions( return nil } -func (s *recordingService) ConsumeRecordingStocks( - ctx context.Context, - tx *gorm.DB, - stocks []entity.RecordingStock, - note string, - actorID uint, -) error { - return s.consumeRecordingStocks(ctx, tx, stocks, note, actorID) -} - func (s *recordingService) releaseRecordingStocks( ctx context.Context, tx *gorm.DB, @@ -248,9 +299,13 @@ func (s *recordingService) releaseRecordingStocks( note string, actorID uint, ) error { - if len(stocks) == 0 || s.FifoSvc == nil { + if len(stocks) == 0 { return nil } + if s.FifoSvc == nil { + s.Log.Errorf("FIFO service is not available for releasing recording stocks") + return errors.New("fifo service is not available") + } if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { return errors.New("stock log repository is not available") } @@ -259,6 +314,26 @@ func (s *recordingService) releaseRecordingStocks( if stock.Id == 0 { continue } + if stock.UsageQty != nil && *stock.UsageQty > 0 { + activeCount, err := s.countActiveAllocations(ctx, tx, fifo.UsableKeyRecordingStock, stock.Id) + if err != nil { + return err + } + if activeCount == 0 { + s.Log.Warnf("recording-stock release: no active allocations, forcing usage/pending to 0 (stock_id=%d)", stock.Id) + if err := s.Repository.UpdateStockUsage(tx, stock.Id, 0, 0); err != nil { + return err + } + continue + } + if err := s.resyncStockableUsageFromAllocations(ctx, tx, fifo.UsableKeyRecordingStock, stock.Id); err != nil { + return err + } + if err := s.ensureActiveAllocations(ctx, tx, fifo.UsableKeyRecordingStock, stock.Id); err != nil { + return err + } + } + s.logStockTrace("release:start", stock, "") if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ UsableKey: recordingStockUsableKey, UsableID: stock.Id, @@ -271,6 +346,7 @@ func (s *recordingService) releaseRecordingStocks( if err := s.Repository.UpdateStockUsage(tx, stock.Id, 0, 0); err != nil { return err } + s.logStockTrace("release:done", stock, "") if stock.UsageQty != nil && *stock.UsageQty > 0 && strings.TrimSpace(note) != "" && actorID != 0 { log := &entity.StockLog{ @@ -309,9 +385,13 @@ func (s *recordingService) releaseRecordingDepletions( note string, actorID uint, ) error { - if len(depletions) == 0 || s.FifoSvc == nil { + if len(depletions) == 0 { return nil } + if s.FifoSvc == nil { + s.Log.Errorf("FIFO service is not available for releasing recording depletions") + return errors.New("fifo service is not available") + } if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { return errors.New("stock log repository is not available") } @@ -320,6 +400,36 @@ func (s *recordingService) releaseRecordingDepletions( if depletion.Id == 0 { continue } + if depletion.UsageQty > 0 { + activeCount, err := s.countActiveAllocations(ctx, tx, fifo.UsableKeyRecordingDepletion, depletion.Id) + if err != nil { + return err + } + if activeCount == 0 { + s.Log.Warnf("recording-depletion release: no active allocations, forcing usage/pending to 0 (depletion_id=%d)", depletion.Id) + if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, 0); err != nil { + return err + } + if err := tx.WithContext(ctx). + Table("recording_depletions"). + Where("id = ?", depletion.Id). + Update("usage_qty", 0).Error; err != nil { + return err + } + continue + } + if err := s.resyncStockableUsageFromAllocations(ctx, tx, fifo.UsableKeyRecordingDepletion, depletion.Id); err != nil { + return err + } + if err := s.ensureActiveAllocations(ctx, tx, fifo.UsableKeyRecordingDepletion, depletion.Id); err != nil { + return err + } + } + s.logDepletionTrace("release:start", depletion, "") + if err := validateDepletionUsage(depletion); err != nil { + s.Log.Errorf("FIFO depletion mismatch for recording %d (depletion %d): qty=%.3f usage=%.3f pending=%.3f", depletion.RecordingId, depletion.Id, depletion.Qty, depletion.UsageQty, depletion.PendingQty) + return err + } sourceWarehouseID := uint(0) if depletion.SourceProductWarehouseId != nil { @@ -340,6 +450,7 @@ func (s *recordingService) releaseRecordingDepletions( if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, 0); err != nil { return err } + s.logDepletionTrace("release:done", depletion, "") logIncrease := depletion.Qty if depletion.PendingQty > 0 { @@ -405,14 +516,15 @@ func (s *recordingService) releaseRecordingDepletions( return nil } -func (s *recordingService) ReleaseRecordingStocks( - ctx context.Context, - tx *gorm.DB, - stocks []entity.RecordingStock, - note string, - actorID uint, -) error { - return s.releaseRecordingStocks(ctx, tx, stocks, note, actorID) +func validateDepletionUsage(depletion entity.RecordingDepletion) error { + desired := depletion.Qty + depletion.PendingQty + if math.Abs(depletion.UsageQty-desired) <= depletionUsageTolerance { + return nil + } + return fiber.NewError( + fiber.StatusConflict, + fmt.Sprintf("FIFO depletion mismatch (id=%d): qty=%.3f usage=%.3f pending=%.3f", depletion.Id, depletion.Qty, depletion.UsageQty, depletion.PendingQty), + ) } func (s *recordingService) logRecordingEggUsage( @@ -503,9 +615,13 @@ func (s *recordingService) replenishRecordingEggs( note string, actorID uint, ) error { - if len(eggs) == 0 || s.FifoSvc == nil { + if len(eggs) == 0 { return nil } + if s.FifoSvc == nil { + s.Log.Errorf("FIFO service is not available for replenishing recording eggs") + return errors.New("fifo service is not available") + } if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { return errors.New("stock log repository is not available") } @@ -514,6 +630,7 @@ func (s *recordingService) replenishRecordingEggs( if egg.Id == 0 || egg.ProductWarehouseId == 0 || egg.Qty <= 0 { continue } + s.logEggTrace("replenish:start", egg, "") if _, err := s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ StockableKey: fifo.StockableKeyRecordingEgg, StockableID: egg.Id, @@ -524,6 +641,7 @@ func (s *recordingService) replenishRecordingEggs( s.Log.Errorf("Failed to replenish FIFO stock for recording egg %d: %+v", egg.Id, err) return err } + s.logEggTrace("replenish:done", egg, "") if strings.TrimSpace(note) != "" && actorID != 0 { log := &entity.StockLog{ @@ -555,6 +673,210 @@ func (s *recordingService) replenishRecordingEggs( return nil } +func (s *recordingService) replenishRecordingDepletions( + ctx context.Context, + tx *gorm.DB, + depletions []entity.RecordingDepletion, +) error { + if len(depletions) == 0 { + return nil + } + if s.FifoSvc == nil { + s.Log.Errorf("FIFO service is not available for replenishing recording depletions") + return errors.New("fifo service is not available") + } + + for _, depletion := range depletions { + if depletion.Id == 0 || depletion.ProductWarehouseId == 0 || depletion.Qty <= 0 { + continue + } + s.logDepletionTrace("replenish:start", depletion, "") + if _, err := s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ + StockableKey: fifo.StockableKeyRecordingDepletion, + StockableID: depletion.Id, + ProductWarehouseID: depletion.ProductWarehouseId, + Quantity: depletion.Qty, + Tx: tx, + }); err != nil { + s.Log.Errorf("Failed to replenish FIFO stock for recording depletion %d: %+v", depletion.Id, err) + return err + } + s.logDepletionTrace("replenish:done", depletion, "") + } + + return nil +} + +func (s *recordingService) reduceRecordingDepletions( + ctx context.Context, + tx *gorm.DB, + depletions []entity.RecordingDepletion, +) error { + if len(depletions) == 0 { + return nil + } + if s.FifoSvc == nil { + s.Log.Errorf("FIFO service is not available for reducing recording depletions") + return errors.New("fifo service is not available") + } + + for _, depletion := range depletions { + if depletion.Id == 0 || depletion.ProductWarehouseId == 0 || depletion.Qty <= 0 { + continue + } + s.logDepletionTrace("reduce:start", depletion, "") + if err := s.FifoSvc.AdjustStockableQuantity(ctx, commonSvc.StockAdjustRequest{ + StockableKey: fifo.StockableKeyRecordingDepletion, + StockableID: depletion.Id, + ProductWarehouseID: depletion.ProductWarehouseId, + Quantity: -depletion.Qty, + Tx: tx, + }); err != nil { + s.Log.Errorf("Failed to reduce FIFO stock for recording depletion %d: %+v", depletion.Id, err) + return err + } + s.logDepletionTrace("reduce:done", depletion, "") + } + + return nil +} + +func (s *recordingService) reduceRecordingEggs( + ctx context.Context, + tx *gorm.DB, + eggs []entity.RecordingEgg, +) error { + if len(eggs) == 0 { + return nil + } + if s.FifoSvc == nil { + s.Log.Errorf("FIFO service is not available for reducing recording eggs") + return errors.New("fifo service is not available") + } + + for _, egg := range eggs { + if egg.Id == 0 || egg.ProductWarehouseId == 0 || egg.Qty <= 0 { + continue + } + s.logEggTrace("reduce:start", egg, "") + if err := s.FifoSvc.AdjustStockableQuantity(ctx, commonSvc.StockAdjustRequest{ + StockableKey: fifo.StockableKeyRecordingEgg, + StockableID: egg.Id, + ProductWarehouseID: egg.ProductWarehouseId, + Quantity: -float64(egg.Qty), + Tx: tx, + }); err != nil { + s.Log.Errorf("Failed to reduce FIFO stock for recording egg %d: %+v", egg.Id, err) + return err + } + s.logEggTrace("reduce:done", egg, "") + } + + return nil +} + +func (s *recordingService) ensureActiveAllocations( + ctx context.Context, + tx *gorm.DB, + usableKey fifo.UsableKey, + usableID uint, +) error { + if usableID == 0 { + return nil + } + var count int64 + if err := tx.WithContext(ctx). + Model(&entity.StockAllocation{}). + Where("usable_type = ? AND usable_id = ? AND status = ?", usableKey, usableID, entity.StockAllocationStatusActive). + Count(&count).Error; err != nil { + return err + } + if count == 0 { + return fiber.NewError(fiber.StatusConflict, fmt.Sprintf("no active allocations for usable %s id=%d", usableKey, usableID)) + } + return nil +} + +func (s *recordingService) countActiveAllocations( + ctx context.Context, + tx *gorm.DB, + usableKey fifo.UsableKey, + usableID uint, +) (int64, error) { + if usableID == 0 { + return 0, nil + } + var count int64 + if err := tx.WithContext(ctx). + Model(&entity.StockAllocation{}). + Where("usable_type = ? AND usable_id = ? AND status = ?", usableKey, usableID, entity.StockAllocationStatusActive). + Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + +func (s *recordingService) resyncStockableUsageFromAllocations( + ctx context.Context, + tx *gorm.DB, + usableKey fifo.UsableKey, + usableID uint, +) error { + if usableID == 0 { + return nil + } + + type stockableRef struct { + StockableType string + StockableID uint + } + + var refs []stockableRef + if err := tx.WithContext(ctx). + Model(&entity.StockAllocation{}). + Select("stockable_type, stockable_id"). + Where("usable_type = ? AND usable_id = ? AND status = ?", usableKey, usableID, entity.StockAllocationStatusActive). + Group("stockable_type, stockable_id"). + Scan(&refs).Error; err != nil { + return err + } + if len(refs) == 0 { + return nil + } + + for _, ref := range refs { + var total float64 + if err := tx.WithContext(ctx). + Model(&entity.StockAllocation{}). + Select("COALESCE(SUM(qty),0)"). + Where("stockable_type = ? AND stockable_id = ? AND status = ?", ref.StockableType, ref.StockableID, entity.StockAllocationStatusActive). + Scan(&total).Error; err != nil { + return err + } + + switch ref.StockableType { + case string(fifo.StockableKeyProjectFlockPopulation): + if err := tx.WithContext(ctx). + Table("project_flock_populations"). + Where("id = ?", ref.StockableID). + Update("total_used_qty", total).Error; err != nil { + return err + } + case string(fifo.StockableKeyPurchaseItems): + if err := tx.WithContext(ctx). + Table("purchase_items"). + Where("id = ?", ref.StockableID). + Update("total_used", total).Error; err != nil { + return err + } + default: + // no-op for other stockables + } + } + + return nil +} + type desiredStock struct { Usage float64 Pending float64 @@ -565,7 +887,7 @@ type desiredDepletion struct { Pending float64 } -func resetStockQuantitiesForFIFO(stocks []entity.RecordingStock, enabled bool) []desiredStock { +func resetStockQuantitiesForFIFO(stocks []entity.RecordingStock) []desiredStock { desired := make([]desiredStock, len(stocks)) for i := range stocks { if stocks[i].UsageQty != nil { @@ -574,9 +896,6 @@ func resetStockQuantitiesForFIFO(stocks []entity.RecordingStock, enabled bool) [ 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 @@ -584,39 +903,19 @@ func resetStockQuantitiesForFIFO(stocks []entity.RecordingStock, enabled bool) [ 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 resetDepletionQuantitiesForFIFO(depletions []entity.RecordingDepletion, enabled bool) []desiredDepletion { +func resetDepletionQuantitiesForFIFO(depletions []entity.RecordingDepletion) []desiredDepletion { desired := make([]desiredDepletion, len(depletions)) for i := range depletions { desired[i].Qty = depletions[i].Qty desired[i].Pending = depletions[i].PendingQty - if !enabled { - continue - } depletions[i].Qty = 0 + depletions[i].UsageQty = 0 depletions[i].PendingQty = 0 } return desired } -func applyDepletionDesiredQuantities(depletions []entity.RecordingDepletion, desired []desiredDepletion, enabled bool) { - if !enabled { - return - } +func applyDepletionDesiredQuantities(depletions []entity.RecordingDepletion, desired []desiredDepletion) { for i := range depletions { if i >= len(desired) { break @@ -636,11 +935,8 @@ func (s *recordingService) syncRecordingStocks( actorID uint, ) 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) + s.Log.Errorf("FIFO service is not available for syncing recording stocks") + return errors.New("fifo service is not available") } existingByWarehouse := make(map[uint][]entity.RecordingStock) @@ -701,3 +997,137 @@ func (s *recordingService) syncRecordingStocks( } return s.consumeRecordingStocks(ctx, tx, stocksToConsume, note, actorID) } + +func sumDepletionQty(items []entity.RecordingDepletion) float64 { + var total float64 + for _, item := range items { + if item.Qty > 0 { + total += item.Qty + } + } + return total +} + +func (s *recordingService) ensureDepletionWithinPopulation(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, newTotal float64, existingTotal float64) error { + if projectFlockKandangId == 0 || newTotal <= 0 { + return nil + } + totalChick, err := s.Repository.GetTotalChick(tx, projectFlockKandangId) + if err != nil { + return err + } + // totalChick already reflects existing depletions; add them back to compare the delta. + available := float64(totalChick) + existingTotal + if newTotal > available { + return fiber.NewError(fiber.StatusBadRequest, "Depletion melebihi populasi yang tersedia") + } + return nil +} + +func ensureRecordingEggsUnused(eggs []entity.RecordingEgg) error { + for _, egg := range eggs { + if egg.TotalUsed > 0 { + return fiber.NewError(fiber.StatusBadRequest, "Recording egg sudah digunakan sehingga tidak dapat diubah") + } + } + return nil +} + +func (s *recordingService) resolvePopulationWarehouseID(ctx context.Context, projectFlockKandangID uint) (uint, error) { + if projectFlockKandangID == 0 { + return 0, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid") + } + populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(ctx, projectFlockKandangID) + if err != nil { + s.Log.Errorf("Failed to fetch populations for project_flock_kandang_id=%d: %+v", projectFlockKandangID, err) + return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data populasi") + } + for _, pop := range populations { + if pop.ProductWarehouseId > 0 && pop.TotalQty > 0 { + return pop.ProductWarehouseId, nil + } + } + for _, pop := range populations { + if pop.ProductWarehouseId > 0 { + return pop.ProductWarehouseId, nil + } + } + return 0, fiber.NewError(fiber.StatusBadRequest, "Source product warehouse populasi tidak ditemukan") +} + +func (s *recordingService) recalculateFrom(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, from time.Time) error { + if tx == nil || projectFlockKandangId == 0 || from.IsZero() { + return nil + } + + fromUTC := from.UTC() + records, err := s.Repository.ListByProjectFlockKandangID(ctx, tx, projectFlockKandangId, &fromUTC) + if err != nil { + return err + } + + for i := range records { + if err := s.computeAndUpdateMetrics(ctx, tx, &records[i]); err != nil { + return err + } + } + + return nil +} + +func (s *recordingService) rollbackRecordingInventory(ctx context.Context, tx *gorm.DB, recordingID uint, note string, actorID uint) error { + if recordingID == 0 || tx == nil { + return nil + } + if err := s.requireFIFO(); err != nil { + return err + } + + oldDepletions, err := s.Repository.ListDepletions(tx, recordingID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to list depletions: %+v", err) + return err + } + + oldEggs, err := s.Repository.ListEggs(tx, recordingID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to list eggs: %+v", err) + return err + } + if err := ensureRecordingEggsUnused(oldEggs); err != nil { + return err + } + if err := s.releaseRecordingDepletions(ctx, tx, oldDepletions, note, actorID); err != nil { + return err + } + + oldStocks, err := s.Repository.ListStocks(tx, recordingID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to list stocks: %+v", err) + return err + } + if err := s.releaseRecordingStocks(ctx, tx, oldStocks, note, actorID); err != nil { + return err + } + + if err := s.reduceRecordingDepletions(ctx, tx, oldDepletions); err != nil { + return err + } + if err := s.reduceRecordingEggs(ctx, tx, oldEggs); err != nil { + return err + } + + if err := s.logRecordingEggRollback(ctx, tx, oldEggs, note, actorID); err != nil { + return err + } + + return nil +} + +func (s *recordingService) requireFIFO() error { + if s.FifoSvc == nil { + s.Log.Errorf("FIFO service is not available for recording operations") + return fiber.NewError(fiber.StatusInternalServerError, "FIFO service is required for recording operations") + } + return nil +} diff --git a/internal/modules/production/recordings/validations/recording.validation.go b/internal/modules/production/recordings/validations/recording.validation.go index dbbd4f30..647ea37c 100644 --- a/internal/modules/production/recordings/validations/recording.validation.go +++ b/internal/modules/production/recordings/validations/recording.validation.go @@ -1,5 +1,7 @@ package validation +import "time" + type ( Stock struct { ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"` @@ -35,6 +37,7 @@ type Update struct { type Query struct { Page int `query:"page" validate:"omitempty,number,min=1"` Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + Offset int `query:"-" validate:"omitempty,number,min=0"` ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"` Search string `query:"search" validate:"omitempty,max=50"` } @@ -44,3 +47,9 @@ type Approve struct { ApprovableIds []uint `json:"approvable_ids" validate:"required_strict,min=1,dive,gt=0"` Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` } + +type GetRecordingNextDay struct { + ProjectFlockKandangId uint `json:"project_flock_kandang_id" query:"project_flock_kandang_id" validate:"required,number,min=1"` + RecordTime *string `json:"record_date" query:"record_date" validate:"required,datetime=2006-01-02"` + RecordTimeValue *time.Time `query:"-" validate:"-"` +} diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index 6ecc6e1f..50e891f5 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -1064,6 +1064,15 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation if err != nil { return err } + // Safety: ensure the PW we got matches the purchase item product. + if pwDetail, err := pwRepoTx.GetDetailByID(c.Context(), pwID); err != nil { + return err + } else if pwDetail.ProductId != uint(item.ProductId) { + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Product warehouse %d belongs to product %d, not purchase item product %d", pwID, pwDetail.ProductId, item.ProductId), + ) + } newPWID = &pwID deltaQty := prep.receivedQty - item.TotalQty diff --git a/internal/utils/fifo/constants.go b/internal/utils/fifo/constants.go index 840ba8e1..6296eedb 100644 --- a/internal/utils/fifo/constants.go +++ b/internal/utils/fifo/constants.go @@ -17,4 +17,5 @@ const ( StockableKeyPurchaseItems StockableKey = "PURCHASE_ITEMS" StockableKeyProjectFlockPopulation StockableKey = "PROJECT_FLOCK_POPULATION" StockableKeyRecordingEgg StockableKey = "RECORDING_EGG" + StockableKeyRecordingDepletion StockableKey = "RECORDING_DEPLETION_IN" ) diff --git a/internal/utils/recording/recording_helpers.go b/internal/utils/recording/recording_helpers.go new file mode 100644 index 00000000..5e79ed40 --- /dev/null +++ b/internal/utils/recording/recording_helpers.go @@ -0,0 +1,330 @@ +package recording + +import ( + "context" + "fmt" + "strings" + + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gorm.io/gorm" +) + +type warnLogger interface { + Warnf(format string, args ...any) +} + +type productWarehouseExistsRepo interface { + ExistsByID(ctx context.Context, id uint) (bool, error) +} + +type recordingValidationRepo interface { + ValidateFeedProductWarehouses(ctx context.Context, ids []uint) (uint, error) + ValidateEggProductWarehouses(ctx context.Context, ids []uint) (uint, error) + ValidateDepletionProductWarehouses(ctx context.Context, ids []uint) (uint, error) +} + +func EnsureProductWarehousesExist(ctx context.Context, repo productWarehouseExistsRepo, ids []uint) error { + if repo == nil || len(ids) == 0 { + return nil + } + for _, id := range ids { + ok, err := repo.ExistsByID(ctx, id) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("product warehouse %d not found", id) + } + } + return nil +} + +type pwValidatorFunc func(ctx context.Context, ids []uint) (uint, error) + +func ensureProductWarehouses(ctx context.Context, ids []uint, label string, validator pwValidatorFunc) error { + if len(ids) == 0 || validator == nil { + return nil + } + invalidID, err := validator(ctx, ids) + if err != nil { + return err + } + if invalidID != 0 { + return fmt.Errorf("product warehouse %d is not a %s warehouse", invalidID, label) + } + return nil +} + +type idGetter[T any] func(T) uint + +func CollectWarehouseIDs[T any](items []T, getID idGetter[T]) []uint { + if len(items) == 0 { + return nil + } + ids := make([]uint, 0, len(items)) + for _, item := range items { + if id := getID(item); id != 0 { + ids = append(ids, id) + } + } + return ids +} + +func EnsureFeedProductWarehouses(ctx context.Context, repo recordingValidationRepo, ids []uint) error { + if repo == nil { + return nil + } + return ensureProductWarehouses(ctx, ids, "feed", repo.ValidateFeedProductWarehouses) +} + +func EnsureEggProductWarehouses(ctx context.Context, repo recordingValidationRepo, ids []uint) error { + if repo == nil { + return nil + } + return ensureProductWarehouses(ctx, ids, "egg", repo.ValidateEggProductWarehouses) +} + +func EnsureDepletionProductWarehouses(ctx context.Context, repo recordingValidationRepo, ids []uint) error { + if repo == nil { + return nil + } + return ensureProductWarehouses(ctx, ids, "depletion", repo.ValidateDepletionProductWarehouses) +} + +func ComputeDepletionRate(prevRecording *entity.Recording, currentDepletion float64, totalChick int64) float64 { + base := 0.0 + if prevRecording != nil && prevRecording.TotalChickQty != nil && *prevRecording.TotalChickQty > 0 { + base = *prevRecording.TotalChickQty + } else if totalChick > 0 { + base = float64(totalChick) + currentDepletion + } + if base <= 0 { + return 0 + } + return (currentDepletion / base) * 100 +} + +func AttachLatestApprovals(ctx context.Context, items []entity.Recording, approvalSvc commonSvc.ApprovalService, logger warnLogger) error { + if len(items) == 0 || approvalSvc == nil { + return nil + } + + ids := make([]uint, 0, len(items)) + visited := make(map[uint]struct{}, len(items)) + for _, item := range items { + if item.Id == 0 { + continue + } + if _, ok := visited[item.Id]; ok { + continue + } + visited[item.Id] = struct{}{} + ids = append(ids, item.Id) + } + + if len(ids) == 0 { + return nil + } + + latestMap, err := approvalSvc.LatestByTargets(ctx, utils.ApprovalWorkflowRecording, ids, func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + }) + if err != nil { + if logger != nil { + logger.Warnf("Unable to load latest approvals for recordings: %+v", err) + } + return nil + } + + if len(latestMap) == 0 { + return nil + } + + for i := range items { + if items[i].Id == 0 { + continue + } + if approval, ok := latestMap[items[i].Id]; ok { + items[i].LatestApproval = approval + } + } + + return nil +} + +func AttachLatestApproval(ctx context.Context, item *entity.Recording, approvalSvc commonSvc.ApprovalService, logger warnLogger) error { + if item == nil || item.Id == 0 || approvalSvc == nil { + return nil + } + + latest, err := approvalSvc.LatestByTarget(ctx, utils.ApprovalWorkflowRecording, item.Id, func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + }) + if err != nil { + if logger != nil { + logger.Warnf("Unable to load approvals for recording %d: %+v", item.Id, err) + } + return nil + } + item.LatestApproval = latest + return nil +} + +type productionStandardValues struct { + HenDay *float64 + HenHouse *float64 + FeedIntake *float64 + MaxDepletion *float64 + EggMass *float64 + EggWeight *float64 +} + +func AttachProductionStandards(ctx context.Context, db *gorm.DB, warnOnly bool, logger warnLogger, items ...*entity.Recording) error { + if len(items) == 0 { + return nil + } + + type standardKey struct { + standardID uint + week int + } + type standardCacheEntry struct { + values productionStandardValues + fcr *float64 + } + + if db == nil { + return nil + } + + standardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db) + growthDetailRepo := rProductionStandard.NewStandardGrowthDetailRepository(db) + cache := make(map[standardKey]standardCacheEntry, len(items)) + + standardIDs := make(map[uint]struct{}, len(items)) + for _, item := range items { + if item == nil || item.ProjectFlockKandang == nil || item.ProjectFlockKandang.ProjectFlock.Id == 0 { + continue + } + if item.ProjectFlockKandang.ProjectFlock.ProductionStandardId > 0 { + standardIDs[item.ProjectFlockKandang.ProjectFlock.ProductionStandardId] = struct{}{} + } + } + + standardDetailByStd := make(map[uint]map[int]*entity.ProductionStandardDetail, len(standardIDs)) + growthDetailByStd := make(map[uint]map[int]*entity.StandardGrowthDetail, len(standardIDs)) + + for standardID := range standardIDs { + details, err := standardDetailRepo.GetByProductionStandardID(ctx, standardID) + if err != nil { + if warnOnly { + if logger != nil { + logger.Warnf("Unable to preload production standard detail for standard %d: %+v", standardID, err) + } + } else { + return err + } + continue + } + detailMap := make(map[int]*entity.ProductionStandardDetail, len(details)) + for i := range details { + detail := details[i] + detailMap[detail.Week] = &detail + } + standardDetailByStd[standardID] = detailMap + + growths, err := growthDetailRepo.GetByProductionStandardID(ctx, standardID) + if err != nil { + if warnOnly { + if logger != nil { + logger.Warnf("Unable to preload standard growth detail for standard %d: %+v", standardID, err) + } + } else { + return err + } + continue + } + growthMap := make(map[int]*entity.StandardGrowthDetail, len(growths)) + for i := range growths { + growth := growths[i] + growthMap[growth.Week] = &growth + } + growthDetailByStd[standardID] = growthMap + } + + for _, item := range items { + if item == nil || item.ProjectFlockKandang == nil || item.ProjectFlockKandang.ProjectFlock.Id == 0 { + continue + } + standardID := item.ProjectFlockKandang.ProjectFlock.ProductionStandardId + if standardID == 0 { + continue + } + week := RecordingWeekValue(*item) + cacheKey := standardKey{standardID: standardID, week: week} + if cached, ok := cache[cacheKey]; ok { + applyProductionStandardValues(item, cached.values, cached.fcr) + continue + } + values := productionStandardValues{} + var fcr *float64 + if detailMap, ok := standardDetailByStd[standardID]; ok { + if detail, ok := detailMap[week]; ok { + values.HenDay = detail.TargetHenDayProduction + values.HenHouse = detail.TargetHenHouseProduction + values.EggMass = detail.TargetEggMass + values.EggWeight = detail.TargetEggWeight + fcr = detail.StandardFCR + } + } + if growthMap, ok := growthDetailByStd[standardID]; ok { + if growth, ok := growthMap[week]; ok { + values.FeedIntake = growth.FeedIntake + values.MaxDepletion = growth.MaxDepletion + } + } + cache[cacheKey] = standardCacheEntry{values: values, fcr: fcr} + applyProductionStandardValues(item, values, fcr) + } + + return nil +} + +func applyProductionStandardValues(item *entity.Recording, values productionStandardValues, fcr *float64) { + item.StandardHenDay = values.HenDay + item.StandardHenHouse = values.HenHouse + item.StandardFeedIntake = values.FeedIntake + item.StandardMaxDepletion = values.MaxDepletion + item.StandardEggMass = values.EggMass + item.StandardEggWeight = values.EggWeight + item.StandardFcr = fcr +} + +func RecordingWeekValue(e entity.Recording) int { + day := intValue(e.Day) + if day <= 0 { + return 0 + } + weekBase := 1 + if IsLayingRecording(e) { + weekBase = 18 + } + return ((day - 1) / 7) + weekBase +} + +func IsLayingRecording(e entity.Recording) bool { + if e.ProjectFlockKandang == nil { + return false + } + return strings.EqualFold(e.ProjectFlockKandang.ProjectFlock.Category, string(utils.ProjectFlockCategoryLaying)) +} + +func intValue(value *int) int { + if value == nil { + return 0 + } + return *value +} diff --git a/internal/utils/recording/util.recording.go b/internal/utils/recording/util.recording.go index 2b146f5f..d03ea800 100644 --- a/internal/utils/recording/util.recording.go +++ b/internal/utils/recording/util.recording.go @@ -1,6 +1,9 @@ package recording import ( + "fmt" + "strings" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" ) @@ -70,3 +73,87 @@ func MapEggs(recordingID uint, createdBy uint, items []validation.Egg) []entity. } return result } + +type EggTotals struct { + Qty int + Weight float64 +} + +func StockUsageByWarehouse(items []entity.RecordingStock) map[uint]float64 { + return TotalsByWarehouse(items, func(stock entity.RecordingStock) (uint, float64) { + var usage float64 + if stock.UsageQty != nil { + usage = *stock.UsageQty + } + return stock.ProductWarehouseId, usage + }) +} + +func StockUsageByWarehouseReq(items []validation.Stock) map[uint]float64 { + return TotalsByWarehouse(items, func(item validation.Stock) (uint, float64) { + return item.ProductWarehouseId, item.Qty + }) +} + +func FloatMapsEqual(a, b map[uint]float64) bool { + if len(a) != len(b) { + return false + } + for key, value := range a { + other, ok := b[key] + if !ok || !floatNearlyEqual(value, other) { + return false + } + } + return true +} + +func EggTotalsEqual(a, b map[uint]EggTotals) bool { + if len(a) != len(b) { + return false + } + for key, value := range a { + other, ok := b[key] + if !ok || value.Qty != other.Qty || !floatNearlyEqual(value.Weight, other.Weight) { + return false + } + } + return true +} + +func floatNearlyEqual(a, b float64) bool { + return a-b <= 0.000001 && b-a <= 0.000001 +} + +func TotalsByWarehouse[T any](items []T, get func(T) (uint, float64)) map[uint]float64 { + result := make(map[uint]float64) + for _, item := range items { + warehouseID, qty := get(item) + result[warehouseID] += qty + } + return result +} + +func EggTotalsByWarehouse[T any](items []T, get func(T) (uint, int, *float64)) map[uint]EggTotals { + result := make(map[uint]EggTotals) + for _, item := range items { + warehouseID, qty, weightPtr := get(item) + weight := 0.0 + if weightPtr != nil { + weight = *weightPtr + } + current := result[warehouseID] + current.Qty += qty + current.Weight += weight + result[warehouseID] = current + } + return result +} + +func RecordingNote(action string, id uint) string { + action = strings.TrimSpace(action) + if action == "" { + return fmt.Sprintf("Recording#%d", id) + } + return fmt.Sprintf("Recording-%s#%d", action, id) +} From e0d42fe6d35e54a77da1422e6d72fd34c73296d7 Mon Sep 17 00:00:00 2001 From: ragilap Date: Wed, 18 Feb 2026 15:30:59 +0700 Subject: [PATCH 07/18] [FEAT/BE] add product flags in stock --- .../recordings/repositories/recording.repository.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index a7978c16..c2a55708 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -861,6 +861,7 @@ func (r *RecordingRepositoryImpl) ValidateFeedProductWarehouses(ctx context.Cont return 0, nil } var invalidIDs []uint + feedFlags := []string{"PAKAN", "OVK"} if err := r.DB().WithContext(ctx). Table("product_warehouses pw"). Where("pw.id IN ?", ids). @@ -868,8 +869,8 @@ func (r *RecordingRepositoryImpl) ValidateFeedProductWarehouses(ctx context.Cont SELECT 1 FROM flags f WHERE f.flagable_type = 'products' AND f.flagable_id = pw.product_id - AND UPPER(f.name) = 'PAKAN' - )`). + AND UPPER(f.name) IN ? + )`, feedFlags). Pluck("pw.id", &invalidIDs).Error; err != nil { return 0, err } From 3da05eea02cffc841f2292b99f1631273fef98c0 Mon Sep 17 00:00:00 2001 From: ragilap Date: Wed, 18 Feb 2026 16:01:20 +0700 Subject: [PATCH 08/18] [FEAT/BE] add coloumn usage_qty and change standart ensure product --- ...d_depletions_recording_total_used.down.sql | 3 + ...add_depletions_recording_total_used.up.sql | 6 +- .../repositories/recording.repository.go | 57 +------------------ .../recordings/services/recording.service.go | 57 ++++++------------- internal/utils/recording/recording_helpers.go | 41 +++++-------- 5 files changed, 39 insertions(+), 125 deletions(-) diff --git a/internal/database/migrations/20260216125014_add_depletions_recording_total_used.down.sql b/internal/database/migrations/20260216125014_add_depletions_recording_total_used.down.sql index 9677a9fe..eb1e8d24 100644 --- a/internal/database/migrations/20260216125014_add_depletions_recording_total_used.down.sql +++ b/internal/database/migrations/20260216125014_add_depletions_recording_total_used.down.sql @@ -6,4 +6,7 @@ ALTER TABLE recording_depletions ALTER TABLE recording_depletions DROP COLUMN IF EXISTS total_used_qty; +ALTER TABLE recording_depletions + DROP COLUMN IF EXISTS usage_qty; + COMMIT; diff --git a/internal/database/migrations/20260216125014_add_depletions_recording_total_used.up.sql b/internal/database/migrations/20260216125014_add_depletions_recording_total_used.up.sql index 59b2aa9a..18870585 100644 --- a/internal/database/migrations/20260216125014_add_depletions_recording_total_used.up.sql +++ b/internal/database/migrations/20260216125014_add_depletions_recording_total_used.up.sql @@ -1,7 +1,8 @@ BEGIN; ALTER TABLE recording_depletions - ADD COLUMN IF NOT EXISTS total_used_qty numeric(15, 3) NOT NULL DEFAULT 0; + ADD COLUMN IF NOT EXISTS total_used_qty numeric(15, 3) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS usage_qty numeric(15, 3) NOT NULL DEFAULT 0; UPDATE recording_depletions SET pending_qty = 0 @@ -11,7 +12,4 @@ ALTER TABLE recording_depletions ADD CONSTRAINT chk_recording_depletions_pending_zero CHECK (pending_qty = 0); -ALTER TABLE recording_depletions - DROP COLUMN IF EXISTS usage_qty; - COMMIT; diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index c2a55708..0f93d0a7 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -65,9 +65,7 @@ type RecordingRepository interface { GetTotalEggProductionWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeightKg float64, err error) GetAverageTargetMetricsByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint, includeTargets bool) (RecordingTargetAverages, error) ResyncProjectFlockPopulationUsage(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error - ValidateFeedProductWarehouses(ctx context.Context, ids []uint) (uint, error) - ValidateEggProductWarehouses(ctx context.Context, ids []uint) (uint, error) - ValidateDepletionProductWarehouses(ctx context.Context, ids []uint) (uint, error) + ValidateProductWarehousesByFlags(ctx context.Context, ids []uint, flags []string) (uint, error) } type RecordingRepositoryImpl struct { @@ -856,36 +854,11 @@ func (r *RecordingRepositoryImpl) ResyncProjectFlockPopulationUsage(ctx context. return nil } -func (r *RecordingRepositoryImpl) ValidateFeedProductWarehouses(ctx context.Context, ids []uint) (uint, error) { +func (r *RecordingRepositoryImpl) ValidateProductWarehousesByFlags(ctx context.Context, ids []uint, flags []string) (uint, error) { if len(ids) == 0 { return 0, nil } var invalidIDs []uint - feedFlags := []string{"PAKAN", "OVK"} - if err := r.DB().WithContext(ctx). - Table("product_warehouses pw"). - Where("pw.id IN ?", ids). - Where(`NOT EXISTS ( - SELECT 1 FROM flags f - WHERE f.flagable_type = 'products' - AND f.flagable_id = pw.product_id - AND UPPER(f.name) IN ? - )`, feedFlags). - Pluck("pw.id", &invalidIDs).Error; err != nil { - return 0, err - } - if len(invalidIDs) > 0 { - return invalidIDs[0], nil - } - return 0, nil -} - -func (r *RecordingRepositoryImpl) ValidateEggProductWarehouses(ctx context.Context, ids []uint) (uint, error) { - if len(ids) == 0 { - return 0, nil - } - eggFlags := []string{"TELUR-UTUH", "TELUR-PECAH", "TELUR-PUTIH", "TELUR-RETAK", "TELUR"} - var invalidIDs []uint if err := r.DB().WithContext(ctx). Table("product_warehouses pw"). Where("pw.id IN ?", ids). @@ -894,31 +867,7 @@ func (r *RecordingRepositoryImpl) ValidateEggProductWarehouses(ctx context.Conte WHERE f.flagable_type = 'products' AND f.flagable_id = pw.product_id AND UPPER(f.name) IN ? - )`, eggFlags). - Pluck("pw.id", &invalidIDs).Error; err != nil { - return 0, err - } - if len(invalidIDs) > 0 { - return invalidIDs[0], nil - } - return 0, nil -} - -func (r *RecordingRepositoryImpl) ValidateDepletionProductWarehouses(ctx context.Context, ids []uint) (uint, error) { - if len(ids) == 0 { - return 0, nil - } - ayamFlags := []string{"AYAM-AFKIR", "AYAM-CULLING", "AYAM-MATI"} - var invalidIDs []uint - if err := r.DB().WithContext(ctx). - Table("product_warehouses pw"). - Where("pw.id IN ?", ids). - Where(`NOT EXISTS ( - SELECT 1 FROM flags f - WHERE f.flagable_type = 'products' - AND f.flagable_id = pw.product_id - AND UPPER(f.name) IN ? - )`, ayamFlags). + )`, flags). Pluck("pw.id", &invalidIDs).Error; err != nil { return 0, err } diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 6ef692b9..5fd387bf 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -311,13 +311,16 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent if err := s.ensureProductWarehousesExist(c, req.Stocks, req.Depletions, req.Eggs); err != nil { return nil, err } - if err := s.ensureFeedProductWarehouses(ctx, req.Stocks); err != nil { + feedIDs := recordingutil.CollectWarehouseIDs(req.Stocks, func(st validation.Stock) uint { return st.ProductWarehouseId }) + if err := s.ensureProductWarehousesByFlags(ctx, feedIDs, []string{"PAKAN", "OVK"}, "feed"); err != nil { return nil, err } - if err := s.ensureDepletionProductWarehouses(ctx, req.Depletions); err != nil { + depletionIDs := recordingutil.CollectWarehouseIDs(req.Depletions, func(d validation.Depletion) uint { return d.ProductWarehouseId }) + if err := s.ensureProductWarehousesByFlags(ctx, depletionIDs, []string{"AYAM-AFKIR", "AYAM-CULLING", "AYAM-MATI"}, "depletion"); err != nil { return nil, err } - if err := s.ensureEggProductWarehouses(ctx, req.Eggs); err != nil { + eggIDs := recordingutil.CollectWarehouseIDs(req.Eggs, func(e validation.Egg) uint { return e.ProductWarehouseId }) + if err := s.ensureProductWarehousesByFlags(ctx, eggIDs, []string{"TELUR-UTUH", "TELUR-PECAH", "TELUR-PUTIH", "TELUR-RETAK", "TELUR"}, "egg"); err != nil { return nil, err } actorID, err := m.ActorIDFromContext(c) @@ -508,7 +511,8 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin if err := s.ensureProductWarehousesExist(c, req.Stocks, nil, nil); err != nil { return err } - if err := s.ensureFeedProductWarehouses(ctx, req.Stocks); err != nil { + feedIDs := recordingutil.CollectWarehouseIDs(req.Stocks, func(st validation.Stock) uint { return st.ProductWarehouseId }) + if err := s.ensureProductWarehousesByFlags(ctx, feedIDs, []string{"PAKAN", "OVK"}, "feed"); err != nil { return err } if err := s.syncRecordingStocks(ctx, tx, recordingEntity.Id, existingStocks, req.Stocks, note, actorID); err != nil { @@ -536,7 +540,8 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin if err := s.ensureProductWarehousesExist(c, nil, req.Depletions, nil); err != nil { return err } - if err := s.ensureDepletionProductWarehouses(ctx, req.Depletions); err != nil { + depletionIDs := recordingutil.CollectWarehouseIDs(req.Depletions, func(d validation.Depletion) uint { return d.ProductWarehouseId }) + if err := s.ensureProductWarehousesByFlags(ctx, depletionIDs, []string{"AYAM-AFKIR", "AYAM-CULLING", "AYAM-MATI"}, "depletion"); err != nil { return err } if err := s.releaseRecordingDepletions(ctx, tx, existingDepletions, note, actorID); err != nil { @@ -607,7 +612,8 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin if err := s.ensureProductWarehousesExist(c, nil, nil, req.Eggs); err != nil { return err } - if err := s.ensureEggProductWarehouses(ctx, req.Eggs); err != nil { + eggIDs := recordingutil.CollectWarehouseIDs(req.Eggs, func(e validation.Egg) uint { return e.ProductWarehouseId }) + if err := s.ensureProductWarehousesByFlags(ctx, eggIDs, []string{"TELUR-UTUH", "TELUR-PECAH", "TELUR-PUTIH", "TELUR-RETAK", "TELUR"}, "egg"); err != nil { return err } if err := ensureRecordingEggsUnused(existingEggs); err != nil { @@ -924,43 +930,14 @@ func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []v return nil } -func (s *recordingService) ensureEggProductWarehouses(ctx context.Context, eggs []validation.Egg) error { - if len(eggs) == 0 { +func (s *recordingService) ensureProductWarehousesByFlags(ctx context.Context, ids []uint, flags []string, label string) error { + if len(ids) == 0 { return nil } - ids := recordingutil.CollectWarehouseIDs(eggs, func(e validation.Egg) uint { return e.ProductWarehouseId }) if err := s.validateWarehouseIDs(ctx, ids, func(ctx context.Context, ids []uint) error { - return recordingutil.EnsureEggProductWarehouses(ctx, s.Repository, ids) - }, "egg"); err != nil { - s.Log.Errorf("Failed to validate egg product warehouses: %+v", err) - return err - } - return nil -} - -func (s *recordingService) ensureDepletionProductWarehouses(ctx context.Context, depletions []validation.Depletion) error { - if len(depletions) == 0 { - return nil - } - ids := recordingutil.CollectWarehouseIDs(depletions, func(d validation.Depletion) uint { return d.ProductWarehouseId }) - if err := s.validateWarehouseIDs(ctx, ids, func(ctx context.Context, ids []uint) error { - return recordingutil.EnsureDepletionProductWarehouses(ctx, s.Repository, ids) - }, "depletion"); err != nil { - s.Log.Errorf("Failed to validate depletion product warehouses: %+v", err) - return err - } - return nil -} - -func (s *recordingService) ensureFeedProductWarehouses(ctx context.Context, stocks []validation.Stock) error { - if len(stocks) == 0 { - return nil - } - ids := recordingutil.CollectWarehouseIDs(stocks, func(st validation.Stock) uint { return st.ProductWarehouseId }) - if err := s.validateWarehouseIDs(ctx, ids, func(ctx context.Context, ids []uint) error { - return recordingutil.EnsureFeedProductWarehouses(ctx, s.Repository, ids) - }, "feed"); err != nil { - s.Log.Errorf("Failed to validate feed product warehouses: %+v", err) + return recordingutil.EnsureProductWarehousesByFlags(ctx, s.Repository, ids, flags, label) + }, label); err != nil { + s.Log.Errorf("Failed to validate %s product warehouses: %+v", label, err) return err } return nil diff --git a/internal/utils/recording/recording_helpers.go b/internal/utils/recording/recording_helpers.go index 5e79ed40..644f6e8d 100644 --- a/internal/utils/recording/recording_helpers.go +++ b/internal/utils/recording/recording_helpers.go @@ -21,9 +21,7 @@ type productWarehouseExistsRepo interface { } type recordingValidationRepo interface { - ValidateFeedProductWarehouses(ctx context.Context, ids []uint) (uint, error) - ValidateEggProductWarehouses(ctx context.Context, ids []uint) (uint, error) - ValidateDepletionProductWarehouses(ctx context.Context, ids []uint) (uint, error) + ValidateProductWarehousesByFlags(ctx context.Context, ids []uint, flags []string) (uint, error) } func EnsureProductWarehousesExist(ctx context.Context, repo productWarehouseExistsRepo, ids []uint) error { @@ -42,13 +40,11 @@ func EnsureProductWarehousesExist(ctx context.Context, repo productWarehouseExis return nil } -type pwValidatorFunc func(ctx context.Context, ids []uint) (uint, error) - -func ensureProductWarehouses(ctx context.Context, ids []uint, label string, validator pwValidatorFunc) error { - if len(ids) == 0 || validator == nil { +func EnsureProductWarehousesByFlags(ctx context.Context, repo recordingValidationRepo, ids []uint, flags []string, label string) error { + if repo == nil || len(ids) == 0 { return nil } - invalidID, err := validator(ctx, ids) + invalidID, err := repo.ValidateProductWarehousesByFlags(ctx, ids, flags) if err != nil { return err } @@ -73,25 +69,16 @@ func CollectWarehouseIDs[T any](items []T, getID idGetter[T]) []uint { return ids } -func EnsureFeedProductWarehouses(ctx context.Context, repo recordingValidationRepo, ids []uint) error { - if repo == nil { - return nil - } - return ensureProductWarehouses(ctx, ids, "feed", repo.ValidateFeedProductWarehouses) -} - -func EnsureEggProductWarehouses(ctx context.Context, repo recordingValidationRepo, ids []uint) error { - if repo == nil { - return nil - } - return ensureProductWarehouses(ctx, ids, "egg", repo.ValidateEggProductWarehouses) -} - -func EnsureDepletionProductWarehouses(ctx context.Context, repo recordingValidationRepo, ids []uint) error { - if repo == nil { - return nil - } - return ensureProductWarehouses(ctx, ids, "depletion", repo.ValidateDepletionProductWarehouses) +func EnsureProductWarehousesByFlagsForItems[T any]( + ctx context.Context, + repo recordingValidationRepo, + items []T, + getID idGetter[T], + flags []string, + label string, +) error { + ids := CollectWarehouseIDs(items, getID) + return EnsureProductWarehousesByFlags(ctx, repo, ids, flags, label) } func ComputeDepletionRate(prevRecording *entity.Recording, currentDepletion float64, totalChick int64) float64 { From 95547ad7c7dbcbaf3019cdbac5e381b58acfab6e Mon Sep 17 00:00:00 2001 From: giovanni Date: Thu, 19 Feb 2026 16:00:48 +0700 Subject: [PATCH 09/18] fix insert stock to stocklog --- .../modules/inventory/transfers/services/transfer.service.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index aa5a6069..fa4cb8ca 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -483,7 +483,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } if len(stockLogs) > 0 { latestStockLog := stockLogs[0] - stockLogDecrease.Stock -= latestStockLog.Stock - stockLogDecrease.Decrease + stockLogDecrease.Stock = latestStockLog.Stock - stockLogDecrease.Decrease } else { stockLogDecrease.Stock -= stockLogDecrease.Decrease } From 1c22c0f01ce6df750ba66afb0fb6f346d4fb7c05 Mon Sep 17 00:00:00 2001 From: ragilap Date: Fri, 20 Feb 2026 10:19:00 +0700 Subject: [PATCH 10/18] [FEAT/BE] fixing remaining stock check closing response --- .../closings/dto/closingSapronak.dto.go | 10 +++--- .../services/project_flock_kandang.service.go | 34 +++++++++---------- 2 files changed, 21 insertions(+), 23 deletions(-) diff --git a/internal/modules/closings/dto/closingSapronak.dto.go b/internal/modules/closings/dto/closingSapronak.dto.go index d4cb0d0d..4dd1dc59 100644 --- a/internal/modules/closings/dto/closingSapronak.dto.go +++ b/internal/modules/closings/dto/closingSapronak.dto.go @@ -65,7 +65,7 @@ type SapronakCategoryRowDTO struct { QtyOut float64 `json:"qty_out"` QtyUsed float64 `json:"qty_used"` Description string `json:"description"` - ProductCategory []string `json:"product_category"` + ProductCategory string `json:"product_category"` UnitPrice float64 `json:"unit_price"` TotalAmount float64 `json:"total_amount"` Notes string `json:"notes"` @@ -183,13 +183,13 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin "PULLET": 0, } - buildFlagList := func(productID uint, fallback string) []string { + buildFlagList := func(productID uint, fallback string) string { rawFlags := productFlags[productID] if len(rawFlags) == 0 { if fallback == "" { - return []string{} + return "" } - return []string{fallback} + return fallback } seen := make(map[string]struct{}, len(rawFlags)) ordered := make([]string, 0, len(rawFlags)) @@ -220,7 +220,7 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin } return li < lj }) - return ordered + return strings.Join(ordered, " ") } for _, group := range report.Groups { diff --git a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go index 61a593d5..a2d276cd 100644 --- a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go +++ b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go @@ -308,25 +308,23 @@ func (s projectFlockKandangService) CheckClosing(c *fiber.Ctx, id uint) (*Closin } for _, pw := range productWarehouses { - if pw.Quantity > 0 { - category := "" - if pw.Product.ProductCategory.Id != 0 { - category = pw.Product.ProductCategory.Name - } - uomName := "" - if pw.Product.Uom.Id != 0 { - uomName = pw.Product.Uom.Name - } - stockRemain = append(stockRemain, StockRemainingDetail{ - FlagName: string(flagName), - ProductWarehouseId: pw.Id, - ProductId: pw.ProductId, - ProductName: pw.Product.Name, - ProductCategory: category, - Uom: uomName, - Quantity: pw.Quantity, - }) + category := "" + if pw.Product.ProductCategory.Id != 0 { + category = pw.Product.ProductCategory.Name } + uomName := "" + if pw.Product.Uom.Id != 0 { + uomName = pw.Product.Uom.Name + } + stockRemain = append(stockRemain, StockRemainingDetail{ + FlagName: string(flagName), + ProductWarehouseId: pw.Id, + ProductId: pw.ProductId, + ProductName: pw.Product.Name, + ProductCategory: category, + Uom: uomName, + Quantity: pw.Quantity, + }) } } } From 4bf9b126803c451c7d19734ff8399fca260e5e23 Mon Sep 17 00:00:00 2001 From: ragilap Date: Fri, 20 Feb 2026 14:46:01 +0700 Subject: [PATCH 11/18] [FEAT/BE] fix response closing and fix status rejected filter --- .../services/deliveryorder.service.go | 27 ++++++++++++++----- .../marketing/services/salesorder.service.go | 25 +++++++++++++++++ 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/internal/modules/marketing/services/deliveryorder.service.go b/internal/modules/marketing/services/deliveryorder.service.go index 677ef965..3fe0dd3b 100644 --- a/internal/modules/marketing/services/deliveryorder.service.go +++ b/internal/modules/marketing/services/deliveryorder.service.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strings" "time" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" @@ -117,18 +118,30 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO Preload("Products.DeliveryProduct") if params.Status != "" { + status := strings.TrimSpace(params.Status) latestApprovalSubQuery := s.MarketingRepo.DB(). WithContext(c.Context()). Table("approvals"). - Select("DISTINCT ON (approvable_id) approvable_id, step_name"). + Select("DISTINCT ON (approvable_id) approvable_id, step_name, action"). Where("approvable_type = ?", utils.ApprovalWorkflowMarketing.String()). Order("approvable_id, id DESC") - db = db.Where(`EXISTS ( - SELECT 1 - FROM (?) AS latest_approval - WHERE latest_approval.approvable_id = marketings.id - AND LOWER(latest_approval.step_name) = LOWER(?) - )`, latestApprovalSubQuery, params.Status) + + if strings.EqualFold(status, "DITOLAK") { + db = db.Where(`EXISTS ( + SELECT 1 + FROM (?) AS latest_approval + WHERE latest_approval.approvable_id = marketings.id + AND latest_approval.action = ? + )`, latestApprovalSubQuery, string(entity.ApprovalActionRejected)) + } else { + db = db.Where(`EXISTS ( + SELECT 1 + FROM (?) AS latest_approval + WHERE latest_approval.approvable_id = marketings.id + AND LOWER(latest_approval.step_name) = LOWER(?) + AND (latest_approval.action IS NULL OR latest_approval.action <> ?) + )`, latestApprovalSubQuery, status, string(entity.ApprovalActionRejected)) + } } if params.Search != "" { diff --git a/internal/modules/marketing/services/salesorder.service.go b/internal/modules/marketing/services/salesorder.service.go index eb2e4f5b..7d032c86 100644 --- a/internal/modules/marketing/services/salesorder.service.go +++ b/internal/modules/marketing/services/salesorder.service.go @@ -152,6 +152,31 @@ func (s *salesOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) (*e } } + requestedByWarehouse := make(map[uint]float64) + for _, item := range req.MarketingProducts { + if item.ProductWarehouseId == 0 { + continue + } + requestedByWarehouse[item.ProductWarehouseId] += item.Qty + } + + for pwID, requestedQty := range requestedByWarehouse { + productWarehouse, err := s.ProductWarehouseRepo.GetDetailByID(c.Context(), pwID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Product warehouse %d not found", pwID)) + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check stock availability") + } + availableQty := productWarehouse.Quantity + if availableQty+1e-6 < requestedQty { + return nil, fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Stok tidak mencukupi untuk gudang %d: diminta %.3f, tersedia %.3f", pwID, requestedQty, availableQty), + ) + } + } + soDate, err := utils.ParseDateString(req.Date) if err != nil { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid date format") From 0ac174fdc6180c4bc3aecd49ba07e0ee5f1eb609 Mon Sep 17 00:00:00 2001 From: ragilap Date: Sun, 22 Feb 2026 21:12:57 +0700 Subject: [PATCH 12/18] [FEAT/BE] fix filter rejected delivery service --- internal/modules/marketing/services/deliveryorder.service.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/modules/marketing/services/deliveryorder.service.go b/internal/modules/marketing/services/deliveryorder.service.go index 3fe0dd3b..bb6682c7 100644 --- a/internal/modules/marketing/services/deliveryorder.service.go +++ b/internal/modules/marketing/services/deliveryorder.service.go @@ -6,7 +6,6 @@ import ( "fmt" "strings" "time" - commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" From 9d6a69dc4dab069850e10871e60656292be6b403 Mon Sep 17 00:00:00 2001 From: ragilap Date: Mon, 23 Feb 2026 11:33:57 +0700 Subject: [PATCH 13/18] [FEAT/BE] fix status closed project flock, closing perhitungan sapronak --- .../closings/dto/closingSapronak.dto.go | 38 +++++++ .../repositories/closing.repository.go | 100 ++++++++++++------ .../closings/services/sapronak.service.go | 8 ++ .../services/deliveryorder.service.go | 12 ++- .../project_flocks/dto/projectflock.dto.go | 15 ++- 5 files changed, 137 insertions(+), 36 deletions(-) diff --git a/internal/modules/closings/dto/closingSapronak.dto.go b/internal/modules/closings/dto/closingSapronak.dto.go index 4dd1dc59..4c5db68d 100644 --- a/internal/modules/closings/dto/closingSapronak.dto.go +++ b/internal/modules/closings/dto/closingSapronak.dto.go @@ -317,6 +317,27 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin } } + // For chicken categories, keep qty_used aligned with qty_in - qty_out. + // Sales are excluded; usage represents remaining after transfers. + adjustChicken := func(cat *SapronakCategoryDTO) { + if cat == nil { + return + } + for i := range cat.Rows { + row := &cat.Rows[i] + remaining := row.QtyIn - row.QtyOut + if remaining < 0 { + remaining = 0 + } + row.QtyUsed = remaining + if row.UnitPrice > 0 { + row.TotalAmount = row.QtyUsed * row.UnitPrice + } + } + } + adjustChicken(result.Doc) + adjustChicken(result.Pullet) + buildTotals := func(cat *SapronakCategoryDTO, label string) { if cat == nil { return @@ -345,5 +366,22 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin buildTotals(result.Doc, "TOTAL DOC") buildTotals(result.Ovk, "TOTAL OVK") buildTotals(result.Pakan, "TOTAL PAKAN") + + // For chicken categories, enforce total qty_used = qty_in - qty_out. + adjustChickenTotal := func(cat *SapronakCategoryDTO) { + if cat == nil { + return + } + remaining := cat.Total.QtyIn - cat.Total.QtyOut + if remaining < 0 { + remaining = 0 + } + cat.Total.QtyUsed = remaining + if cat.Total.AvgUnitPrice > 0 { + cat.Total.TotalAmount = cat.Total.AvgUnitPrice * remaining + } + } + adjustChickenTotal(result.Doc) + adjustChickenTotal(result.Pullet) return result } diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index ecd96b0a..a796d513 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -1029,17 +1029,18 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C Joins("LEFT JOIN adjustment_stocks ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyAdjustmentIn.String()). Joins("LEFT JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()). Joins("LEFT JOIN project_chickins pc ON pc.id = pfp.project_chickin_id"). - Joins("LEFT JOIN products p_resolve ON p_resolve.id = COALESCE(pi.product_id, pw_ltt.product_id, pw.product_id)"). + Joins("LEFT JOIN product_warehouses pw_pc ON pw_pc.id = pc.product_warehouse_id"). + Joins("LEFT JOIN products p_resolve ON p_resolve.id = COALESCE(pi.product_id, pw_ltt.product_id, pw_pc.product_id, pw.product_id)"). Where("sa.status = ?", entity.StockAllocationStatusActive). Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()). Where("f.name IN ?", sapronakFlagsAll). Where(` - (sa.usable_type = ? AND r.project_flock_kandangs_id = ?) + (sa.usable_type = ? AND r.project_flock_kandangs_id = ? AND f.name IN ?) OR - (sa.usable_type = ? AND pc_used.project_flock_kandang_id = ?) + (sa.usable_type = ? AND pc_used.project_flock_kandang_id = ? AND f.name IN ?) `, - fifo.UsableKeyRecordingStock.String(), projectFlockKandangID, - fifo.UsableKeyProjectChickin.String(), projectFlockKandangID, + fifo.UsableKeyRecordingStock.String(), projectFlockKandangID, sapronakFlagsUsage, + fifo.UsableKeyProjectChickin.String(), projectFlockKandangID, sapronakFlagsChickin, ) query = r.joinSapronakProductFlag(query, "p_resolve"). Group(` @@ -1447,51 +1448,90 @@ func (r *ClosingRepositoryImpl) FetchSapronakSalesAllocatedDetails(ctx context.C return map[uint][]SapronakDetailRow{}, nil } + pfpType := fifo.StockableKeyProjectFlockPopulation.String() + query := r.withCtx(ctx). Table("stock_allocations AS sa"). - Select(` - pw.product_id AS product_id, - p.name AS product_name, + Select(fmt.Sprintf(` + p_resolve.id AS product_id, + p_resolve.name AS product_name, f.name AS flag, - COALESCE( - pi.received_date, - st.transfer_date, - lt.transfer_date, - ast.created_at - ) AS date, - COALESCE( - po.po_number, - st.movement_number, - lt.transfer_number, - CONCAT('ADJ-', ast.id), - '' - ) AS reference, + CASE + WHEN sa.stockable_type = '%s' THEN COALESCE( + pi_pc.received_date, + st_pc.transfer_date, + lt_pc.transfer_date, + ast_pc.created_at, + pc.chick_in_date + ) + ELSE COALESCE( + pi.received_date, + st.transfer_date, + lt.transfer_date, + ast.created_at + ) + END AS date, + CASE + WHEN sa.stockable_type = '%s' THEN COALESCE( + po_pc.po_number, + st_pc.movement_number, + lt_pc.transfer_number, + CASE WHEN ast_pc.id IS NOT NULL THEN CONCAT('ADJ-', ast_pc.id) END, + CONCAT('CHICKIN-', pc.id), + '' + ) + ELSE COALESCE( + po.po_number, + st.movement_number, + lt.transfer_number, + CASE WHEN ast.id IS NOT NULL THEN CONCAT('ADJ-', ast.id) END, + '' + ) + END AS reference, 0 AS qty_in, COALESCE(SUM(sa.qty), 0) AS qty_out, - COALESCE(pi.price, p.product_price, 0) AS price - `). - Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id"). - Joins("JOIN products p ON p.id = pw.product_id"). + CASE + WHEN sa.stockable_type = '%s' THEN COALESCE(pi_pc.price, p_resolve.product_price, 0) + ELSE COALESCE(pi.price, p_resolve.product_price, 0) + END AS price + `, pfpType, pfpType, pfpType)). Joins("JOIN marketing_delivery_products mdp ON mdp.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyMarketingDelivery.String()). + Joins("JOIN product_warehouses pw_sales ON pw_sales.id = mdp.product_warehouse_id"). + Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id"). Joins("LEFT JOIN purchase_items pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()). Joins("LEFT JOIN purchases po ON po.id = pi.purchase_id"). Joins("LEFT JOIN stock_transfer_details std ON std.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyStockTransferIn.String()). Joins("LEFT JOIN stock_transfers st ON st.id = std.stock_transfer_id"). Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()). Joins("LEFT JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id"). + Joins("LEFT JOIN product_warehouses pw_ltt ON pw_ltt.id = ltt.product_warehouse_id"). Joins("LEFT JOIN adjustment_stocks ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyAdjustmentIn.String()). + Joins("LEFT JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()). + Joins("LEFT JOIN project_chickins pc ON pc.id = pfp.project_chickin_id"). + Joins("LEFT JOIN stock_allocations sa_pc ON sa_pc.usable_type = ? AND sa_pc.usable_id = pc.id", fifo.UsableKeyProjectChickin.String()). + Joins("LEFT JOIN purchase_items pi_pc ON pi_pc.id = sa_pc.stockable_id AND sa_pc.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()). + Joins("LEFT JOIN purchases po_pc ON po_pc.id = pi_pc.purchase_id"). + Joins("LEFT JOIN stock_transfer_details std_pc ON std_pc.id = sa_pc.stockable_id AND sa_pc.stockable_type = ?", fifo.StockableKeyStockTransferIn.String()). + Joins("LEFT JOIN stock_transfers st_pc ON st_pc.id = std_pc.stock_transfer_id"). + Joins("LEFT JOIN laying_transfer_targets ltt_pc ON ltt_pc.id = sa_pc.stockable_id AND sa_pc.stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()). + Joins("LEFT JOIN laying_transfers lt_pc ON lt_pc.id = ltt_pc.laying_transfer_id"). + Joins("LEFT JOIN adjustment_stocks ast_pc ON ast_pc.id = sa_pc.stockable_id AND sa_pc.stockable_type = ?", fifo.StockableKeyAdjustmentIn.String()). + Joins("LEFT JOIN product_warehouses pw_pc ON pw_pc.id = pc.product_warehouse_id"). + Joins(fmt.Sprintf("LEFT JOIN products p_resolve ON p_resolve.id = CASE WHEN sa.stockable_type = '%s' THEN pw_pc.product_id ELSE COALESCE(pi.product_id, pw_ltt.product_id, pw.product_id) END", pfpType)). Where("sa.status = ?", entity.StockAllocationStatusActive). - Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()). - Where("pw.project_flock_kandang_id = ?", projectFlockKandangID). + Where("sa.stockable_type <> ?", fifo.StockableKeyRecordingEgg.String()). + Where("pw_sales.project_flock_kandang_id = ?", projectFlockKandangID). Where("f.name IN ?", sapronakFlagsAll). Group(` - pw.product_id, p.name, f.name, + p_resolve.id, p_resolve.name, f.name, + pi_pc.received_date, st_pc.transfer_date, lt_pc.transfer_date, ast_pc.created_at, pc.chick_in_date, pi.received_date, st.transfer_date, lt.transfer_date, ast.created_at, + po_pc.po_number, st_pc.movement_number, lt_pc.transfer_number, ast_pc.id, pc.id, po.po_number, st.movement_number, lt.transfer_number, ast.id, - pi.price, p.product_price + pi_pc.price, pi.price, p_resolve.product_price, sa.stockable_type `) - query = r.joinSapronakProductFlag(query, "p") + query = r.joinSapronakProductFlag(query, "p_resolve") return scanAndGroupDetails(query) } diff --git a/internal/modules/closings/services/sapronak.service.go b/internal/modules/closings/services/sapronak.service.go index ba79db1d..7e7c69b2 100644 --- a/internal/modules/closings/services/sapronak.service.go +++ b/internal/modules/closings/services/sapronak.service.go @@ -470,6 +470,7 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj // should not be counted yet. Only when category is LAYING we allow // pullet usage to contribute to qty_used. isLaying := strings.EqualFold(string(pfk.ProjectFlock.Category), string(utils.ProjectFlockCategoryLaying)) + hasChickin := len(pfk.Chickins) > 0 if !isLaying { filteredUsage := make([]repository.SapronakUsageRow, 0, len(chickinUsageRows)) @@ -775,6 +776,9 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj if !matchesFlag(flag) { continue } + if hasChickin && (strings.EqualFold(flag, "DOC") || strings.EqualFold(flag, "PULLET") || strings.EqualFold(flag, "LAYER")) { + continue + } group := ensureGroup(flag) for _, d := range details { if d.Flag == "" { @@ -794,6 +798,10 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj if !matchesFlag(flag) { continue } + // For chicken, we don't count sales as sapronak outflow. + if strings.EqualFold(flag, "DOC") || strings.EqualFold(flag, "PULLET") || strings.EqualFold(flag, "LAYER") { + continue + } group := ensureGroup(flag) for _, d := range details { if d.Flag == "" { diff --git a/internal/modules/marketing/services/deliveryorder.service.go b/internal/modules/marketing/services/deliveryorder.service.go index bb6682c7..d3edf3b4 100644 --- a/internal/modules/marketing/services/deliveryorder.service.go +++ b/internal/modules/marketing/services/deliveryorder.service.go @@ -560,11 +560,17 @@ func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gor if deliveryProduct == nil || deliveryProduct.Id == 0 { return fiber.NewError(fiber.StatusInternalServerError, "Delivery product not found") } + if deliveryProduct.ProductWarehouseId == 0 { + return fiber.NewError(fiber.StatusInternalServerError, "Delivery product warehouse not found") + } + if deliveryProduct.ProductWarehouseId != marketingProduct.ProductWarehouseId { + return fiber.NewError(fiber.StatusBadRequest, "Delivery product warehouse mismatch with marketing product") + } result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ UsableKey: fifo.UsableKeyMarketingDelivery, UsableID: deliveryProduct.Id, - ProductWarehouseID: marketingProduct.ProductWarehouseId, + ProductWarehouseID: deliveryProduct.ProductWarehouseId, Quantity: requestedQty, AllowPending: false, Tx: tx, @@ -585,12 +591,12 @@ func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gor Decrease: result.UsageQuantity, LoggableType: string(utils.StockLogTypeMarketing), LoggableId: deliveryProduct.Id, - ProductWarehouseId: marketingProduct.ProductWarehouseId, + ProductWarehouseId: deliveryProduct.ProductWarehouseId, CreatedBy: actorID, Notes: fmt.Sprintf("FIFO consume (%.2f)", result.UsageQuantity), } - stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, marketingProduct.ProductWarehouseId, 1) + stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, deliveryProduct.ProductWarehouseId, 1) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") } diff --git a/internal/modules/production/project_flocks/dto/projectflock.dto.go b/internal/modules/production/project_flocks/dto/projectflock.dto.go index e7240b49..2701134c 100644 --- a/internal/modules/production/project_flocks/dto/projectflock.dto.go +++ b/internal/modules/production/project_flocks/dto/projectflock.dto.go @@ -42,6 +42,7 @@ type KandangWithProjectFlockIdDTO struct { kandangDTO.KandangRelationDTO ProjectFlockKandangId uint `json:"project_flock_kandang_id"` Period int `json:"period"` + ClosedAt *time.Time `json:"closed_at,omitempty"` } type ProjectFlockDetailDTO struct { @@ -74,20 +75,28 @@ func ToProjectFlockListDTOWithPeriod(e entity.ProjectFlock, period int) ProjectF for i, kandang := range e.Kandangs { var ( - pfkId uint - period int + pfkId uint + period int + closedAt *time.Time ) for _, kh := range e.KandangHistory { if kh.KandangId == kandang.Id { pfkId = kh.Id period = kh.Period + closedAt = kh.ClosedAt break } } + mapped := kandangDTO.ToKandangRelationDTO(kandang) + if closedAt != nil { + // Jangan ubah tabel kandang, hanya override status di response. + mapped.Status = string(utils.KandangStatusNonActive) + } kandangSummaries[i] = KandangWithProjectFlockIdDTO{ - KandangRelationDTO: kandangDTO.ToKandangRelationDTO(kandang), + KandangRelationDTO: mapped, ProjectFlockKandangId: pfkId, Period: period, + ClosedAt: closedAt, } } } From b73f13ee7688aacff9608b47c87787fd00b55d8c Mon Sep 17 00:00:00 2001 From: giovanni Date: Mon, 23 Feb 2026 11:46:31 +0700 Subject: [PATCH 14/18] add query param filter has laying --- .../locations/controllers/location.controller.go | 9 +++++---- .../master/locations/services/location.service.go | 11 +++++++++++ .../locations/validations/location.validation.go | 9 +++++---- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/internal/modules/master/locations/controllers/location.controller.go b/internal/modules/master/locations/controllers/location.controller.go index f360a9c9..7a3bb5ff 100644 --- a/internal/modules/master/locations/controllers/location.controller.go +++ b/internal/modules/master/locations/controllers/location.controller.go @@ -24,10 +24,11 @@ func NewLocationController(locationService service.LocationService) *LocationCon func (u *LocationController) GetAll(c *fiber.Ctx) error { query := &validation.Query{ - Page: c.QueryInt("page", 1), - Limit: c.QueryInt("limit", 10), - Search: c.Query("search", ""), - AreaId: c.QueryInt("area_id", 0), + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + AreaId: c.QueryInt("area_id", 0), + HasLaying: c.QueryBool("has_laying", false), } if query.Page < 1 || query.Limit < 1 { diff --git a/internal/modules/master/locations/services/location.service.go b/internal/modules/master/locations/services/location.service.go index 03f6cf45..8aa01dbf 100644 --- a/internal/modules/master/locations/services/location.service.go +++ b/internal/modules/master/locations/services/location.service.go @@ -60,6 +60,17 @@ func (s locationService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit if params.AreaId != 0 { db = db.Where("area_id = ?", params.AreaId) } + if params.HasLaying { + db = db.Where(` + EXISTS ( + SELECT 1 + FROM project_flocks pf + WHERE pf.location_id = locations.id + AND pf.category = ? + AND pf.deleted_at IS NULL + ) + `, utils.ProjectFlockCategoryLaying) + } return db.Order("created_at DESC").Order("updated_at DESC") }) diff --git a/internal/modules/master/locations/validations/location.validation.go b/internal/modules/master/locations/validations/location.validation.go index a2ac6175..fbdc1572 100644 --- a/internal/modules/master/locations/validations/location.validation.go +++ b/internal/modules/master/locations/validations/location.validation.go @@ -13,8 +13,9 @@ type Update struct { } type Query struct { - Page int `query:"page" validate:"omitempty,number,min=1"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=500"` - Search string `query:"search" validate:"omitempty,max=50"` - AreaId int `query:"area_id" validate:"omitempty,number,gt=0"` + Page int `query:"page" validate:"omitempty,number,min=1"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=500"` + Search string `query:"search" validate:"omitempty,max=50"` + AreaId int `query:"area_id" validate:"omitempty,number,gt=0"` + HasLaying bool `query:"has_laying"` } From a3334c6bb0a41d7466eda7a64fed10fb564df8d7 Mon Sep 17 00:00:00 2001 From: ragilap Date: Mon, 23 Feb 2026 11:56:53 +0700 Subject: [PATCH 15/18] [FEAT/BE] fixing approve status unclose --- .../services/project_flock_kandang.service.go | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go index a2d276cd..4374ba25 100644 --- a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go +++ b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go @@ -583,7 +583,7 @@ func (s projectFlockKandangService) Closing(c *fiber.Ctx, id uint, req *validati } } if s.ApprovalSvc != nil { - reopenAction := entity.ApprovalActionUpdated + reopenAction := entity.ApprovalActionApproved // Hindari duplikasi jika approval terakhir sudah Disetujui + Updated latestPFK, lerr := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowProjectFlockKandang, id, nil) if lerr != nil { @@ -609,6 +609,31 @@ func (s projectFlockKandangService) Closing(c *fiber.Ctx, id uint, req *validati return nil, aerr } } + + // Pastikan approval project flock kembali ke Aktif + latestPF, lerr := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowProjectFlock, pfk.ProjectFlockId, nil) + if lerr != nil { + return nil, lerr + } + shouldCreatePF := true + if latestPF != nil && + latestPF.StepNumber == uint16(utils.ProjectFlockStepAktif) && + latestPF.Action != nil && *latestPF.Action == reopenAction { + shouldCreatePF = false + } + if shouldCreatePF { + if _, aerr := s.ApprovalSvc.CreateApproval( + c.Context(), + utils.ApprovalWorkflowProjectFlock, + pfk.ProjectFlockId, + utils.ProjectFlockStepAktif, + &reopenAction, + actorID, + nil, + ); aerr != nil && !errors.Is(aerr, gorm.ErrDuplicatedKey) { + return nil, aerr + } + } } default: return nil, fiber.NewError(fiber.StatusBadRequest, "action harus close atau unclose") From f6f4cc5a10cefd25cddfccc03dcfa54f5c7174ad Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 24 Feb 2026 15:16:09 +0700 Subject: [PATCH 16/18] [FEAT/BE] resolve jwks --- internal/config/config.go | 2 + internal/middleware/auth.go | 72 ++++++++-- .../modules/sso/controllers/sso.controller.go | 57 +++++++- internal/modules/sso/verifier/verifier.go | 128 +++++++++++++++++- 4 files changed, 240 insertions(+), 19 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index af723b3b..0c09ee33 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -57,6 +57,7 @@ var ( SSOPortalURL string SSOClients map[string]SSOClientConfig SSOAccessCookieName string + SSOAccessCookieFallback []string SSORefreshCookieName string SSOCookieDomain string SSOCookieSecure bool @@ -141,6 +142,7 @@ func init() { SSOGetMeURL = viper.GetString("SSO_GETME_URL") SSOPortalURL = strings.TrimSpace(viper.GetString("SSO_PORTAL_URL")) SSOAccessCookieName = defaultString(viper.GetString("SSO_ACCESS_COOKIE_NAME"), "sso_access") + SSOAccessCookieFallback = parseList("SSO_ACCESS_COOKIE_FALLBACK") SSORefreshCookieName = defaultString(viper.GetString("SSO_REFRESH_COOKIE_NAME"), "sso_refresh") SSOCookieDomain = viper.GetString("SSO_COOKIE_DOMAIN") SSOCookieSecure = viper.GetBool("SSO_COOKIE_SECURE") diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index b7229382..e7640e7b 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -19,11 +19,11 @@ const ( // AuthContext keeps authentication details captured by the middleware. type AuthContext struct { - Token string - Verification *sso.VerificationResult - User *entity.User - Roles []sso.Role - Permissions map[string]struct{} + Token string + Verification *sso.VerificationResult + User *entity.User + Roles []sso.Role + Permissions map[string]struct{} UserAreaIDs []uint UserLocationIDs []uint UserAllArea bool @@ -36,8 +36,30 @@ type AuthContext struct { func Auth(userService service.UserService, requiredScopes ...string) fiber.Handler { return func(c *fiber.Ctx) error { token := bearerToken(c) - if token == "" { - token = strings.TrimSpace(c.Cookies(config.SSOAccessCookieName)) + tokenSource := "" + if token != "" { + tokenSource = "header" + } else { + primaryName := strings.TrimSpace(config.SSOAccessCookieName) + if primaryName != "" { + token = strings.TrimSpace(c.Cookies(primaryName)) + if token != "" { + tokenSource = "cookie:" + primaryName + } + } + if token == "" { + for _, name := range config.SSOAccessCookieFallback { + name = strings.TrimSpace(name) + if name == "" || name == primaryName { + continue + } + token = strings.TrimSpace(c.Cookies(name)) + if token != "" { + tokenSource = "cookie:" + name + break + } + } + } } if token == "" { return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") @@ -45,7 +67,11 @@ func Auth(userService service.UserService, requiredScopes ...string) fiber.Handl verification, err := sso.VerifyAccessToken(token) if err != nil { - utils.Log.WithError(err).Warn("auth: token verification failed") + if sso.IsSignatureError(err) { + logSignatureError("auth", tokenSource, token, err) + } else { + utils.Log.WithError(err).Warn("auth: token verification failed") + } return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") } @@ -89,11 +115,11 @@ func Auth(userService service.UserService, requiredScopes ...string) fiber.Handl } ctx := &AuthContext{ - Token: token, - Verification: verification, - User: user, - Roles: roles, - Permissions: permissions, + Token: token, + Verification: verification, + User: user, + Roles: roles, + Permissions: permissions, UserAreaIDs: nil, UserLocationIDs: nil, UserAllArea: false, @@ -216,6 +242,26 @@ func hasAllScopes(have, required []string) bool { return true } +func logSignatureError(ctxLabel, tokenSource, token string, err error) { + info := sso.ExtractTokenInfo(token) + aud := strings.Join(info.Aud, ",") + utils.Log.Errorf( + "access token verification failed: %v | ctx=%s source=%s iss=%s kid=%s aud=%s sub=%s exp=%d iat=%d nbf=%d expected_iss=%s expected_aud=%v", + err, + ctxLabel, + tokenSource, + info.Iss, + info.Kid, + aud, + info.Sub, + info.Exp, + info.Iat, + info.Nbf, + config.SSOIssuer, + config.SSOAllowedAudiences, + ) +} + // RequirePermissions ensures the authenticated user possesses all specified permissions. func RequirePermissions(perms ...string) fiber.Handler { required := canonicalPermissions(perms) diff --git a/internal/modules/sso/controllers/sso.controller.go b/internal/modules/sso/controllers/sso.controller.go index 5e75d4a9..41ece390 100644 --- a/internal/modules/sso/controllers/sso.controller.go +++ b/internal/modules/sso/controllers/sso.controller.go @@ -196,7 +196,11 @@ func (h *Controller) Refresh(c *fiber.Ctx) error { verification, err := sso.VerifyAccessToken(tokenResp.AccessToken) if err != nil { - utils.Log.Errorf("access token verification failed: %v", err) + if sso.IsSignatureError(err) { + logSignatureError("sso refresh", "sso_token", tokenResp.AccessToken, err) + } else { + utils.Log.Errorf("access token verification failed: %v", err) + } return fiber.NewError(fiber.StatusUnauthorized, "invalid access token") } @@ -304,7 +308,11 @@ func (h *Controller) Callback(c *fiber.Ctx) error { verification, err := sso.VerifyAccessToken(tokenResp.AccessToken) if err != nil { - utils.Log.Errorf("access token verification failed: %v", err) + if sso.IsSignatureError(err) { + logSignatureError("sso callback", "sso_token", tokenResp.AccessToken, err) + } else { + utils.Log.Errorf("access token verification failed: %v", err) + } return fiber.NewError(fiber.StatusUnauthorized, "invalid access token") } @@ -337,6 +345,22 @@ func (h *Controller) UserInfo(c *fiber.Ctx) error { token := strings.TrimSpace(c.Cookies(accessName)) tokenFromCookie := token != "" + usedCookieName := accessName + + if !tokenFromCookie { + for _, name := range config.SSOAccessCookieFallback { + name = strings.TrimSpace(name) + if name == "" || name == accessName { + continue + } + token = strings.TrimSpace(c.Cookies(name)) + if token != "" { + tokenFromCookie = true + usedCookieName = name + break + } + } + } if !tokenFromCookie { authHeader := strings.TrimSpace(c.Get("Authorization")) @@ -363,7 +387,11 @@ func (h *Controller) UserInfo(c *fiber.Ctx) error { } if _, err := sso.VerifyAccessToken(token); err != nil { - utils.Log.WithError(err).Warn("access token verification failed for userinfo") + if sso.IsSignatureError(err) { + logSignatureError("sso userinfo", "request", token, err) + } else { + utils.Log.WithError(err).Warn("access token verification failed for userinfo") + } return fiber.NewError(fiber.StatusUnauthorized, "unauthenticated") } @@ -382,7 +410,7 @@ func (h *Controller) UserInfo(c *fiber.Ctx) error { // SSO /auth/get-me expects the access cookie; add Authorization as well for compatibility. req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) if tokenFromCookie { - req.Header.Set("Cookie", fmt.Sprintf("%s=%s", accessName, token)) + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", usedCookieName, token)) } resp, err := h.httpClient.Do(req) @@ -836,6 +864,27 @@ func resolveSSOCookieName(configuredName, fallback string) string { return strings.TrimSpace(fallback) } +func logSignatureError(ctxLabel, tokenSource, token string, err error) { + info := sso.ExtractTokenInfo(token) + aud := strings.Join(info.Aud, ",") + utils.Log.Errorf( + "access token verification failed: %v | ctx=%s source=%s iss=%s kid=%s aud=%s sub=%s exp=%d iat=%d nbf=%d expected_iss=%s expected_aud=%v jwks=%s", + err, + ctxLabel, + tokenSource, + info.Iss, + info.Kid, + aud, + info.Sub, + info.Exp, + info.Iat, + info.Nbf, + config.SSOIssuer, + config.SSOAllowedAudiences, + config.SSOJWKSURL, + ) +} + func normalizeClientParam(raw string) string { value := strings.TrimSpace(raw) if value == "" { diff --git a/internal/modules/sso/verifier/verifier.go b/internal/modules/sso/verifier/verifier.go index 0c8d97e8..7d7cefbb 100644 --- a/internal/modules/sso/verifier/verifier.go +++ b/internal/modules/sso/verifier/verifier.go @@ -2,9 +2,11 @@ package sso import ( "context" + "encoding/json" "errors" "fmt" "net/http" + "os" "strconv" "strings" "sync" @@ -41,6 +43,16 @@ type VerificationResult struct { Claims *AccessTokenClaims } +type TokenInfo struct { + Kid string + Iss string + Aud []string + Sub string + Exp int64 + Iat int64 + Nbf int64 +} + var ( globalMu sync.RWMutex globalV *verifier @@ -106,10 +118,19 @@ func VerifyAccessToken(token string) (*VerificationResult, error) { jwt.WithIssuedAt(), jwt.WithExpirationRequired(), ) - + tok, err := parser.ParseWithClaims(token, claims, v.jwks.Keyfunc) if err != nil { - return nil, fmt.Errorf("parse token: %w", err) + if shouldRefreshOnVerifyError(err) { + if refreshErr := v.jwks.Refresh(context.Background(), keyfunc.RefreshOptions{IgnoreRateLimit: true}); refreshErr != nil { + utils.Log.WithError(refreshErr).Warn("sso jwks refresh after signature error failed") + } else { + tok, err = parser.ParseWithClaims(token, claims, v.jwks.Keyfunc) + } + } + if err != nil { + return nil, fmt.Errorf("parse token: %w", err) + } } if !tok.Valid { return nil, errors.New("invalid token") @@ -158,3 +179,106 @@ func VerifyAccessToken(token string) (*VerificationResult, error) { return result, nil } + +func shouldRefreshOnVerifyError(err error) bool { + if !IsSignatureError(err) { + return false + } + return !disableRefreshOnSignatureError() +} + +func IsSignatureError(err error) bool { + if err == nil { + return false + } + msg := err.Error() + return strings.Contains(msg, "verification error") || strings.Contains(msg, "token signature is invalid") +} + +func disableRefreshOnSignatureError() bool { + val := strings.TrimSpace(os.Getenv("SSO_DISABLE_JWKS_REFRESH_ON_SIG_ERROR")) + if val == "" { + return false + } + return val == "1" || strings.EqualFold(val, "true") || strings.EqualFold(val, "yes") +} + +func ExtractTokenInfo(token string) TokenInfo { + token = strings.TrimSpace(token) + if token == "" { + return TokenInfo{} + } + + claims := jwt.MapClaims{} + parser := jwt.NewParser() + tok, _, err := parser.ParseUnverified(token, claims) + if err != nil { + return TokenInfo{} + } + + info := TokenInfo{} + if kid, ok := tok.Header["kid"].(string); ok { + info.Kid = kid + } + if iss, ok := claims["iss"].(string); ok { + info.Iss = iss + } + if sub, ok := claims["sub"].(string); ok { + info.Sub = sub + } + if aud, ok := claims["aud"]; ok { + info.Aud = toStringSlice(aud) + } + info.Exp = toInt64(claims["exp"]) + info.Iat = toInt64(claims["iat"]) + info.Nbf = toInt64(claims["nbf"]) + return info +} + +func toStringSlice(v any) []string { + switch t := v.(type) { + case string: + if t == "" { + return nil + } + return []string{t} + case []string: + out := make([]string, 0, len(t)) + for _, s := range t { + if s != "" { + out = append(out, s) + } + } + return out + case []any: + out := make([]string, 0, len(t)) + for _, item := range t { + if s, ok := item.(string); ok && s != "" { + out = append(out, s) + } + } + return out + default: + return nil + } +} + +func toInt64(v any) int64 { + switch t := v.(type) { + case int64: + return t + case int: + return int64(t) + case float64: + return int64(t) + case json.Number: + if n, err := t.Int64(); err == nil { + return n + } + case string: + if n, err := strconv.ParseInt(t, 10, 64); err == nil { + return n + } + } + return 0 +} From 88f1381f4b4d1905f0b3b8300d713c11556149ff Mon Sep 17 00:00:00 2001 From: ragilap Date: Wed, 25 Feb 2026 16:35:43 +0700 Subject: [PATCH 17/18] [FEAT/BE] fixing overhead,sapronak,perhitungan sapronak --- .../closings/dto/closingOverhead.dto.go | 54 +-- .../repositories/closing.repository.go | 165 +++++++-- .../closings/services/closing.service.go | 327 ++++++++++++++++-- .../closings/services/sapronak.service.go | 54 ++- .../expense_realization.repository.go | 8 +- 5 files changed, 468 insertions(+), 140 deletions(-) diff --git a/internal/modules/closings/dto/closingOverhead.dto.go b/internal/modules/closings/dto/closingOverhead.dto.go index 4730474a..f29d6ef5 100644 --- a/internal/modules/closings/dto/closingOverhead.dto.go +++ b/internal/modules/closings/dto/closingOverhead.dto.go @@ -1,8 +1,6 @@ package dto import ( - "encoding/json" - entity "gitlab.com/mbugroup/lti-api.git/internal/entities" ) @@ -71,7 +69,7 @@ func ToOverheadDTO(budget *entity.ProjectBudget, realization *entity.ExpenseReal return dto } -func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.ExpenseRealization, totalChickinQty, totalActualPopulation float64, isPerKandang bool, totalKandangCount int, projectFlockKandangCountMap map[uint]int) OverheadListDTO { +func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.ExpenseRealization, totalChickinQty, totalActualPopulation float64, isPerKandang bool, totalKandangCount int) OverheadListDTO { overheadsByNonstockID := make(map[uint]*OverheadDTO) latestDateByNonstockID := make(map[uint]string) @@ -113,35 +111,6 @@ func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.Ex qty := realizations[i].Qty totalAmount := calculateTotal(realizations[i].Qty, realizations[i].Price) - // Farm-level expense division - if realizations[i].ExpenseNonstock.Expense != nil && - realizations[i].ExpenseNonstock.Expense.ProjectFlockId != nil { - projectFlockIDs := parseProjectFlockIDsFromJSON(*realizations[i].ExpenseNonstock.Expense.ProjectFlockId) - - if len(projectFlockIDs) > 0 { - totalKandangInAllProjects := 0 - for _, pfID := range projectFlockIDs { - if count, exists := projectFlockKandangCountMap[pfID]; exists { - totalKandangInAllProjects += count - } - } - - if totalKandangInAllProjects > 0 { - if isPerKandang { - qty = qty / float64(totalKandangInAllProjects) - totalAmount = totalAmount / float64(totalKandangInAllProjects) - } else { - // Overhead ALL: divide by total kandang then multiply by this project's kandang count - perKandangAmount := totalAmount / float64(totalKandangInAllProjects) - perKandangQty := qty / float64(totalKandangInAllProjects) - - qty = perKandangQty * float64(totalKandangCount) - totalAmount = perKandangAmount * float64(totalKandangCount) - } - } - } - } - overheadsByNonstockID[nonstockID].ActualQuantity += qty overheadsByNonstockID[nonstockID].ActualTotalAmount += totalAmount @@ -191,27 +160,6 @@ func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.Ex } } -func parseProjectFlockIDsFromJSON(projectFlockJSON string) []uint { - if projectFlockJSON == "" { - return []uint{} - } - - var projectFlocks []uint - if err := json.Unmarshal([]byte(projectFlockJSON), &projectFlocks); err != nil { - return []uint{} - } - - return projectFlocks -} - -func countProjectFlocksInJSON(projectFlockJSON string) int { - projectFlocks := parseProjectFlockIDsFromJSON(projectFlockJSON) - if len(projectFlocks) == 0 { - return 1 - } - return len(projectFlocks) -} - func getItemInfo(nonstock *entity.Nonstock) (string, string) { if nonstock != nil && nonstock.Id != 0 { return nonstock.Name, nonstock.Uom.Name diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index a796d513..12aec564 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -25,17 +25,17 @@ type ClosingRepository interface { SumMarketingWeightAndQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, float64, float64, error) SumRecordingEggQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, error) GetExpeditionHPP(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]ExpeditionHPPRow, error) - FetchSapronakIncoming(ctx context.Context, kandangID uint) ([]SapronakIncomingRow, error) - FetchSapronakIncomingDetails(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, error) - FetchSapronakUsage(ctx context.Context, pfkID uint) ([]SapronakUsageRow, error) - FetchSapronakUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error) - FetchSapronakChickinUsage(ctx context.Context, pfkID uint) ([]SapronakUsageRow, error) - FetchSapronakChickinUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error) - FetchSapronakUsageAllocatedDetails(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error) - FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) - FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) - FetchSapronakSales(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error) - FetchSapronakSalesAllocatedDetails(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error) + FetchSapronakIncoming(ctx context.Context, kandangID uint, start, end *time.Time) ([]SapronakIncomingRow, error) + FetchSapronakIncomingDetails(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) + FetchSapronakUsage(ctx context.Context, pfkID uint, start, end *time.Time) ([]SapronakUsageRow, error) + FetchSapronakUsageDetails(ctx context.Context, pfkID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) + FetchSapronakChickinUsage(ctx context.Context, pfkID uint, start, end *time.Time) ([]SapronakUsageRow, error) + FetchSapronakChickinUsageDetails(ctx context.Context, pfkID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) + FetchSapronakUsageAllocatedDetails(ctx context.Context, projectFlockKandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) + FetchSapronakAdjustments(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) + FetchSapronakTransfers(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) + FetchSapronakSales(ctx context.Context, projectFlockKandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) + FetchSapronakSalesAllocatedDetails(ctx context.Context, projectFlockKandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error) } @@ -86,6 +86,8 @@ type SapronakQueryParams struct { Limit int Offset int Search string + StartDate *time.Time + EndDate *time.Time } func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params SapronakQueryParams) ([]SapronakRow, int64, error) { @@ -142,15 +144,33 @@ func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params Sapronak } var totalResults int64 - countSQL := fmt.Sprintf("SELECT COUNT(*) FROM (%s) AS combined%s", unionSQL, searchClause) - countArgs := append(append([]any{}, args...), searchArgs...) + dateClause := "" + var dateArgs []any + if params.StartDate != nil { + dateClause += " AND sort_date::date >= ?" + dateArgs = append(dateArgs, params.StartDate) + } + if params.EndDate != nil { + dateClause += " AND sort_date::date <= ?" + dateArgs = append(dateArgs, params.EndDate) + } + whereClause := searchClause + if dateClause != "" { + if whereClause == "" { + whereClause = " WHERE " + strings.TrimPrefix(dateClause, " AND ") + } else { + whereClause += dateClause + } + } + countSQL := fmt.Sprintf("SELECT COUNT(*) FROM (%s) AS combined%s", unionSQL, whereClause) + countArgs := append(append(append([]any{}, args...), searchArgs...), dateArgs...) if err := db.Raw(countSQL, countArgs...).Scan(&totalResults).Error; err != nil { return nil, 0, err } - dataArgs := append(append([]any{}, args...), searchArgs...) + dataArgs := append(append(append([]any{}, args...), searchArgs...), dateArgs...) dataArgs = append(dataArgs, params.Limit, params.Offset) - dataSQL := fmt.Sprintf("SELECT * FROM (%s) AS combined%s ORDER BY sort_date ASC, id ASC LIMIT ? OFFSET ?", unionSQL, searchClause) + dataSQL := fmt.Sprintf("SELECT * FROM (%s) AS combined%s ORDER BY sort_date ASC, id ASC LIMIT ? OFFSET ?", unionSQL, whereClause) var rows []SapronakRow if err := db.Raw(dataSQL, dataArgs...).Scan(&rows).Error; err != nil { @@ -213,6 +233,25 @@ func (r *ClosingRepositoryImpl) GetSapronakSummary(ctx context.Context, params S searchArgs = append(searchArgs, like, like, like, like, like, like, like, like, like) } + dateClause := "" + var dateArgs []any + if params.StartDate != nil { + dateClause += " AND sort_date::date >= ?" + dateArgs = append(dateArgs, params.StartDate) + } + if params.EndDate != nil { + dateClause += " AND sort_date::date <= ?" + dateArgs = append(dateArgs, params.EndDate) + } + whereClause := searchClause + if dateClause != "" { + if whereClause == "" { + whereClause = " WHERE " + strings.TrimPrefix(dateClause, " AND ") + } else { + whereClause += dateClause + } + } + querySQL := fmt.Sprintf(` SELECT product_category AS category, @@ -222,8 +261,8 @@ SELECT FROM (%s) AS combined%s GROUP BY product_category, unit_id, unit ORDER BY product_category ASC, unit ASC -`, unionSQL, searchClause) - queryArgs := append(append([]any{}, args...), searchArgs...) +`, unionSQL, whereClause) + queryArgs := append(append(append([]any{}, args...), searchArgs...), dateArgs...) var rows []SapronakSummaryRow if err := db.Raw(querySQL, queryArgs...).Scan(&rows).Error; err != nil { @@ -778,6 +817,16 @@ type SapronakDetailRow struct { func (r *ClosingRepositoryImpl) withCtx(ctx context.Context) *gorm.DB { return r.DB().WithContext(ctx) } +func applyDateRange(db *gorm.DB, column string, start, end *time.Time) *gorm.DB { + if start != nil { + db = db.Where(column+"::date >= ?", start) + } + if end != nil { + db = db.Where(column+"::date <= ?", end) + } + return db +} + func applyJoins(db *gorm.DB, joins ...string) *gorm.DB { for _, j := range joins { if strings.TrimSpace(j) != "" { @@ -878,6 +927,14 @@ func (r *ClosingRepositoryImpl) fetchSapronakUsage( return rows, nil } +func scanUsage(db *gorm.DB) ([]SapronakUsageRow, error) { + rows := make([]SapronakUsageRow, 0) + if err := db.Group("pw.product_id, p.name, f.name, p.product_price").Scan(&rows).Error; err != nil { + return nil, err + } + return rows, nil +} + func (r *ClosingRepositoryImpl) detailQuery( ctx context.Context, table string, @@ -909,11 +966,11 @@ func (r *ClosingRepositoryImpl) fetchSapronakDetails( return scanAndGroupDetails(r.detailQuery(ctx, table, pwJoinCond, joins, selectSQL, where, args...)) } -func (r *ClosingRepositoryImpl) FetchSapronakUsage(ctx context.Context, pfkID uint) ([]SapronakUsageRow, error) { +func (r *ClosingRepositoryImpl) FetchSapronakUsage(ctx context.Context, pfkID uint, start, end *time.Time) ([]SapronakUsageRow, error) { if pfkID == 0 { return nil, nil } - return r.fetchSapronakUsage( + db := r.usageQuery( ctx, "recording_stocks rs", "pw.id = rs.product_warehouse_id", @@ -922,13 +979,15 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsage(ctx context.Context, pfkID ui pfkID, sapronakFlagsUsage, ) + db = applyDateRange(db, "r.record_datetime", start, end) + return scanUsage(db) } -func (r *ClosingRepositoryImpl) FetchSapronakChickinUsage(ctx context.Context, pfkID uint) ([]SapronakUsageRow, error) { +func (r *ClosingRepositoryImpl) FetchSapronakChickinUsage(ctx context.Context, pfkID uint, start, end *time.Time) ([]SapronakUsageRow, error) { if pfkID == 0 { return []SapronakUsageRow{}, nil } - return r.fetchSapronakUsage( + db := r.usageQuery( ctx, "project_chickins pc", "pw.id = pc.product_warehouse_id", @@ -937,10 +996,12 @@ func (r *ClosingRepositoryImpl) FetchSapronakChickinUsage(ctx context.Context, p pfkID, sapronakFlagsChickin, ) + db = applyDateRange(db, "pc.chick_in_date", start, end) + return scanUsage(db) } -func (r *ClosingRepositoryImpl) FetchSapronakUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error) { - return r.fetchSapronakDetails( +func (r *ClosingRepositoryImpl) FetchSapronakUsageDetails(ctx context.Context, pfkID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) { + db := r.detailQuery( ctx, "recording_stocks rs", "pw.id = rs.product_warehouse_id", @@ -959,10 +1020,12 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageDetails(ctx context.Context, p pfkID, sapronakFlagsUsage, ) + db = applyDateRange(db, "r.record_datetime", start, end) + return scanAndGroupDetails(db) } -func (r *ClosingRepositoryImpl) FetchSapronakChickinUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error) { - return r.fetchSapronakDetails( +func (r *ClosingRepositoryImpl) FetchSapronakChickinUsageDetails(ctx context.Context, pfkID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) { + db := r.detailQuery( ctx, "project_chickins pc", "pw.id = pc.product_warehouse_id", @@ -981,13 +1044,16 @@ func (r *ClosingRepositoryImpl) FetchSapronakChickinUsageDetails(ctx context.Con pfkID, sapronakFlagsChickin, ) + db = applyDateRange(db, "pc.chick_in_date", start, end) + return scanAndGroupDetails(db) } -func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error) { +func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.Context, projectFlockKandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) { if projectFlockKandangID == 0 { return map[uint][]SapronakDetailRow{}, nil } + dateExpr := "COALESCE(pi.received_date, st.transfer_date, lt.transfer_date, ast.created_at, pc.chick_in_date, r.record_datetime)" query := r.withCtx(ctx). Table("stock_allocations AS sa"). Select(` @@ -1049,11 +1115,12 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C po.po_number, st.movement_number, lt.transfer_number, ast.id, pc.id, r.id, pi.price, p_resolve.product_price `) + query = applyDateRange(query, dateExpr, start, end) return scanAndGroupDetails(query) } -func (r *ClosingRepositoryImpl) incomingPurchaseBase(ctx context.Context, kandangID uint) *gorm.DB { +func (r *ClosingRepositoryImpl) incomingPurchaseBase(ctx context.Context, kandangID uint, start, end *time.Time) *gorm.DB { db := r.withCtx(ctx). Table("purchase_items AS pi"). Joins("JOIN purchases po ON po.id = pi.purchase_id AND po.deleted_at IS NULL"). @@ -1062,12 +1129,13 @@ func (r *ClosingRepositoryImpl) incomingPurchaseBase(ctx context.Context, kandan Where("w.kandang_id = ?", kandangID). Where("f.name IN ?", sapronakFlagsAll). Where("pi.received_date IS NOT NULL") + db = applyDateRange(db, "pi.received_date", start, end) return r.joinSapronakProductFlag(db, "p") } -func (r *ClosingRepositoryImpl) FetchSapronakIncoming(ctx context.Context, kandangID uint) ([]SapronakIncomingRow, error) { +func (r *ClosingRepositoryImpl) FetchSapronakIncoming(ctx context.Context, kandangID uint, start, end *time.Time) ([]SapronakIncomingRow, error) { rows := make([]SapronakIncomingRow, 0) - db := r.incomingPurchaseBase(ctx, kandangID).Select(` + db := r.incomingPurchaseBase(ctx, kandangID, start, end).Select(` pi.product_id AS product_id, p.name AS product_name, f.name AS flag, @@ -1081,9 +1149,9 @@ func (r *ClosingRepositoryImpl) FetchSapronakIncoming(ctx context.Context, kanda return rows, nil } -func (r *ClosingRepositoryImpl) FetchSapronakIncomingDetails(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, error) { +func (r *ClosingRepositoryImpl) FetchSapronakIncomingDetails(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) { return scanAndGroupDetails( - r.incomingPurchaseBase(ctx, kandangID).Select(` + r.incomingPurchaseBase(ctx, kandangID, start, end).Select(` pi.product_id AS product_id, p.name AS product_name, f.name AS flag, @@ -1178,7 +1246,7 @@ func splitStockLogs(rows []stockLogSapronakRow, refFn func(stockLogSapronakRow) return in, out } -func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) { +func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) { poByWarehouse := r.DB(). Table("purchase_items pi"). Select("DISTINCT ON (pi.product_warehouse_id) pi.product_warehouse_id, po.po_number, pi.received_date"). @@ -1205,11 +1273,13 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka Where("f.name IN ?", sapronakFlagsAll). Where("COALESCE(ast.total_qty, 0) > 0") incomingQuery = r.joinSapronakProductFlag(incomingQuery, "p") + incomingQuery = applyDateRange(incomingQuery, "ast.created_at", start, end) incoming, err := scanAndGroupDetails(incomingQuery) if err != nil { return nil, nil, err } + dateExpr := "COALESCE(pi.received_date, st.transfer_date, lt.transfer_date, pfp_po.received_date, pc.chick_in_date, ast_in.created_at, ast.created_at)" outgoingQuery := r.withCtx(ctx). Table("stock_allocations AS sa"). Select(` @@ -1242,6 +1312,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka Where("f.name NOT IN ?", sapronakFlags(utils.FlagDOC, utils.FlagPullet)). Group("pw.product_id, p.name, f.name, pi.received_date, st.transfer_date, lt.transfer_date, pfp_po.received_date, pc.chick_in_date, ast_in.created_at, ast.created_at, po.po_number, st.movement_number, lt.transfer_number, pfp_po.po_number, pc.id, ast_in.id, ast.id, p.product_price") outgoingQuery = r.joinSapronakProductFlag(outgoingQuery, "p") + outgoingQuery = applyDateRange(outgoingQuery, dateExpr, start, end) outgoing, err := scanAndGroupDetails(outgoingQuery) if err != nil { return nil, nil, err @@ -1250,7 +1321,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka return incoming, outgoing, nil } -func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) { +func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) { incomingQuery := r.withCtx(ctx). Table("stock_transfer_details AS std"). Select(` @@ -1272,6 +1343,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand Where("(fw.kandang_id IS NULL OR fw.kandang_id <> w.kandang_id)"). Where("f.name IN ?", sapronakFlagsAll) incomingQuery = r.joinSapronakProductFlag(incomingQuery, "p") + incomingQuery = applyDateRange(incomingQuery, "st.transfer_date", start, end) incoming, err := scanAndGroupDetails(incomingQuery) if err != nil { return nil, nil, err @@ -1300,6 +1372,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand Where("(w_source.kandang_id IS NULL OR w_source.kandang_id <> w.kandang_id)"). Where("f.name IN ?", sapronakFlagsAll) incomingLayingQuery = r.joinSapronakProductFlag(incomingLayingQuery, "p") + incomingLayingQuery = applyDateRange(incomingLayingQuery, "lt.transfer_date", start, end) incomingLaying, err := scanAndGroupDetails(incomingLayingQuery) if err != nil { return nil, nil, err @@ -1333,6 +1406,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand Where("f.name IN ?", sapronakFlagsAll). Group("std.id, std.product_id, p.name, f.name, st.transfer_date, st.movement_number, p.product_price") outgoingQuery = r.joinSapronakProductFlag(outgoingQuery, "p") + outgoingQuery = applyDateRange(outgoingQuery, "st.transfer_date", start, end) outgoing, err := scanAndGroupDetails(outgoingQuery) if err != nil { return nil, nil, err @@ -1364,6 +1438,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand Where("f.name IN ?", sapronakFlagsAll). Group("lts.id, pw.product_id, p.name, f.name, lt.transfer_date, lt.transfer_number, p.product_price") outgoingLayingQuery = r.joinSapronakProductFlag(outgoingLayingQuery, "p") + outgoingLayingQuery = applyDateRange(outgoingLayingQuery, "lt.transfer_date", start, end) outgoingLaying, err := scanAndGroupDetails(outgoingLayingQuery) if err != nil { return nil, nil, err @@ -1375,7 +1450,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand return incoming, outgoing, nil } -func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error) { +func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectFlockKandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) { query := r.withCtx(ctx). Table("stock_allocations AS sa"). Select(` @@ -1399,6 +1474,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectF Group("mdp.id, pw.product_id, p.name, f.name, mdp.delivery_date, mdp.created_at, m.so_number, mdp.unit_price, mp.unit_price") query = r.joinSapronakProductFlag(query, "p") + query = applyDateRange(query, "COALESCE(mdp.delivery_date, mdp.created_at)", start, end) sales, err := scanAndGroupDetails(query) if err != nil { return nil, err @@ -1431,6 +1507,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectF Group("mdp.id, pw.product_id, p.name, f.name, mdp.delivery_date, mdp.created_at, m.so_number, mdp.unit_price, mp.unit_price") nonFifoQuery = r.joinSapronakProductFlag(nonFifoQuery, "p") + nonFifoQuery = applyDateRange(nonFifoQuery, "COALESCE(mdp.delivery_date, mdp.created_at)", start, end) nonFifoSales, err := scanAndGroupDetails(nonFifoQuery) if err != nil { return nil, err @@ -1443,12 +1520,29 @@ func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectF return sales, nil } -func (r *ClosingRepositoryImpl) FetchSapronakSalesAllocatedDetails(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error) { +func (r *ClosingRepositoryImpl) FetchSapronakSalesAllocatedDetails(ctx context.Context, projectFlockKandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) { if projectFlockKandangID == 0 { return map[uint][]SapronakDetailRow{}, nil } pfpType := fifo.StockableKeyProjectFlockPopulation.String() + dateExpr := fmt.Sprintf(` + CASE + WHEN sa.stockable_type = '%s' THEN COALESCE( + pi_pc.received_date, + st_pc.transfer_date, + lt_pc.transfer_date, + ast_pc.created_at, + pc.chick_in_date + ) + ELSE COALESCE( + pi.received_date, + st.transfer_date, + lt.transfer_date, + ast.created_at + ) + END + `, pfpType) query := r.withCtx(ctx). Table("stock_allocations AS sa"). @@ -1532,6 +1626,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakSalesAllocatedDetails(ctx context.C `) query = r.joinSapronakProductFlag(query, "p_resolve") + query = applyDateRange(query, dateExpr, start, end) return scanAndGroupDetails(query) } diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 71bfcdec..cd8ea5ac 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -2,7 +2,6 @@ package service import ( "context" - "encoding/json" "errors" "fmt" "math" @@ -33,6 +32,14 @@ import ( "gorm.io/gorm" ) +type activeKandangMetric struct { + ProjectFlockKandangID uint + ProjectFlockID uint + KandangID uint + Category string + Metric float64 +} + type ClosingService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]dto.ClosingListItemDTO, int64, error) GetProjectFlockByID(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, error) @@ -385,6 +392,11 @@ func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, pa } offset := (params.Page - 1) * params.Limit + startDate, endDate, err := s.getSapronakDateRange(c.Context(), projectFlockID, params.KandangID) + if err != nil { + s.Log.Errorf("Failed to resolve sapronak date range for project flock %d: %+v", projectFlockID, err) + return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve sapronak date range") + } rows, totalResults, err := s.Repository.GetSapronak(c.Context(), repository.SapronakQueryParams{ Type: params.Type, WarehouseIDs: warehouseIDs, @@ -392,6 +404,8 @@ func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, pa Limit: params.Limit, Offset: offset, Search: params.Search, + StartDate: startDate, + EndDate: endDate, }) if err != nil { s.Log.Errorf("Failed to fetch sapronak %s for project flock %d: %+v", params.Type, projectFlockID, err) @@ -468,11 +482,19 @@ func (s closingService) GetClosingSapronakSummary(c *fiber.Ctx, projectFlockID u } } + startDate, endDate, err := s.getSapronakDateRange(c.Context(), projectFlockID, params.KandangID) + if err != nil { + s.Log.Errorf("Failed to resolve sapronak date range for project flock %d: %+v", projectFlockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve sapronak date range") + } + rows, err := s.Repository.GetSapronakSummary(c.Context(), repository.SapronakQueryParams{ Type: params.Type, WarehouseIDs: warehouseIDs, ProjectFlockKandangIDs: projectFlockKandangIDs, Search: params.Search, + StartDate: startDate, + EndDate: endDate, }) if err != nil { s.Log.Errorf("Failed to fetch sapronak %s summary for project flock %d: %+v", params.Type, projectFlockID, err) @@ -542,6 +564,90 @@ func (s closingService) getProjectFlockKandangIDs(ctx context.Context, projectFl return ids, nil } +func (s closingService) getSapronakDateRange(ctx context.Context, projectFlockID uint, kandangID *uint) (*time.Time, *time.Time, error) { + db := s.Repository.DB().WithContext(ctx) + + if kandangID != nil && *kandangID > 0 { + var pfk entity.ProjectFlockKandang + if err := db.Select("id, created_at, closed_at").First(&pfk, *kandangID).Error; err != nil { + return nil, nil, err + } + + var minChickin *time.Time + if err := db.Table("project_chickins"). + Select("MIN(chick_in_date)"). + Where("project_flock_kandang_id = ?", pfk.Id). + Scan(&minChickin).Error; err != nil { + return nil, nil, err + } + + start := pfk.CreatedAt + if minChickin != nil && !minChickin.IsZero() { + start = *minChickin + } + startDate := dateOnlyUTC(start) + + var endDate *time.Time + if pfk.ClosedAt != nil { + d := dateOnlyUTC(*pfk.ClosedAt) + endDate = &d + } + + return &startDate, endDate, nil + } + + var minCreated time.Time + if err := db.Model(&entity.ProjectFlockKandang{}). + Select("MIN(created_at)"). + Where("project_flock_id = ?", projectFlockID). + Scan(&minCreated).Error; err != nil { + return nil, nil, err + } + + var minChickin *time.Time + if err := db.Table("project_chickins pc"). + Select("MIN(pc.chick_in_date)"). + Joins("JOIN project_flock_kandangs pfk ON pfk.id = pc.project_flock_kandang_id"). + Where("pfk.project_flock_id = ?", projectFlockID). + Scan(&minChickin).Error; err != nil { + return nil, nil, err + } + + start := minCreated + if minChickin != nil && !minChickin.IsZero() { + start = *minChickin + } + startDate := dateOnlyUTC(start) + + var endDate *time.Time + var openCount int64 + if err := db.Model(&entity.ProjectFlockKandang{}). + Where("project_flock_id = ? AND closed_at IS NULL", projectFlockID). + Count(&openCount).Error; err != nil { + return nil, nil, err + } + if openCount == 0 { + var maxClosed *time.Time + if err := db.Model(&entity.ProjectFlockKandang{}). + Select("MAX(closed_at)"). + Where("project_flock_id = ?", projectFlockID). + Scan(&maxClosed).Error; err != nil { + return nil, nil, err + } + if maxClosed != nil && !maxClosed.IsZero() { + d := dateOnlyUTC(*maxClosed) + endDate = &d + } + } + + return &startDate, endDate, nil +} + +func dateOnlyUTC(t time.Time) time.Time { + u := t.UTC() + return time.Date(u.Year(), u.Month(), u.Day(), 0, 0, 0, 0, time.UTC) +} + func formatQuantity(qty float64, uom string) string { qtyStr := strconv.FormatFloat(qty, 'f', -1, 64) if uom == "" { @@ -616,38 +722,17 @@ func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint, projectFl return nil, err } + realizations, err = s.allocateFarmOverheadRealizations(c.Context(), projectFlockID, projectFlockKandangID, realizations) + if err != nil { + return nil, err + } + projectFlockKandangs, err := s.ProjectFlockKandangRepo.GetByProjectFlockID(c.Context(), projectFlockID) if err != nil { return nil, err } totalKandangCount := len(projectFlockKandangs) - // Build kandang count map for farm expense division - projectFlockKandangCountMap := make(map[uint]int) - projectFlockKandangCountMap[projectFlockID] = totalKandangCount - - involvedProjectFlocks := make(map[uint]bool) - for _, realization := range realizations { - if realization.ExpenseNonstock != nil && - realization.ExpenseNonstock.Expense != nil && - realization.ExpenseNonstock.Expense.ProjectFlockId != nil { - var projectFlockIDs []uint - if err := json.Unmarshal([]byte(*realization.ExpenseNonstock.Expense.ProjectFlockId), &projectFlockIDs); err == nil { - for _, pfID := range projectFlockIDs { - if pfID != projectFlockID { - involvedProjectFlocks[pfID] = true - } - } - } - } - } - - for pfID := range involvedProjectFlocks { - if pfKandangs, err := s.ProjectFlockKandangRepo.GetByProjectFlockID(c.Context(), pfID); err == nil { - projectFlockKandangCountMap[pfID] = len(pfKandangs) - } - } - chickins, err := s.ChickinRepo.GetByProjectFlockID(c.Context(), projectFlockID) if err != nil { return nil, err @@ -688,11 +773,197 @@ func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint, projectFl totalActualPopulation := totalChickinQty - totalDepletion - result := dto.ToOverheadListDTOs(budgets, realizations, totalChickinQty, totalActualPopulation, projectFlockKandangID != nil, totalKandangCount, projectFlockKandangCountMap) + result := dto.ToOverheadListDTOs(budgets, realizations, totalChickinQty, totalActualPopulation, projectFlockKandangID != nil, totalKandangCount) return &result, nil } +type activeKandangMetricRow struct { + ProjectFlockKandangID uint `gorm:"column:project_flock_kandang_id"` + ProjectFlockID uint `gorm:"column:project_flock_id"` + KandangID uint `gorm:"column:kandang_id"` + Category string `gorm:"column:category"` + ChickinQty float64 `gorm:"column:chickin_qty"` + DepletionQty float64 `gorm:"column:depletion_qty"` + EggQty float64 `gorm:"column:egg_qty"` +} + +func (s closingService) getActiveKandangMetrics(ctx context.Context, locationID uint, transactionDate time.Time) ([]activeKandangMetric, error) { + db := s.Repository.DB().WithContext(ctx) + + rows := []activeKandangMetricRow{} + rawSQL := ` +SELECT + pfk.id AS project_flock_kandang_id, + pfk.project_flock_id AS project_flock_id, + pfk.kandang_id AS kandang_id, + pf.category AS category, + COALESCE(( + SELECT SUM(pc.usage_qty) + FROM project_chickins pc + WHERE pc.project_flock_kandang_id = pfk.id + AND pc.chick_in_date::date <= ? + ), 0) AS chickin_qty, + COALESCE(( + SELECT SUM(rd.qty) + FROM recording_depletions rd + JOIN recordings r ON r.id = rd.recording_id + WHERE r.project_flock_kandangs_id = pfk.id + AND r.record_datetime::date <= ? + ), 0) AS depletion_qty, + COALESCE(( + SELECT SUM(re.qty) + FROM recording_eggs re + JOIN recordings r2 ON r2.id = re.recording_id + WHERE r2.project_flock_kandangs_id = pfk.id + AND r2.record_datetime::date <= ? + ), 0) AS egg_qty +FROM project_flock_kandangs pfk +JOIN project_flocks pf ON pf.id = pfk.project_flock_id +WHERE pf.location_id = ? + AND (pfk.closed_at IS NULL OR pfk.closed_at::date > ?) + AND EXISTS ( + SELECT 1 + FROM project_chickins pc2 + WHERE pc2.project_flock_kandang_id = pfk.id + AND pc2.chick_in_date::date <= ? + ) +` + if err := db.Raw(rawSQL, transactionDate, transactionDate, transactionDate, locationID, transactionDate, transactionDate).Scan(&rows).Error; err != nil { + return nil, err + } + + result := make([]activeKandangMetric, 0, len(rows)) + for _, row := range rows { + metric := 0.0 + switch strings.ToLower(strings.TrimSpace(row.Category)) { + case "growing": + metric = row.ChickinQty + case "laying": + metric = row.EggQty + default: + s.Log.Warnf("Unknown project flock category for overhead allocation: %s (pfk=%d)", row.Category, row.ProjectFlockKandangID) + } + + result = append(result, activeKandangMetric{ + ProjectFlockKandangID: row.ProjectFlockKandangID, + ProjectFlockID: row.ProjectFlockID, + KandangID: row.KandangID, + Category: row.Category, + Metric: metric, + }) + } + + return result, nil +} + +func round2(value float64) float64 { + return math.Round(value*100) / 100 +} + +func allocateFarmLevelQty(totalQty float64, metrics []activeKandangMetric) map[uint]float64 { + allocations := make(map[uint]float64, len(metrics)) + if totalQty == 0 || len(metrics) == 0 { + return allocations + } + + totalMetric := 0.0 + var maxMetric float64 + var maxMetricID uint + for _, m := range metrics { + if m.Metric <= 0 { + continue + } + totalMetric += m.Metric + if m.Metric > maxMetric || maxMetricID == 0 { + maxMetric = m.Metric + maxMetricID = m.ProjectFlockKandangID + } + } + if totalMetric == 0 { + return allocations + } + + sumRounded := 0.0 + for _, m := range metrics { + if m.Metric <= 0 { + continue + } + portion := totalQty * (m.Metric / totalMetric) + rounded := round2(portion) + allocations[m.ProjectFlockKandangID] = rounded + sumRounded += rounded + } + + diff := totalQty - sumRounded + if maxMetricID != 0 && diff != 0 { + allocations[maxMetricID] = round2(allocations[maxMetricID] + diff) + } + + return allocations +} + +func (s closingService) allocateFarmOverheadRealizations(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint, realizations []entity.ExpenseRealization) ([]entity.ExpenseRealization, error) { + if len(realizations) == 0 { + return realizations, nil + } + + cache := make(map[string][]activeKandangMetric) + allocated := make([]entity.ExpenseRealization, 0, len(realizations)) + + for _, realization := range realizations { + expenseNonstock := realization.ExpenseNonstock + if expenseNonstock == nil || expenseNonstock.Expense == nil { + allocated = append(allocated, realization) + continue + } + + // If already bound to a specific project flock kandang, don't re-allocate. + if expenseNonstock.ProjectFlockKandangId != nil { + allocated = append(allocated, realization) + continue + } + + expense := expenseNonstock.Expense + locationID := uint(expense.LocationId) + txDate := expense.RealizationDate + + cacheKey := fmt.Sprintf("%d|%s", locationID, txDate.Format("2006-01-02")) + metrics, exists := cache[cacheKey] + if !exists { + var err error + metrics, err = s.getActiveKandangMetrics(ctx, locationID, txDate) + if err != nil { + return nil, err + } + cache[cacheKey] = metrics + } + + allocations := allocateFarmLevelQty(realization.Qty, metrics) + allocatedQty := 0.0 + if projectFlockKandangID != nil { + allocatedQty = allocations[*projectFlockKandangID] + } else { + for _, m := range metrics { + if m.ProjectFlockID == projectFlockID { + allocatedQty += allocations[m.ProjectFlockKandangID] + } + } + allocatedQty = round2(allocatedQty) + } + + adj := realization + adj.Qty = allocatedQty + if adj.Qty == 0 { + adj.Price = realization.Price + } + + allocated = append(allocated, adj) + } + + return allocated, nil +} + func (s closingService) GetExpeditionHPP(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.ExpeditionHPPDTO, error) { if projectFlockKandangID != nil { if err := m.EnsureProjectFlockKandangAccess(c, s.Repository.DB(), projectFlockID, *projectFlockKandangID); err != nil { diff --git a/internal/modules/closings/services/sapronak.service.go b/internal/modules/closings/services/sapronak.service.go index 7e7c69b2..460b139a 100644 --- a/internal/modules/closings/services/sapronak.service.go +++ b/internal/modules/closings/services/sapronak.service.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strings" + "time" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -123,7 +124,7 @@ func (s sapronakService) computeSapronakReports(ctx context.Context, params *val continue } - // We no longer filter by date for closing sapronak report; pass nil pointers. + // Filter sapronak data by project flock period range. items, groups, totalIncoming, totalUsage, err := s.buildSapronakItems(ctx, pfk, params.Flag) if err != nil { s.Log.Errorf("Failed to build sapronak items for pfk %d: %+v", pfk.Id, err) @@ -379,33 +380,33 @@ func buildSapronakDetails( } func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.ProjectFlockKandang, flagFilter string) ([]dto.SapronakItemDTO, []dto.SapronakGroupDTO, float64, float64, error) { - // For sapronak closing report we intentionally ignore date range - // and aggregate all historical transactions for the kandang/project. - incomingRows, err := s.Repository.FetchSapronakIncoming(ctx, pfk.KandangId) + // Filter by project flock period (start = first chickin or pfk created_at, end = closed_at if any). + startDate, endDate := sapronakPeriodRange(pfk) + incomingRows, err := s.Repository.FetchSapronakIncoming(ctx, pfk.KandangId, startDate, endDate) if err != nil { return nil, nil, 0, 0, err } - incomingDetailsRows, err := s.Repository.FetchSapronakIncomingDetails(ctx, pfk.KandangId) + incomingDetailsRows, err := s.Repository.FetchSapronakIncomingDetails(ctx, pfk.KandangId, startDate, endDate) if err != nil { return nil, nil, 0, 0, err } - usageRows, err := s.Repository.FetchSapronakUsage(ctx, pfk.Id) + usageRows, err := s.Repository.FetchSapronakUsage(ctx, pfk.Id, startDate, endDate) if err != nil { return nil, nil, 0, 0, err } - chickinUsageRows, err := s.Repository.FetchSapronakChickinUsage(ctx, pfk.Id) + chickinUsageRows, err := s.Repository.FetchSapronakChickinUsage(ctx, pfk.Id, startDate, endDate) if err != nil { return nil, nil, 0, 0, err } - usageDetailsRows, err := s.Repository.FetchSapronakUsageDetails(ctx, pfk.Id) + usageDetailsRows, err := s.Repository.FetchSapronakUsageDetails(ctx, pfk.Id, startDate, endDate) if err != nil { return nil, nil, 0, 0, err } - chickinUsageDetailsRows, err := s.Repository.FetchSapronakChickinUsageDetails(ctx, pfk.Id) + chickinUsageDetailsRows, err := s.Repository.FetchSapronakChickinUsageDetails(ctx, pfk.Id, startDate, endDate) if err != nil { return nil, nil, 0, 0, err } - usageAllocatedDetails, err := s.Repository.FetchSapronakUsageAllocatedDetails(ctx, pfk.Id) + usageAllocatedDetails, err := s.Repository.FetchSapronakUsageAllocatedDetails(ctx, pfk.Id, startDate, endDate) if err != nil { return nil, nil, 0, 0, err } @@ -413,15 +414,15 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj usageDetailsRows = usageAllocatedDetails chickinUsageDetailsRows = map[uint][]repository.SapronakDetailRow{} } - adjIncomingRows, adjOutgoingRows, err := s.Repository.FetchSapronakAdjustments(ctx, pfk.KandangId) + adjIncomingRows, adjOutgoingRows, err := s.Repository.FetchSapronakAdjustments(ctx, pfk.KandangId, startDate, endDate) if err != nil { return nil, nil, 0, 0, err } - transIncomingRows, transOutgoingRows, err := s.Repository.FetchSapronakTransfers(ctx, pfk.KandangId) + transIncomingRows, transOutgoingRows, err := s.Repository.FetchSapronakTransfers(ctx, pfk.KandangId, startDate, endDate) if err != nil { return nil, nil, 0, 0, err } - salesOutRows, err := s.Repository.FetchSapronakSalesAllocatedDetails(ctx, pfk.Id) + salesOutRows, err := s.Repository.FetchSapronakSalesAllocatedDetails(ctx, pfk.Id, startDate, endDate) if err != nil { return nil, nil, 0, 0, err } @@ -492,11 +493,6 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj chickinUsageDetailsRows = filteredDetail } - allUsageRows := append(usageRows, chickinUsageRows...) - incoming, usage := mapIncomingUsage(incomingRows, allUsageRows) - itemMap := make(map[uint]dto.SapronakItemDTO, len(incoming)+len(usage)) - groupMap := make(map[string]*dto.SapronakGroupDTO) - for pid, rows := range chickinUsageDetailsRows { if len(rows) == 0 { continue @@ -513,6 +509,11 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj transOutgoing := detailMaps.TransferOut salesOutgoing := detailMaps.SalesOut + allUsageRows := append(usageRows, chickinUsageRows...) + incoming, usage := mapIncomingUsage(incomingRows, allUsageRows) + itemMap := make(map[uint]dto.SapronakItemDTO, len(incoming)+len(usage)) + groupMap := make(map[string]*dto.SapronakGroupDTO) + transIncoming = dedupTransfers(transIncoming) transOutgoing = dedupTransfers(transOutgoing) @@ -823,3 +824,20 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj return items, groups, totalIncoming, totalUsage, nil } + +func sapronakPeriodRange(pfk entity.ProjectFlockKandang) (*time.Time, *time.Time) { + if len(pfk.Chickins) == 0 { + start := dateOnlyUTC(pfk.CreatedAt) + return &start, pfk.ClosedAt + } + + minDate := pfk.Chickins[0].ChickInDate + for _, c := range pfk.Chickins[1:] { + if c.ChickInDate.Before(minDate) { + minDate = c.ChickInDate + } + } + + start := dateOnlyUTC(minDate) + return &start, pfk.ClosedAt +} diff --git a/internal/modules/expenses/repositories/expense_realization.repository.go b/internal/modules/expenses/repositories/expense_realization.repository.go index 68890c9a..d41c5dd7 100644 --- a/internal/modules/expenses/repositories/expense_realization.repository.go +++ b/internal/modules/expenses/repositories/expense_realization.repository.go @@ -69,23 +69,19 @@ func (r *ExpenseRealizationRepositoryImpl) GetClosingOverhead(ctx context.Contex Joins("JOIN expense_nonstocks ON expense_nonstocks.id = expense_realizations.expense_nonstock_id"). Joins("JOIN expenses ON expenses.id = expense_nonstocks.expense_id"). Joins("LEFT JOIN project_flock_kandangs ON project_flock_kandangs.id = expense_nonstocks.project_flock_kandang_id"). - Joins("LEFT JOIN kandangs ON kandangs.id = expense_nonstocks.kandang_id"). Where("expenses.realization_date IS NOT NULL"). Where("expenses.category = ?", "BOP") if projectFlockKandangID != nil { db = db.Where(`( expense_nonstocks.project_flock_kandang_id = ? OR - (expense_nonstocks.kandang_id = (SELECT kandang_id FROM project_flock_kandangs WHERE id = ?) AND - expense_nonstocks.project_flock_kandang_id IS NULL) OR (expenses.project_flock_id IS NOT NULL AND expenses.project_flock_id::jsonb @> ?::jsonb) - )`, *projectFlockKandangID, *projectFlockKandangID, fmt.Sprintf("[%d]", projectFlockID)) + )`, *projectFlockKandangID, fmt.Sprintf("[%d]", projectFlockID)) } else { db = db.Where(`( project_flock_kandangs.project_flock_id = ? OR - kandangs.id IN (SELECT kandang_id FROM project_flock_kandangs WHERE project_flock_id = ?) OR (expenses.project_flock_id IS NOT NULL AND expenses.project_flock_id::jsonb @> ?::jsonb) - )`, projectFlockID, projectFlockID, fmt.Sprintf("[%d]", projectFlockID)) + )`, projectFlockID, fmt.Sprintf("[%d]", projectFlockID)) } err := db.Find(&realizations).Error From daca97f113d630ebcf2916abf0717d6df9d921ac Mon Sep 17 00:00:00 2001 From: ragilap Date: Thu, 26 Feb 2026 11:17:13 +0700 Subject: [PATCH 18/18] [FEAT/BE] fix return backend without payload logout --- .../modules/sso/controllers/sso.controller.go | 305 +----------------- 1 file changed, 10 insertions(+), 295 deletions(-) diff --git a/internal/modules/sso/controllers/sso.controller.go b/internal/modules/sso/controllers/sso.controller.go index 41ece390..d35bf78e 100644 --- a/internal/modules/sso/controllers/sso.controller.go +++ b/internal/modules/sso/controllers/sso.controller.go @@ -428,13 +428,6 @@ func (h *Controller) UserInfo(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadGateway, "invalid user profile response") } - // if sanitized, perms, ok := sanitizeUserInfoPayload(body); ok { - // if caps := capabilities.FromPermissions(perms); len(caps) > 0 { - // injectCapabilities(sanitized, caps) - // } - // return c.Status(resp.StatusCode).JSON(sanitized) - // } - if ct := resp.Header.Get("Content-Type"); ct != "" { c.Set("Content-Type", ct) } else { @@ -446,17 +439,9 @@ func (h *Controller) UserInfo(c *fiber.Ctx) error { // Logout clears SSO cookies and removes any leftover PKCE session state. func (h *Controller) Logout(c *fiber.Ctx) error { - requestedAlias := normalizeClientParam(c.Query("client")) - if requestedAlias == "" { - requestedAlias = normalizeClientParam(c.Query("client_id")) - } - var ( - alias string - cfg config.SSOClientConfig - hasClientInfo bool - ) - if requestedAlias != "" { - alias, cfg, hasClientInfo = findSSOClientConfig(requestedAlias) + alias := "" + if singleAlias, _, ok := singleSSOClient(); ok { + alias = singleAlias } accessName := resolveSSOCookieName(config.SSOAccessCookieName, "access") @@ -473,14 +458,7 @@ func (h *Controller) Logout(c *fiber.Ctx) error { hadAccessCookie := accessToken != "" hadRefreshCookie := refreshToken != "" - state := strings.TrimSpace(c.Query("state")) - if state != "" { - if err := h.store.Delete(c.Context(), state); err != nil { - utils.Log.Warnf("failed to delete pkce session during logout: %v", err) - } - } - - if !hadAccessCookie && !hadRefreshCookie && state == "" { + if !hadAccessCookie && !hadRefreshCookie { return fiber.NewError(fiber.StatusUnauthorized, "not authenticated") } @@ -505,52 +483,20 @@ func (h *Controller) Logout(c *fiber.Ctx) error { clearSSOCookie(c, refreshName) redirectTarget := "" - rawReturn := strings.TrimSpace(c.Query("return_to")) - if hasClientInfo { - if rawReturn == "" { - rawReturn = cfg.DefaultReturnURI - } - if normalized, err := normalizeReturnTarget(rawReturn, cfg); err == nil { - redirectTarget = normalized - } else if rawReturn != "" { - utils.Log.WithError(err).Warn("invalid return_to during logout") - } - } else if rawReturn == "" && config.SSOPortalURL != "" { - if alias, singleCfg, ok := singleClientFromToken(verification); ok { - if normalized, err := normalizeReturnTarget(singleCfg.DefaultReturnURI, singleCfg); err == nil && normalized != "" { - redirectTarget = normalized - alias, cfg, hasClientInfo = alias, singleCfg, true - } else { - redirectTarget = config.SSOPortalURL - } - } else if accessToken != "" { - if alias, singleCfg, ok := h.singleClientFromSSO(c.Context(), accessToken); ok { - if normalized, err := normalizeReturnTarget(singleCfg.DefaultReturnURI, singleCfg); err == nil && normalized != "" { - redirectTarget = normalized - alias, cfg, hasClientInfo = alias, singleCfg, true - } else { - redirectTarget = config.SSOPortalURL - } - } else { - redirectTarget = config.SSOPortalURL - } - } else { - redirectTarget = config.SSOPortalURL - } - } else if rawReturn != "" { - if strings.HasPrefix(rawReturn, "/") && !strings.HasPrefix(rawReturn, "//") { - redirectTarget = rawReturn - } + if config.SSOPortalURL != "" { + redirectTarget = config.SSOPortalURL } utils.Log.WithFields(logrus.Fields{ "client": alias, - "state": state, "redirect": redirectTarget, }).Info("sso logout completed") if redirectTarget != "" { - return c.Redirect(redirectTarget, fiber.StatusFound) + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "status": "signed out", + "redirect": redirectTarget, + }) } return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "signed out"}) @@ -569,145 +515,6 @@ func singleSSOClient() (string, config.SSOClientConfig, bool) { return "", config.SSOClientConfig{}, false } -func singleClientFromToken(verification *sso.VerificationResult) (string, config.SSOClientConfig, bool) { - if verification == nil || verification.Claims == nil { - return "", config.SSOClientConfig{}, false - } - return singleClientFromScopes(verification.Claims.Scopes()) -} - -func (h *Controller) singleClientFromSSO(ctx context.Context, accessToken string) (string, config.SSOClientConfig, bool) { - accessToken = strings.TrimSpace(accessToken) - if accessToken == "" { - return "", config.SSOClientConfig{}, false - } - meURL := strings.TrimSpace(config.SSOGetMeURL) - if meURL == "" { - return "", config.SSOClientConfig{}, false - } - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, meURL, nil) - if err != nil { - utils.Log.WithError(err).Warn("failed to build SSO getme request") - return "", config.SSOClientConfig{}, false - } - req.Header.Set("Authorization", "Bearer "+accessToken) - - resp, err := h.httpClient.Do(req) - if err != nil { - utils.Log.WithError(err).Warn("SSO getme request failed") - return "", config.SSOClientConfig{}, false - } - defer resp.Body.Close() - - if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { - utils.Log.WithField("status", resp.StatusCode).Warn("SSO getme responded with error") - return "", config.SSOClientConfig{}, false - } - - var payload struct { - Data struct { - Roles []struct { - Client *struct { - Alias string `json:"alias"` - } `json:"client"` - } `json:"roles"` - } `json:"data"` - } - if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { - utils.Log.WithError(err).Warn("failed to decode SSO getme response") - return "", config.SSOClientConfig{}, false - } - - aliases := make(map[string]struct{}) - for _, role := range payload.Data.Roles { - if role.Client == nil { - continue - } - alias := strings.ToLower(strings.TrimSpace(role.Client.Alias)) - if alias != "" { - aliases[alias] = struct{}{} - } - } - if len(aliases) != 1 { - return "", config.SSOClientConfig{}, false - } - for alias := range aliases { - if normalized, cfg, ok := findClientAlias(alias); ok { - return normalized, cfg, true - } - return "", config.SSOClientConfig{}, false - } - return "", config.SSOClientConfig{}, false -} - -func singleClientFromScopes(scopes []string) (string, config.SSOClientConfig, bool) { - if len(scopes) == 0 { - return "", config.SSOClientConfig{}, false - } - seen := make(map[string]struct{}) - for _, scope := range scopes { - if alias, ok := matchClientAliasFromScope(scope); ok { - seen[alias] = struct{}{} - } - if len(seen) > 1 { - return "", config.SSOClientConfig{}, false - } - } - if len(seen) != 1 { - return "", config.SSOClientConfig{}, false - } - for alias := range seen { - if normalized, cfg, ok := findClientAlias(alias); ok { - return normalized, cfg, true - } - } - return "", config.SSOClientConfig{}, false -} - -func matchClientAliasFromScope(scope string) (string, bool) { - scope = strings.ToLower(strings.TrimSpace(scope)) - if scope == "" { - return "", false - } - prefix := scope - if idx := strings.IndexAny(prefix, ".:"); idx > 0 { - prefix = prefix[:idx] - } - if prefix == "" { - return "", false - } - if alias, _, ok := findClientAlias(prefix); ok { - return alias, true - } - if prefix == "user-management" { - if alias, _, ok := findClientAlias("umgmt"); ok { - return alias, true - } - } - if prefix == "umgmt" { - if alias, _, ok := findClientAlias("user-management"); ok { - return alias, true - } - } - return "", false -} - -func findClientAlias(alias string) (string, config.SSOClientConfig, bool) { - alias = strings.TrimSpace(alias) - if alias == "" { - return "", config.SSOClientConfig{}, false - } - if cfg, ok := config.SSOClients[alias]; ok && strings.TrimSpace(cfg.PublicID) != "" { - return alias, cfg, true - } - for key, cfg := range config.SSOClients { - if strings.EqualFold(key, alias) && strings.TrimSpace(cfg.PublicID) != "" { - return key, cfg, true - } - } - return "", config.SSOClientConfig{}, false -} func defaultSSOClientAlias() string { for alias := range config.SSOClients { @@ -897,98 +704,6 @@ func normalizeClientParam(raw string) string { return strings.ToLower(value) } -func sanitizeUserInfoPayload(body []byte) (map[string]any, []string, bool) { - if len(body) == 0 { - return map[string]any{}, nil, true - } - - var payload any - if err := json.Unmarshal(body, &payload); err != nil { - return nil, nil, false - } - - perms := collectPermissionNames(payload) - - sensitive := map[string]struct{}{ - "roles": {}, - "permissions": {}, - } - payload = scrubSensitiveKeys(payload, sensitive) - - sanitized, ok := payload.(map[string]any) - if !ok { - sanitized = map[string]any{"data": payload} - } - - return sanitized, perms, true -} - -func scrubSensitiveKeys(value any, sensitive map[string]struct{}) any { - switch v := value.(type) { - case map[string]any: - for key, val := range v { - if _, ok := sensitive[strings.ToLower(key)]; ok { - delete(v, key) - continue - } - v[key] = scrubSensitiveKeys(val, sensitive) - } - return v - case []any: - for i, item := range v { - v[i] = scrubSensitiveKeys(item, sensitive) - } - return v - default: - return value - } -} - -func collectPermissionNames(value any) []string { - names := make(map[string]struct{}) - collectPermissionRec(value, names) - out := make([]string, 0, len(names)) - for name := range names { - out = append(out, name) - } - return out -} - -func collectPermissionRec(value any, acc map[string]struct{}) { - switch v := value.(type) { - case map[string]any: - for key, val := range v { - if strings.EqualFold(key, "permissions") { - if arr, ok := val.([]any); ok { - for _, item := range arr { - if perm, ok := item.(map[string]any); ok { - if name, ok := perm["name"].(string); ok && strings.TrimSpace(name) != "" { - acc[strings.ToLower(strings.TrimSpace(name))] = struct{}{} - } - } - } - } - } else { - collectPermissionRec(val, acc) - } - } - case []any: - for _, item := range v { - collectPermissionRec(item, acc) - } - } -} - -func injectCapabilities(payload map[string]any, caps map[string]bool) { - if len(caps) == 0 { - return - } - if data, ok := payload["data"].(map[string]any); ok { - data["capabilities"] = caps - return - } - payload["capabilities"] = caps -} func findSSOClientConfig(requestedAlias string) (string, config.SSOClientConfig, bool) { if requestedAlias == "" {