From 272367d8efd8e82a78f2f6d12796f657aa6df853 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Sun, 11 Jan 2026 12:51:37 +0700 Subject: [PATCH 01/18] FIX[BE]: fixing transfer to laying and implement correct fifo stock --- ...laying_transfers_to_detail_tables.down.sql | 79 +++ ...m_laying_transfers_to_detail_tables.up.sql | 73 +++ internal/entities/laying_transfer.go | 13 - internal/entities/laying_transfer_source.go | 3 +- internal/entities/laying_transfer_target.go | 3 +- .../project_flock_population_repository.go | 6 +- .../controllers/transfer_laying.controller.go | 4 +- .../dto/transfer_laying.dto.go | 8 +- .../production/transfer_layings/module.go | 43 +- .../services/transfer_laying.service.go | 495 ++++++++---------- internal/utils/fifo/constants.go | 20 +- 11 files changed, 403 insertions(+), 344 deletions(-) create mode 100644 internal/database/migrations/20260109145555_move_fifo_fields_from_laying_transfers_to_detail_tables.down.sql create mode 100644 internal/database/migrations/20260109145555_move_fifo_fields_from_laying_transfers_to_detail_tables.up.sql diff --git a/internal/database/migrations/20260109145555_move_fifo_fields_from_laying_transfers_to_detail_tables.down.sql b/internal/database/migrations/20260109145555_move_fifo_fields_from_laying_transfers_to_detail_tables.down.sql new file mode 100644 index 00000000..a74e2882 --- /dev/null +++ b/internal/database/migrations/20260109145555_move_fifo_fields_from_laying_transfers_to_detail_tables.down.sql @@ -0,0 +1,79 @@ +-- Rollback: Revert FIFO fields back to laying_transfers from detail tables + +-- ============================================================================ +-- PART 1: Remove FIFO columns from detail tables +-- ============================================================================ + +-- Add back old qty column first +ALTER TABLE laying_transfer_sources + ADD COLUMN IF NOT EXISTS qty NUMERIC(15, 3) NOT NULL DEFAULT 0; + +ALTER TABLE laying_transfer_targets + ADD COLUMN IF NOT EXISTS qty NUMERIC(15, 3) NOT NULL DEFAULT 0; + +-- Now drop FIFO columns +ALTER TABLE laying_transfer_sources + DROP COLUMN IF EXISTS usage_qty, + DROP COLUMN IF EXISTS pending_usage_qty; + +ALTER TABLE laying_transfer_targets + DROP COLUMN IF EXISTS total_qty, + DROP COLUMN IF EXISTS total_used; + +-- ============================================================================ +-- PART 2: Add back FIFO columns to laying_transfers table +-- ============================================================================ + +-- Add columns back for USABLE role (source warehouse) +ALTER TABLE laying_transfers + ADD COLUMN product_warehouse_id BIGINT, + ADD COLUMN pending_usage_qty NUMERIC(15, 3), + ADD COLUMN usage_qty NUMERIC(15, 3); + +-- Add columns back for STOCKABLE role (destination warehouse) +ALTER TABLE laying_transfers + ADD COLUMN dest_product_warehouse_id BIGINT, + ADD COLUMN total_qty NUMERIC(15, 3) DEFAULT 0 NOT NULL, + ADD COLUMN total_used NUMERIC(15, 3) DEFAULT 0 NOT NULL; + +-- ============================================================================ +-- PART 3: Recreate foreign key constraints +-- ============================================================================ + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'product_warehouses') THEN + -- Add source product warehouse FK + ALTER TABLE laying_transfers + ADD CONSTRAINT fk_laying_transfers_product_warehouse_id + FOREIGN KEY (product_warehouse_id) + REFERENCES product_warehouses(id) + ON DELETE SET NULL; + + -- Add destination product warehouse FK + ALTER TABLE laying_transfers + ADD CONSTRAINT fk_laying_transfers_dest_product_warehouse_id + FOREIGN KEY (dest_product_warehouse_id) + REFERENCES product_warehouses(id) + ON DELETE SET NULL; + END IF; +END $$; + +-- ============================================================================ +-- PART 4: Recreate indexes for performance +-- ============================================================================ + +CREATE INDEX idx_laying_transfers_product_warehouse_id + ON laying_transfers(product_warehouse_id); + +CREATE INDEX idx_laying_transfers_dest_product_warehouse_id + ON laying_transfers(dest_product_warehouse_id); + +-- ============================================================================ +-- PART 5: Recreate comments for documentation +-- ============================================================================ + +COMMENT ON COLUMN laying_transfers.product_warehouse_id IS 'Product warehouse at source (Growing flock) - for USABLE role'; +COMMENT ON COLUMN laying_transfers.dest_product_warehouse_id IS 'Product warehouse at destination (Laying flock) - for STOCKABLE role'; +COMMENT ON COLUMN laying_transfers.total_qty IS 'Total lot quantity introduced to destination warehouse - for STOCKABLE role'; +COMMENT ON COLUMN laying_transfers.total_used IS 'Quantity already consumed from this lot at destination - for FIFO STOCKABLE role'; diff --git a/internal/database/migrations/20260109145555_move_fifo_fields_from_laying_transfers_to_detail_tables.up.sql b/internal/database/migrations/20260109145555_move_fifo_fields_from_laying_transfers_to_detail_tables.up.sql new file mode 100644 index 00000000..19c710d9 --- /dev/null +++ b/internal/database/migrations/20260109145555_move_fifo_fields_from_laying_transfers_to_detail_tables.up.sql @@ -0,0 +1,73 @@ +-- Move FIFO fields from laying_transfers to detail tables (sources & targets) +-- This enables proper FIFO integration for transfer laying with multiple sources and targets + +-- ============================================================================ +-- PART 1: Remove FIFO-related columns from laying_transfers table +-- ============================================================================ + +-- Drop foreign key constraints first +DO $$ +BEGIN + -- Drop source product warehouse FK + IF EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_laying_transfers_product_warehouse_id' + ) THEN + ALTER TABLE laying_transfers + DROP CONSTRAINT fk_laying_transfers_product_warehouse_id; + END IF; + + -- Drop destination product warehouse FK + IF EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_laying_transfers_dest_product_warehouse_id' + ) THEN + ALTER TABLE laying_transfers + DROP CONSTRAINT fk_laying_transfers_dest_product_warehouse_id; + END IF; +END $$; + +-- Drop indexes +DROP INDEX IF EXISTS idx_laying_transfers_product_warehouse_id; +DROP INDEX IF EXISTS idx_laying_transfers_dest_product_warehouse_id; + +-- Remove columns from laying_transfers +ALTER TABLE laying_transfers + DROP COLUMN IF EXISTS product_warehouse_id, + DROP COLUMN IF EXISTS dest_product_warehouse_id, + DROP COLUMN IF EXISTS pending_usage_qty, + DROP COLUMN IF EXISTS usage_qty, + DROP COLUMN IF EXISTS total_qty, + DROP COLUMN IF EXISTS total_used; + +-- ============================================================================ +-- PART 2: Add FIFO columns to laying_transfer_sources (USABLE role) +-- ============================================================================ + +ALTER TABLE laying_transfer_sources + ADD COLUMN usage_qty NUMERIC(15, 3) DEFAULT 0 NOT NULL, + ADD COLUMN pending_usage_qty NUMERIC(15, 3) DEFAULT 0 NOT NULL; + +-- Add comments for documentation +COMMENT ON COLUMN laying_transfer_sources.usage_qty IS 'Quantity consumed from this source - for FIFO USABLE role'; +COMMENT ON COLUMN laying_transfer_sources.pending_usage_qty IS 'Quantity pending to consume from this source - for FIFO USABLE role'; + +-- Drop old qty column as it's replaced by usage_qty +ALTER TABLE laying_transfer_sources + DROP COLUMN IF EXISTS qty; + +-- ============================================================================ +-- PART 3: Add FIFO columns to laying_transfer_targets (STOCKABLE role) +-- ============================================================================ + +ALTER TABLE laying_transfer_targets + ADD COLUMN total_qty NUMERIC(15, 3) DEFAULT 0 NOT NULL, + ADD COLUMN total_used NUMERIC(15, 3) DEFAULT 0 NOT NULL; + +-- Add comments for documentation +COMMENT ON COLUMN laying_transfer_targets.total_qty IS 'Total lot quantity introduced to this target warehouse - for FIFO STOCKABLE role'; +COMMENT ON COLUMN laying_transfer_targets.total_used IS 'Quantity already consumed from this lot at target warehouse - for FIFO STOCKABLE role'; + +-- Drop old qty column as it's replaced by total_qty +ALTER TABLE laying_transfer_targets + DROP COLUMN IF EXISTS qty; diff --git a/internal/entities/laying_transfer.go b/internal/entities/laying_transfer.go index 97a7df12..f983519f 100644 --- a/internal/entities/laying_transfer.go +++ b/internal/entities/laying_transfer.go @@ -12,17 +12,6 @@ type LayingTransfer struct { FromProjectFlockId uint `gorm:"not null"` ToProjectFlockId uint `gorm:"not null"` TransferDate time.Time `gorm:"type:date;not null"` - - - PendingUsageQty *float64 `gorm:"type:numeric(15,3)"` - UsageQty *float64 `gorm:"type:numeric(15,3)"` - ProductWarehouseId *uint `gorm:"type:bigint"` // Source PW (PULLET) - - - DestProductWarehouseID *uint `gorm:"column:dest_product_warehouse_id;type:bigint"` // Destination PW (LAYER) - TotalQty float64 `gorm:"column:total_qty;type:numeric(15,3);default:0"` // Total lot introduced to destination - TotalUsed float64 `gorm:"column:total_used;type:numeric(15,3);default:0"` // Already consumed from this lot - Notes string `gorm:"type:text"` CreatedBy uint `gorm:"not null"` CreatedAt time.Time `gorm:"autoCreateTime"` @@ -31,8 +20,6 @@ type LayingTransfer struct { FromProjectFlock *ProjectFlock `gorm:"foreignKey:FromProjectFlockId;references:Id"` ToProjectFlock *ProjectFlock `gorm:"foreignKey:ToProjectFlockId;references:Id"` - ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` // Source PW - DestProductWarehouse *ProductWarehouse `gorm:"foreignKey:DestProductWarehouseID;references:Id"` // Destination PW CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` Sources []LayingTransferSource `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"` Targets []LayingTransferTarget `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"` diff --git a/internal/entities/laying_transfer_source.go b/internal/entities/laying_transfer_source.go index 6b54bd84..e0b85774 100644 --- a/internal/entities/laying_transfer_source.go +++ b/internal/entities/laying_transfer_source.go @@ -11,7 +11,8 @@ type LayingTransferSource struct { LayingTransferId uint `gorm:"index;not null"` SourceProjectFlockKandangId uint `gorm:"not null"` ProductWarehouseId *uint `gorm:""` - Qty float64 `gorm:"type:numeric(15,3);not null"` + UsageQty float64 `gorm:"type:numeric(15,3);default:0;not null"` // FIFO USABLE field + PendingUsageQty float64 `gorm:"type:numeric(15,3);default:0;not null"` // FIFO USABLE field Note string `gorm:"type:text"` CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` diff --git a/internal/entities/laying_transfer_target.go b/internal/entities/laying_transfer_target.go index dec98f1f..560e09f7 100644 --- a/internal/entities/laying_transfer_target.go +++ b/internal/entities/laying_transfer_target.go @@ -10,7 +10,8 @@ type LayingTransferTarget struct { Id uint `gorm:"primaryKey"` LayingTransferId uint `gorm:"index;not null"` TargetProjectFlockKandangId uint `gorm:"not null"` - Qty float64 `gorm:"type:numeric(15,3);not null"` + TotalQty float64 `gorm:"type:numeric(15,3);default:0;not null"` // FIFO STOCKABLE field + TotalUsed float64 `gorm:"type:numeric(15,3);default:0;not null"` // FIFO STOCKABLE field ProductWarehouseId *uint `gorm:""` Note string `gorm:"type:text"` CreatedAt time.Time `gorm:"autoCreateTime"` diff --git a/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go b/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go index 04ae56e1..022da6a3 100644 --- a/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go +++ b/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go @@ -96,9 +96,9 @@ func (r *projectFlockPopulationRepositoryImpl) GetTotalQtyByProjectFlockKandangI var total float64 err := r.DB().WithContext(ctx). Table("project_flock_populations"). - Select("COALESCE(SUM(total_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). + Select("COALESCE(SUM(total_qty - total_used_qty), 0) AS available_qty"). + Joins("JOIN product_warehouses pw ON project_flock_populations.product_warehouse_id = pw.id"). + Where("pw.project_flock_kandang_id = ?", projectFlockKandangID). Scan(&total).Error if err != nil { return 0, err diff --git a/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go b/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go index c69f4ff5..d2ab6d0a 100644 --- a/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go +++ b/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go @@ -84,7 +84,7 @@ func (u *TransferLayingController) CreateOne(c *fiber.Ctx) error { req := new(validation.Create) if err := c.BodyParser(req); err != nil { - return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + return fiber.NewError(fiber.StatusBadRequest, "Format permintaan tidak valid") } result, err := u.TransferLayingService.CreateOne(c, req) @@ -96,7 +96,7 @@ func (u *TransferLayingController) CreateOne(c *fiber.Ctx) error { JSON(response.Success{ Code: fiber.StatusCreated, Status: "success", - Message: "Create transferLaying successfully", + Message: "Berhasil membuat transfer laying", Data: dto.ToTransferLayingListDTO(*result), }) } diff --git a/internal/modules/production/transfer_layings/dto/transfer_laying.dto.go b/internal/modules/production/transfer_layings/dto/transfer_laying.dto.go index aeb12e5e..e81d6cc5 100644 --- a/internal/modules/production/transfer_layings/dto/transfer_laying.dto.go +++ b/internal/modules/production/transfer_layings/dto/transfer_laying.dto.go @@ -67,8 +67,6 @@ type TransferLayingListDTO struct { TransferLayingRelationDTO FromProjectFlock *ProjectFlockSummaryDTO `json:"from_project_flock,omitempty"` ToProjectFlock *ProjectFlockSummaryDTO `json:"to_project_flock,omitempty"` - PendingUsageQty *float64 `json:"pending_usage_qty"` - UsageQty *float64 `json:"usage_qty"` CreatedBy uint `json:"created_by"` CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` CreatedAt time.Time `json:"created_at"` @@ -166,7 +164,7 @@ func ToProductWarehouseSummaryDTO(pw *entity.ProductWarehouse) *ProductWarehouse func ToLayingTransferSourceDTO(source entity.LayingTransferSource) LayingTransferSourceDTO { return LayingTransferSourceDTO{ SourceProjectFlockKandang: ToProjectFlockKandangSummaryDTO(source.SourceProjectFlockKandang), - Qty: source.Qty, + Qty: source.UsageQty, // Ambil dari UsageQty (FIFO consumed quantity) ProductWarehouse: ToProductWarehouseSummaryDTO(source.ProductWarehouse), Note: source.Note, } @@ -186,7 +184,7 @@ func ToLayingTransferSourceDTOs(sources []entity.LayingTransferSource) []LayingT func ToLayingTransferTargetDTO(target entity.LayingTransferTarget) LayingTransferTargetDTO { return LayingTransferTargetDTO{ TargetProjectFlockKandang: ToProjectFlockKandangSummaryDTO(target.TargetProjectFlockKandang), - Qty: target.Qty, + Qty: target.TotalQty, // Ambil dari TotalQty (FIFO replenished quantity) ProductWarehouse: ToProductWarehouseSummaryDTO(target.ProductWarehouse), Note: target.Note, } @@ -223,8 +221,6 @@ func ToTransferLayingListDTO(e entity.LayingTransfer) TransferLayingListDTO { TransferLayingRelationDTO: ToTransferLayingRelationDTO(e), FromProjectFlock: ToProjectFlockSummaryDTO(e.FromProjectFlock), ToProjectFlock: ToProjectFlockSummaryDTO(e.ToProjectFlock), - PendingUsageQty: e.PendingUsageQty, - UsageQty: e.UsageQty, CreatedBy: e.CreatedBy, CreatedUser: createdUser, CreatedAt: e.CreatedAt, diff --git a/internal/modules/production/transfer_layings/module.go b/internal/modules/production/transfer_layings/module.go index 381f2492..2068ccd7 100644 --- a/internal/modules/production/transfer_layings/module.go +++ b/internal/modules/production/transfer_layings/module.go @@ -36,30 +36,13 @@ func (TransferLayingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, val stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) - - if err := fifoService.RegisterUsable(fifo.UsableConfig{ - Key: fifo.UsableKeyTransferToLaying, - Table: "laying_transfers", - Columns: fifo.UsableColumns{ - ID: "id", - ProductWarehouseID: "product_warehouse_id", - UsageQuantity: "usage_qty", - PendingQuantity: "pending_usage_qty", - CreatedAt: "created_at", - }, - }); err != nil { - if !strings.Contains(strings.ToLower(err.Error()), "already registered") { - panic(fmt.Sprintf("failed to register transfer to laying usable workflow: %v", err)) - } - } - - + // daftarin jadi stockable if err := fifoService.RegisterStockable(fifo.StockableConfig{ - Key: fifo.StockableKeyTransferToLaying, - Table: "laying_transfers", + Key: fifo.StockableKeyTransferToLayingIn, + Table: "laying_transfer_targets", Columns: fifo.StockableColumns{ ID: "id", - ProductWarehouseID: "dest_product_warehouse_id", + ProductWarehouseID: "product_warehouse_id", TotalQuantity: "total_qty", TotalUsedQuantity: "total_used", CreatedAt: "created_at", @@ -71,6 +54,24 @@ func (TransferLayingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, val } } + // daftarin jadi usable + if err := fifoService.RegisterUsable(fifo.UsableConfig{ + Key: fifo.UsableKeyTransferToLayingOut, + Table: "laying_transfer_sources", + Columns: fifo.UsableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + UsageQuantity: "usage_qty", + PendingQuantity: "pending_usage_qty", + CreatedAt: "created_at", + }, + OrderBy: []string{"created_at ASC", "id ASC"}, + }); err != nil { + if !strings.Contains(strings.ToLower(err.Error()), "already registered") { + panic(fmt.Sprintf("failed to register transfer to laying usable workflow: %v", err)) + } + } + approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowTransferToLaying, utils.TransferToLayingApprovalSteps); err != nil { diff --git a/internal/modules/production/transfer_layings/services/transfer_laying.service.go b/internal/modules/production/transfer_layings/services/transfer_laying.service.go index 22c712e5..e62d4722 100644 --- a/internal/modules/production/transfer_layings/services/transfer_laying.service.go +++ b/internal/modules/production/transfer_layings/services/transfer_laying.service.go @@ -16,6 +16,7 @@ import ( ProjectFlockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/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" @@ -45,6 +46,7 @@ type transferLayingService struct { ProjectFlockPopulationRepo ProjectFlockRepository.ProjectFlockPopulationRepository ProductWarehouseRepo rInventory.ProductWarehouseRepository WarehouseRepo rWarehouse.WarehouseRepository + StockLogRepo rStockLogs.StockLogRepository ApprovalService commonSvc.ApprovalService FifoSvc commonSvc.FifoService } @@ -69,6 +71,7 @@ func NewTransferLayingService( ProjectFlockPopulationRepo: projectFlockPopulationRepo, ProductWarehouseRepo: productWarehouseRepo, WarehouseRepo: warehouseRepo, + StockLogRepo: rStockLogs.NewStockLogRepository(repo.DB()), ApprovalService: approvalService, FifoSvc: fifoSvc, } @@ -164,55 +167,42 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) return nil, err } - if _, err := s.ProjectFlockRepo.GetByID(c.Context(), req.SourceProjectFlockId, nil); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Source Project Flock not found") - } - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate source project flock") + if err := commonSvc.EnsureRelations(c.Context(), + commonSvc.RelationCheck{Name: "Source Project Flock", ID: &req.SourceProjectFlockId, Exists: s.ProjectFlockRepo.IdExists}, + commonSvc.RelationCheck{Name: "Target Project Flock", ID: &req.TargetProjectFlockId, Exists: s.ProjectFlockRepo.IdExists}, + ); err != nil { + return nil, err } - if _, err := s.ProjectFlockRepo.GetByID(c.Context(), req.TargetProjectFlockId, nil); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Target Project Flock not found") - } - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate target project flock") + sourceKandangIDs := make([]uint, len(req.SourceKandangs)) + for i, detail := range req.SourceKandangs { + sourceKandangIDs[i] = detail.ProjectFlockKandangId } - for _, detail := range req.SourceKandangs { - if err := commonSvc.EnsureRelations(c.Context(), - commonSvc.RelationCheck{Name: "Source Project Flock Kandang", ID: &detail.ProjectFlockKandangId, Exists: s.ProjectFlockKandangRepo.IdExists}, - ); err != nil { - return nil, err - } - - pfk, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), detail.ProjectFlockKandangId) - if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get source project flock kandang") - } - if pfk.ProjectFlockId != req.SourceProjectFlockId { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Source kandang %d does not belong to source project flock %d", detail.ProjectFlockKandangId, req.SourceProjectFlockId)) - } + if err := s.validateKandangOwnership( + c.Context(), + req.SourceProjectFlockId, + sourceKandangIDs, + ); err != nil { + return nil, err } - for _, detail := range req.TargetKandangs { - if err := commonSvc.EnsureRelations(c.Context(), - commonSvc.RelationCheck{Name: "Target Project Flock Kandang", ID: &detail.ProjectFlockKandangId, Exists: s.ProjectFlockKandangRepo.IdExists}, - ); err != nil { - return nil, err - } + targetKandangIDs := make([]uint, len(req.TargetKandangs)) + for i, detail := range req.TargetKandangs { + targetKandangIDs[i] = detail.ProjectFlockKandangId + } - pfk, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), detail.ProjectFlockKandangId) - if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get target project flock kandang") - } - if pfk.ProjectFlockId != req.TargetProjectFlockId { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Target kandang %d does not belong to target project flock %d", detail.ProjectFlockKandangId, req.TargetProjectFlockId)) - } + if err := s.validateKandangOwnership( + c.Context(), + req.TargetProjectFlockId, + targetKandangIDs, + ); err != nil { + return nil, err } transferDate, err := utils.ParseDateString(req.TransferDate) if err != nil { - return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transfer date format") + return nil, fiber.NewError(fiber.StatusBadRequest, "Format tanggal transfer tidak valid") } var totalSourceQty, totalTargetQty float64 @@ -220,7 +210,7 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) for _, sourceDetail := range req.SourceKandangs { if sourceDetail.Quantity <= 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Source kandang quantity must be greater than 0") + return nil, fiber.NewError(fiber.StatusBadRequest, "Jumlah kandang sumber harus lebih dari 0") } totalSourceQty += sourceDetail.Quantity @@ -239,11 +229,11 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) } if totalPopulation == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Source kandang %d has no population available for transfer", sourceDetail.ProjectFlockKandangId)) + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Kandang sumber %d tidak memiliki populasi untuk ditransfer", sourceDetail.ProjectFlockKandangId)) } if totalPopulation < sourceDetail.Quantity { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Source kandang %d has insufficient quantity. Available: %.0f, Requested: %.0f", sourceDetail.ProjectFlockKandangId, totalPopulation, sourceDetail.Quantity)) + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Kandang sumber %d jumlah tidak mencukupi. Tersedia: %.0f, Diminta: %.0f", sourceDetail.ProjectFlockKandangId, totalPopulation, sourceDetail.Quantity)) } sourceWarehouseMap[sourceDetail.ProjectFlockKandangId] = productWarehouseId @@ -251,13 +241,13 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) for _, targetDetail := range req.TargetKandangs { if targetDetail.Quantity <= 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Target kandang quantity must be greater than 0") + return nil, fiber.NewError(fiber.StatusBadRequest, "Jumlah kandang tujuan harus lebih dari 0") } totalTargetQty += targetDetail.Quantity } if totalSourceQty != totalTargetQty { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Total source quantity (%f) must equal total target quantity (%f)", totalSourceQty, totalTargetQty)) + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Jumlah total sumber (%.0f) harus sama dengan jumlah total tujuan (%.0f)", totalSourceQty, totalTargetQty)) } transferNumber := fmt.Sprintf("TL-%d", time.Now().UnixNano()) @@ -268,22 +258,14 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) FromProjectFlockId: req.SourceProjectFlockId, ToProjectFlockId: req.TargetProjectFlockId, TransferDate: transferDate, - PendingUsageQty: &totalSourceQty, CreatedBy: actorID, } - if len(sourceWarehouseMap) > 0 { - for _, pwID := range sourceWarehouseMap { - createBody.ProductWarehouseId = &pwID - break - } - } - err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { repoTx := s.Repository.WithTx(dbTransaction) if err := repoTx.CreateOne(c.Context(), createBody, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to create transfer laying record") + return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat record transfer laying") } for _, sourceDetail := range req.SourceKandangs { @@ -292,78 +274,91 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) source := entity.LayingTransferSource{ LayingTransferId: createBody.Id, SourceProjectFlockKandangId: sourceDetail.ProjectFlockKandangId, - Qty: sourceDetail.Quantity, + UsageQty: 0, + PendingUsageQty: 0, // Di-set 0, biarkan FIFO Consume yang handle saat Approval ProductWarehouseId: &productWarehouseId, } if err := dbTransaction.Create(&source).Error; err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to create transfer source") + return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat sumber transfer") } } - var firstTargetProductWarehouseID *uint + for _, targetDetail := range req.TargetKandangs { - for i, targetDetail := range req.TargetKandangs { - - targetPFK, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), targetDetail.ProjectFlockKandangId) + targetprojectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), targetDetail.ProjectFlockKandangId) if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get target project flock kandang") + return fiber.NewError(fiber.StatusInternalServerError, "Gagal mendapatkan project flock kandang tujuan") } - targetWarehouse, err := s.WarehouseRepo.GetLatestByKandangID(c.Context(), targetPFK.KandangId) + targetWarehouse, err := s.WarehouseRepo.GetLatestByKandangID(c.Context(), targetprojectFlockKandang.KandangId) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("No warehouse found for target kandang %d", targetDetail.ProjectFlockKandangId)) + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Warehouse tidak ditemukan untuk kandang tujuan %d", targetDetail.ProjectFlockKandangId)) } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get target warehouse") + return fiber.NewError(fiber.StatusInternalServerError, "Gagal mendapatkan warehouse tujuan") } + // Ambil product ID dari salah satu source warehouse (harusnya semua sources product-nya sama) + var sourceProductID uint + for _, sourceDetail := range req.SourceKandangs { + if pwID, ok := sourceWarehouseMap[sourceDetail.ProjectFlockKandangId]; ok { + // Get product warehouse untuk ambil product ID + var sourcePW entity.ProductWarehouse + if err := dbTransaction.First(&sourcePW, pwID).Error; err == nil { + sourceProductID = sourcePW.ProductId + break + } + } + } + + if sourceProductID == 0 { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal mendapatkan product dari source warehouse") + } + + // Cari product warehouse di target berdasarkan: warehouse + project_flock_kandang + PRODUCT var targetPW entity.ProductWarehouse - err = dbTransaction.Where("warehouse_id = ? AND project_flock_kandang_id = ?", targetWarehouse.Id, targetDetail.ProjectFlockKandangId). + err = dbTransaction.Where("warehouse_id = ? AND project_flock_kandang_id = ? AND product_id = ?", + targetWarehouse.Id, targetDetail.ProjectFlockKandangId, sourceProductID). First(&targetPW).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("No product warehouse found for target kandang %d in warehouse %d", targetDetail.ProjectFlockKandangId, targetWarehouse.Id)) + // Create baru dengan product yang sama dengan source + targetPW = entity.ProductWarehouse{ + ProductId: sourceProductID, + WarehouseId: targetWarehouse.Id, + ProjectFlockKandangId: &targetDetail.ProjectFlockKandangId, + Quantity: 0, + } + if err := dbTransaction.Create(&targetPW).Error; err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal membuat product warehouse untuk kandang tujuan %d: %v", targetDetail.ProjectFlockKandangId, err)) + } + } else { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mendapatkan product warehouse untuk kandang tujuan %d: %v", targetDetail.ProjectFlockKandangId, err)) } - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to get product warehouse for target kandang %d: %v", targetDetail.ProjectFlockKandangId, err)) } target := entity.LayingTransferTarget{ LayingTransferId: createBody.Id, TargetProjectFlockKandangId: targetDetail.ProjectFlockKandangId, - Qty: targetDetail.Quantity, + TotalQty: targetDetail.Quantity, + TotalUsed: 0, ProductWarehouseId: &targetPW.Id, } if err := dbTransaction.Create(&target).Error; err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to create transfer target") - } - - if i == 0 { - firstTargetProductWarehouseID = &targetPW.Id - } - } - - // Set DestProductWarehouseID untuk STOCKABLE role (ambil dari target pertama) - if firstTargetProductWarehouseID != nil { - createBody.DestProductWarehouseID = firstTargetProductWarehouseID - - // Update DestProductWarehouseID ke database - if err := dbTransaction.Model(&entity.LayingTransfer{}). - Where("id = ?", createBody.Id). - Update("dest_product_warehouse_id", *firstTargetProductWarehouseID).Error; err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to update DestProductWarehouseID") + return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat target transfer") } } if err := createApprovalTransferLaying(c.Context(), dbTransaction, createBody.Id, createBody.CreatedBy); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to create transfer approval") + return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat approval transfer") } return nil }) if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create transfer laying") + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat transfer laying") } return s.GetOne(c, createBody.Id) @@ -412,24 +407,8 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { repoTx := s.Repository.WithTx(dbTransaction) - projectFlockPopulationRepoTx := s.ProjectFlockPopulationRepo.WithTx(dbTransaction) - productWarehouseRepoTx := s.ProductWarehouseRepo.WithTx(dbTransaction) - - for _, oldSource := range existingTransfer.Sources { - if oldSource.ProductWarehouseId != nil && oldSource.Qty > 0 { - - if err := productWarehouseRepoTx.PatchOne(c.Context(), *oldSource.ProductWarehouseId, map[string]any{ - "qty": gorm.Expr("qty + ?", oldSource.Qty), - }, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to restore warehouse quantity") - } - - if err := s.restoreProjectFlockPopulation(c.Context(), projectFlockPopulationRepoTx, oldSource.SourceProjectFlockKandangId, oldSource.Qty); err != nil { - return err - } - } - } + // Hapus old sources dan targets for _, oldSource := range existingTransfer.Sources { if err := dbTransaction.Delete(&oldSource).Error; err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete old source") @@ -442,23 +421,16 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, } } - totalSourceQty := 0.0 - for _, source := range req.SourceKandangs { - totalSourceQty += source.Quantity - } - if err := repoTx.PatchOne(c.Context(), id, map[string]any{ - "transfer_date": transferDate, - "notes": req.Reason, - "pending_usage_qty": &totalSourceQty, + "transfer_date": transferDate, + "notes": req.Reason, }, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to update transfer header") } - sourceWarehouseMap := make(map[uint]uint) + // Create new sources dengan pending quantity for _, sourceDetail := range req.SourceKandangs { - - populations, err := projectFlockPopulationRepoTx.GetByProjectFlockKandangID(c.Context(), sourceDetail.ProjectFlockKandangId) + populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(c.Context(), sourceDetail.ProjectFlockKandangId) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get populations") } @@ -467,48 +439,37 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Source kandang %d has no population available", sourceDetail.ProjectFlockKandangId)) } - var totalPopulation float64 var productWarehouseId uint - for _, pop := range populations { - totalPopulation += pop.TotalQty if pop.ProductWarehouseId > 0 { productWarehouseId = pop.ProductWarehouseId + break } } - if totalPopulation < sourceDetail.Quantity { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Source kandang %d has insufficient quantity. Available: %.0f, Requested: %.0f", sourceDetail.ProjectFlockKandangId, totalPopulation, sourceDetail.Quantity)) + if productWarehouseId == 0 { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Source kandang %d has no product warehouse", sourceDetail.ProjectFlockKandangId)) } - sourceWarehouseMap[sourceDetail.ProjectFlockKandangId] = productWarehouseId - source := entity.LayingTransferSource{ LayingTransferId: id, SourceProjectFlockKandangId: sourceDetail.ProjectFlockKandangId, - Qty: sourceDetail.Quantity, + UsageQty: 0, + PendingUsageQty: sourceDetail.Quantity, ProductWarehouseId: &productWarehouseId, } if err := dbTransaction.Create(&source).Error; err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create transfer source") } - - if err := s.reduceProjectFlockPopulation(c.Context(), projectFlockPopulationRepoTx, sourceDetail.ProjectFlockKandangId, sourceDetail.Quantity); err != nil { - return err - } - - if err := productWarehouseRepoTx.PatchOne(c.Context(), productWarehouseId, map[string]any{"qty": gorm.Expr("qty - ?", sourceDetail.Quantity)}, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to update source warehouse quantity") - } } for _, targetDetail := range req.TargetKandangs { - targetPFK, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), targetDetail.ProjectFlockKandangId) + targetprojectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), targetDetail.ProjectFlockKandangId) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get target project flock kandang") } - targetWarehouse, err := s.WarehouseRepo.GetLatestByKandangID(c.Context(), targetPFK.KandangId) + targetWarehouse, err := s.WarehouseRepo.GetLatestByKandangID(c.Context(), targetprojectFlockKandang.KandangId) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("No warehouse found for target kandang %d", targetDetail.ProjectFlockKandangId)) @@ -516,20 +477,50 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, return fiber.NewError(fiber.StatusInternalServerError, "Failed to get target warehouse") } + // Ambil product ID dari source yang pertama (semua sources seharusnya product-nya sama) + var sourceProductID uint + if len(req.SourceKandangs) > 0 { + firstSourceKandangID := req.SourceKandangs[0].ProjectFlockKandangId + populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(c.Context(), firstSourceKandangID) + if err == nil && len(populations) > 0 && populations[0].ProductWarehouseId > 0 { + var sourcePW entity.ProductWarehouse + if err := dbTransaction.First(&sourcePW, populations[0].ProductWarehouseId).Error; err == nil { + sourceProductID = sourcePW.ProductId + } + } + } + + if sourceProductID == 0 { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get product from source warehouse") + } + + // Cari product warehouse di target berdasarkan: warehouse + project_flock_kandang + PRODUCT var targetPW entity.ProductWarehouse - err = dbTransaction.Where("warehouse_id = ? AND project_flock_kandang_id = ?", targetWarehouse.Id, targetDetail.ProjectFlockKandangId). + err = dbTransaction.Where("warehouse_id = ? AND project_flock_kandang_id = ? AND product_id = ?", + targetWarehouse.Id, targetDetail.ProjectFlockKandangId, sourceProductID). First(&targetPW).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("No product warehouse found for target kandang %d in warehouse %d", targetDetail.ProjectFlockKandangId, targetWarehouse.Id)) + // Create baru dengan product yang sama dengan source + targetPW = entity.ProductWarehouse{ + ProductId: sourceProductID, + WarehouseId: targetWarehouse.Id, + ProjectFlockKandangId: &targetDetail.ProjectFlockKandangId, + Quantity: 0, + } + if err := dbTransaction.Create(&targetPW).Error; err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to create product warehouse for target kandang %d: %v", targetDetail.ProjectFlockKandangId, err)) + } + } else { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to get product warehouse for target kandang %d: %v", targetDetail.ProjectFlockKandangId, err)) } - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to get product warehouse for target kandang %d: %v", targetDetail.ProjectFlockKandangId, err)) } target := entity.LayingTransferTarget{ LayingTransferId: id, TargetProjectFlockKandangId: targetDetail.ProjectFlockKandangId, - Qty: targetDetail.Quantity, + TotalQty: targetDetail.Quantity, + TotalUsed: 0, ProductWarehouseId: &targetPW.Id, } if err := dbTransaction.Create(&target).Error; err != nil { @@ -573,49 +564,9 @@ func (s transferLayingService) DeleteOne(c *fiber.Ctx, id uint) error { } err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { repoTx := s.Repository.WithTx(dbTransaction) - productWarehouseRepoTx := s.ProductWarehouseRepo.WithTx(dbTransaction) - projectFlockPopulationRepoTx := s.ProjectFlockPopulationRepo.WithTx(dbTransaction) - - sourceRepoTx := repository.NewLayingTransferSourceRepository(dbTransaction) - sources, err := sourceRepoTx.GetByLayingTransferId(c.Context(), id) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get transfer sources") - } - - for _, source := range sources { - if source.ProductWarehouseId != nil && source.Qty > 0 { - - if err := productWarehouseRepoTx.PatchOne(c.Context(), *source.ProductWarehouseId, map[string]any{ - "qty": gorm.Expr("qty + ?", source.Qty), - }, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to restore source warehouse quantity") - } - } - } - - for _, source := range sources { - populations, err := projectFlockPopulationRepoTx.GetByProjectFlockKandangID(c.Context(), source.SourceProjectFlockKandangId) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get populations for restoration") - } - - remainingToRestore := source.Qty - for i := len(populations) - 1; i >= 0 && remainingToRestore > 0; i-- { - pop := populations[i] - restoreAmount := remainingToRestore - if pop.TotalQty < remainingToRestore { - restoreAmount = pop.TotalQty - } - - newQty := pop.TotalQty + restoreAmount - if err := projectFlockPopulationRepoTx.PatchOne(c.Context(), pop.Id, map[string]any{"total_qty": newQty}, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to restore population quantity") - } - - remainingToRestore -= restoreAmount - } - } + // Delete transfer - cascade akan menghapus sources dan targets + // FIFO akan menangani stock allocation cleanup via foreign key constraints if err := repoTx.DeleteOne(c.Context(), id); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete transfer laying") } @@ -667,7 +618,6 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { repoTx := s.Repository.WithTx(dbTransaction) approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) - sourceRepoTx := repository.NewLayingTransferSourceRepository(dbTransaction) targetRepoTx := repository.NewLayingTransferTargetRepository(dbTransaction) for _, approvableID := range approvableIDs { @@ -691,70 +641,77 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( return fiber.NewError(fiber.StatusInternalServerError, "Failed to record approval") } - if action == entity.ApprovalActionApproved && transfer.PendingUsageQty != nil && *transfer.PendingUsageQty > 0 { + if action == entity.ApprovalActionApproved { - sources, err := sourceRepoTx.GetByLayingTransferId(c.Context(), approvableID) + sources, err := repository.NewLayingTransferSourceRepository(dbTransaction).GetByLayingTransferId(c.Context(), approvableID) if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get transfer sources") + return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil sources transfer") } targets, err := targetRepoTx.GetByLayingTransferId(c.Context(), approvableID) if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get transfer targets") + return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil targets transfer") } - if len(sources) > 0 && len(targets) > 0 { + // Hitung total quantity dari targets untuk di-consume dari sources + totalTargetQty := 0.0 + for _, target := range targets { + totalTargetQty += target.TotalQty + } - for _, source := range sources { - if source.ProductWarehouseId == nil { - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Source product warehouse not found for transfer %d", approvableID)) - } - - _, err := s.FifoSvc.Consume(c.Context(), commonSvc.StockConsumeRequest{ - UsableKey: fifo.UsableKeyTransferToLaying, - UsableID: approvableID, - ProductWarehouseID: *source.ProductWarehouseId, - Quantity: source.Qty, - AllowPending: false, - Tx: dbTransaction, - }) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to consume FIFO stock for source %d: %v", source.ProductWarehouseId, err)) - } + // Consume dari laying_transfer_sources (Usable) - akan consume dari ProjectFlockPopulation (Stockable) + for _, source := range sources { + if source.ProductWarehouseId == nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Source product warehouse tidak ditemukan untuk transfer %d", approvableID)) } - if transfer.DestProductWarehouseID != nil { - note := fmt.Sprintf("Transfer to Laying #%s", transfer.TransferNumber) - replenishResult, err := s.FifoSvc.Replenish(c.Context(), commonSvc.StockReplenishRequest{ - StockableKey: fifo.StockableKeyTransferToLaying, - StockableID: approvableID, - ProductWarehouseID: *transfer.DestProductWarehouseID, - Quantity: *transfer.PendingUsageQty, - Note: ¬e, - Tx: dbTransaction, - }) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to replenish stock to destination warehouse: %v", err)) - } + consumeResult, err := s.FifoSvc.Consume(c.Context(), commonSvc.StockConsumeRequest{ + UsableKey: fifo.UsableKeyTransferToLayingOut, + UsableID: source.Id, + ProductWarehouseID: *source.ProductWarehouseId, + Quantity: totalTargetQty, + AllowPending: false, + Tx: dbTransaction, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal consume FIFO stock: %v", err)) + } - if err := dbTransaction.Model(&entity.LayingTransfer{}). - Where("id = ?", approvableID). - Updates(map[string]interface{}{ - "total_qty": replenishResult.AddedQuantity, - }).Error; err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to update total quantity for transfer") - } + // Update source usage tracking + if err := dbTransaction.Model(&entity.LayingTransferSource{}). + Where("id = ?", source.Id). + Updates(map[string]interface{}{ + "usage_qty": source.UsageQty + consumeResult.UsageQuantity, + "pending_usage_qty": consumeResult.PendingQuantity, + }).Error; err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal update source usage qty") } } - usageQty := *transfer.PendingUsageQty - updateData := map[string]any{ - "usage_qty": usageQty, - "total_qty": usageQty, // Same as usage_qty for initial transfer - "pending_usage_qty": nil, - } - if err := repoTx.PatchOne(c.Context(), approvableID, updateData, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to update transfer laying status") + // Replenish ke target warehouse + for _, target := range targets { + if target.ProductWarehouseId == nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Target product warehouse tidak ditemukan untuk transfer %d", approvableID)) + } + + note := fmt.Sprintf("Transfer to Laying #%s - Target Kandang", transfer.TransferNumber) + replenishResult, err := s.FifoSvc.Replenish(c.Context(), commonSvc.StockReplenishRequest{ + StockableKey: fifo.StockableKeyTransferToLayingIn, + StockableID: target.Id, + ProductWarehouseID: *target.ProductWarehouseId, + Quantity: target.TotalQty, + Note: ¬e, + Tx: dbTransaction, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal replenish stock ke target warehouse: %v", err)) + } + + if err := dbTransaction.Model(&entity.LayingTransferTarget{}). + Where("id = ?", target.Id). + Update("total_qty", replenishResult.AddedQuantity).Error; err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal update target total qty") + } } } } @@ -832,66 +789,6 @@ func (s *transferLayingService) getOrCreateProductWarehouse(ctx context.Context, return newWarehouse, nil } -func (s *transferLayingService) reduceProjectFlockPopulation(ctx context.Context, populationRepo ProjectFlockRepository.ProjectFlockPopulationRepository, projectFlockKandangID uint, quantityToReduce float64) error { - - populations, err := populationRepo.GetByProjectFlockKandangID(ctx, projectFlockKandangID) - if err != nil { - return err - } - - if len(populations) == 0 { - return fiber.NewError(fiber.StatusBadRequest, "No populations found for reduction") - } - - remainingToReduce := quantityToReduce - - for i := len(populations) - 1; i >= 0; i-- { - if remainingToReduce <= 0 { - break - } - - pop := populations[i] - reductionAmount := remainingToReduce - if pop.TotalQty < remainingToReduce { - reductionAmount = pop.TotalQty - } - - newQty := pop.TotalQty - reductionAmount - if err := populationRepo.PatchOne(ctx, pop.Id, map[string]any{"total_qty": newQty}, nil); err != nil { - return err - } - - remainingToReduce -= reductionAmount - } - - if remainingToReduce > 0 { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient population to reduce. Still need to reduce: %.0f", remainingToReduce)) - } - - return nil -} - -func (s *transferLayingService) restoreProjectFlockPopulation(ctx context.Context, populationRepo ProjectFlockRepository.ProjectFlockPopulationRepository, projectFlockKandangID uint, quantityToRestore float64) error { - populations, err := populationRepo.GetByProjectFlockKandangID(ctx, projectFlockKandangID) - if err != nil { - return err - } - - if len(populations) == 0 { - return fiber.NewError(fiber.StatusBadRequest, "No populations found for restoration") - } - - if len(populations) > 0 { - lastPop := populations[len(populations)-1] - newQty := lastPop.TotalQty + quantityToRestore - if err := populationRepo.PatchOne(ctx, lastPop.Id, map[string]any{"total_qty": newQty}, nil); err != nil { - return err - } - } - - return nil -} - func (s transferLayingService) GetAvailableQtyPerKandang(ctx *fiber.Ctx, projectFlockID uint) (*entity.ProjectFlock, map[uint]float64, error) { pf, err := s.ProjectFlockRepo.GetByID(ctx.Context(), projectFlockID, func(db *gorm.DB) *gorm.DB { @@ -925,3 +822,27 @@ func (s transferLayingService) GetAvailableQtyPerKandang(ctx *fiber.Ctx, project return pf, kandangAvailableQty, nil } + +func (s *transferLayingService) validateKandangOwnership( + ctx context.Context, + projectFlockID uint, + kandangIDs []uint, +) error { + + for _, kandangID := range kandangIDs { + // validasi terlebih dahulu apakah kandangnya itu ada atau gak + projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(ctx, kandangID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Kandang %d tidak ditemukan", kandangID)) + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get project flock kandang") + } + + if projectFlockKandang.ProjectFlockId != projectFlockID { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Kandang %d tidak terhubung ke project flock %d", kandangID, projectFlockID)) + } + } + + return nil +} diff --git a/internal/utils/fifo/constants.go b/internal/utils/fifo/constants.go index 2f96beaa..03f61f82 100644 --- a/internal/utils/fifo/constants.go +++ b/internal/utils/fifo/constants.go @@ -2,17 +2,17 @@ package fifo const ( // Usable Keys - UsableKeyRecordingStock UsableKey = "RECORDING_STOCK" - UsableKeyProjectChickin UsableKey = "PROJECT_CHICKIN" - UsableKeyMarketingDelivery UsableKey = "MARKETING_DELIVERY" - UsableKeyTransferToLaying UsableKey = "TRANSFER_TO_LAYING" - UsableKeyStockTransferOut UsableKey = "STOCK_TRANSFER_OUT" - UsableKeyAdjustmentOut UsableKey = "ADJUSTMENT_OUT" + UsableKeyRecordingStock UsableKey = "RECORDING_STOCK" + UsableKeyProjectChickin UsableKey = "PROJECT_CHICKIN" + UsableKeyMarketingDelivery UsableKey = "MARKETING_DELIVERY" + UsableKeyTransferToLayingOut UsableKey = "TRANSFERTOLAYING_OUT" + UsableKeyStockTransferOut UsableKey = "STOCK_TRANSFER_OUT" + UsableKeyAdjustmentOut UsableKey = "ADJUSTMENT_OUT" // Stockable Keys - StockableKeyTransferToLaying StockableKey = "TRANSFER_TO_LAYING" - StockableKeyStockTransferIn StockableKey = "STOCK_TRANSFER_IN" - StockableKeyAdjustmentIn StockableKey = "ADJUSTMENT_IN" - StockableKeyPurchaseItems StockableKey = "PURCHASE_ITEMS" + StockableKeyTransferToLayingIn StockableKey = "TRANSFERTOLAYING_IN" + StockableKeyStockTransferIn StockableKey = "STOCK_TRANSFER_IN" + StockableKeyAdjustmentIn StockableKey = "ADJUSTMENT_IN" + StockableKeyPurchaseItems StockableKey = "PURCHASE_ITEMS" StockableKeyProjectFlockPopulation StockableKey = "PROJECT_FLOCK_POPULATION" ) From c1e9b5a97571650856a6038e40c11b3a7ea8c515 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Sun, 11 Jan 2026 13:30:19 +0700 Subject: [PATCH 02/18] FIX[BE]: add laying transfer source and target repositories to transfer laying service --- .../production/transfer_layings/module.go | 4 + .../services/transfer_laying.service.go | 89 ++++++++++--------- 2 files changed, 51 insertions(+), 42 deletions(-) diff --git a/internal/modules/production/transfer_layings/module.go b/internal/modules/production/transfer_layings/module.go index 2068ccd7..dfe2ad44 100644 --- a/internal/modules/production/transfer_layings/module.go +++ b/internal/modules/production/transfer_layings/module.go @@ -26,6 +26,8 @@ type TransferLayingModule struct{} func (TransferLayingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { transferLayingRepo := rTransferLaying.NewTransferLayingRepository(db) + layingTransferSourceRepo := rTransferLaying.NewLayingTransferSourceRepository(db) + layingTransferTargetRepo := rTransferLaying.NewLayingTransferTargetRepository(db) userRepo := rUser.NewUserRepository(db) projectFlockRepo := rProjectFlock.NewProjectflockRepository(db) projectFlockKandangRepo := rProjectFlock.NewProjectFlockKandangRepository(db) @@ -80,6 +82,8 @@ func (TransferLayingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, val transferLayingService := sTransferLaying.NewTransferLayingService( transferLayingRepo, + layingTransferSourceRepo, + layingTransferTargetRepo, projectFlockRepo, projectFlockKandangRepo, projectFlockPopulationRepo, diff --git a/internal/modules/production/transfer_layings/services/transfer_laying.service.go b/internal/modules/production/transfer_layings/services/transfer_laying.service.go index e62d4722..9732ad75 100644 --- a/internal/modules/production/transfer_layings/services/transfer_laying.service.go +++ b/internal/modules/production/transfer_layings/services/transfer_laying.service.go @@ -41,6 +41,8 @@ type transferLayingService struct { Log *logrus.Logger Validate *validator.Validate Repository repository.TransferLayingRepository + LayingTransferSourceRepo repository.LayingTransferSourceRepository + LayingTransferTargetRepo repository.LayingTransferTargetRepository ProjectFlockRepo ProjectFlockRepository.ProjectflockRepository ProjectFlockKandangRepo ProjectFlockRepository.ProjectFlockKandangRepository ProjectFlockPopulationRepo ProjectFlockRepository.ProjectFlockPopulationRepository @@ -53,6 +55,8 @@ type transferLayingService struct { func NewTransferLayingService( repo repository.TransferLayingRepository, + layingTransferSourceRepo repository.LayingTransferSourceRepository, + layingTransferTargetRepo repository.LayingTransferTargetRepository, projectFlockRepo ProjectFlockRepository.ProjectflockRepository, projectFlockKandangRepo ProjectFlockRepository.ProjectFlockKandangRepository, projectFlockPopulationRepo ProjectFlockRepository.ProjectFlockPopulationRepository, @@ -66,6 +70,8 @@ func NewTransferLayingService( Log: utils.Log, Validate: validate, Repository: repo, + LayingTransferSourceRepo: layingTransferSourceRepo, + LayingTransferTargetRepo: layingTransferTargetRepo, ProjectFlockRepo: projectFlockRepo, ProjectFlockKandangRepo: projectFlockKandangRepo, ProjectFlockPopulationRepo: projectFlockPopulationRepo, @@ -262,7 +268,11 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) } err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + repoTx := s.Repository.WithTx(dbTransaction) + sourceRepoTx := repository.NewLayingTransferSourceRepository(dbTransaction) + targetRepoTx := repository.NewLayingTransferTargetRepository(dbTransaction) + pwRepoTx := rInventory.NewProductWarehouseRepository(dbTransaction) if err := repoTx.CreateOne(c.Context(), createBody, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat record transfer laying") @@ -278,7 +288,7 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) PendingUsageQty: 0, // Di-set 0, biarkan FIFO Consume yang handle saat Approval ProductWarehouseId: &productWarehouseId, } - if err := dbTransaction.Create(&source).Error; err != nil { + if err := sourceRepoTx.CreateOne(c.Context(), &source, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat sumber transfer") } @@ -304,8 +314,8 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) for _, sourceDetail := range req.SourceKandangs { if pwID, ok := sourceWarehouseMap[sourceDetail.ProjectFlockKandangId]; ok { // Get product warehouse untuk ambil product ID - var sourcePW entity.ProductWarehouse - if err := dbTransaction.First(&sourcePW, pwID).Error; err == nil { + sourcePW, err := pwRepoTx.GetByID(c.Context(), pwID, nil) + if err == nil { sourceProductID = sourcePW.ProductId break } @@ -317,22 +327,19 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) } // Cari product warehouse di target berdasarkan: warehouse + project_flock_kandang + PRODUCT - var targetPW entity.ProductWarehouse - err = dbTransaction.Where("warehouse_id = ? AND project_flock_kandang_id = ? AND product_id = ?", - targetWarehouse.Id, targetDetail.ProjectFlockKandangId, sourceProductID). - First(&targetPW).Error + targetPW, err := pwRepoTx.FindByProductWarehouseAndPfk(c.Context(), sourceProductID, targetWarehouse.Id, &targetDetail.ProjectFlockKandangId) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - // Create baru dengan product yang sama dengan source - targetPW = entity.ProductWarehouse{ + newTargetPW := entity.ProductWarehouse{ ProductId: sourceProductID, WarehouseId: targetWarehouse.Id, ProjectFlockKandangId: &targetDetail.ProjectFlockKandangId, Quantity: 0, } - if err := dbTransaction.Create(&targetPW).Error; err != nil { + if err := pwRepoTx.CreateOne(c.Context(), &newTargetPW, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal membuat product warehouse untuk kandang tujuan %d: %v", targetDetail.ProjectFlockKandangId, err)) } + targetPW = &newTargetPW } else { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mendapatkan product warehouse untuk kandang tujuan %d: %v", targetDetail.ProjectFlockKandangId, err)) } @@ -345,7 +352,7 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) TotalUsed: 0, ProductWarehouseId: &targetPW.Id, } - if err := dbTransaction.Create(&target).Error; err != nil { + if err := targetRepoTx.CreateOne(c.Context(), &target, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat target transfer") } } @@ -407,16 +414,18 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { repoTx := s.Repository.WithTx(dbTransaction) + sourceRepo := s.LayingTransferSourceRepo.WithTx(dbTransaction) + targetRepo := s.LayingTransferTargetRepo.WithTx(dbTransaction) // Hapus old sources dan targets for _, oldSource := range existingTransfer.Sources { - if err := dbTransaction.Delete(&oldSource).Error; err != nil { + if err := sourceRepo.DeleteOne(c.Context(), oldSource.Id); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete old source") } } for _, oldTarget := range existingTransfer.Targets { - if err := dbTransaction.Delete(&oldTarget).Error; err != nil { + if err := targetRepo.DeleteOne(c.Context(), oldTarget.Id); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete old target") } } @@ -458,11 +467,13 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, PendingUsageQty: sourceDetail.Quantity, ProductWarehouseId: &productWarehouseId, } - if err := dbTransaction.Create(&source).Error; err != nil { + if err := sourceRepo.CreateOne(c.Context(), &source, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create transfer source") } } + pwRepo := rInventory.NewProductWarehouseRepository(dbTransaction) + for _, targetDetail := range req.TargetKandangs { targetprojectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), targetDetail.ProjectFlockKandangId) if err != nil { @@ -483,8 +494,8 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, firstSourceKandangID := req.SourceKandangs[0].ProjectFlockKandangId populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(c.Context(), firstSourceKandangID) if err == nil && len(populations) > 0 && populations[0].ProductWarehouseId > 0 { - var sourcePW entity.ProductWarehouse - if err := dbTransaction.First(&sourcePW, populations[0].ProductWarehouseId).Error; err == nil { + sourcePW, err := pwRepo.GetByID(c.Context(), populations[0].ProductWarehouseId, nil) + if err == nil { sourceProductID = sourcePW.ProductId } } @@ -494,23 +505,20 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, return fiber.NewError(fiber.StatusInternalServerError, "Failed to get product from source warehouse") } - // Cari product warehouse di target berdasarkan: warehouse + project_flock_kandang + PRODUCT - var targetPW entity.ProductWarehouse - err = dbTransaction.Where("warehouse_id = ? AND project_flock_kandang_id = ? AND product_id = ?", - targetWarehouse.Id, targetDetail.ProjectFlockKandangId, sourceProductID). - First(&targetPW).Error + targetPW, err := pwRepo.FindByProductWarehouseAndPfk(c.Context(), sourceProductID, targetWarehouse.Id, &targetDetail.ProjectFlockKandangId) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - // Create baru dengan product yang sama dengan source - targetPW = entity.ProductWarehouse{ + + newTargetPW := entity.ProductWarehouse{ ProductId: sourceProductID, WarehouseId: targetWarehouse.Id, ProjectFlockKandangId: &targetDetail.ProjectFlockKandangId, Quantity: 0, } - if err := dbTransaction.Create(&targetPW).Error; err != nil { + if err := pwRepo.CreateOne(c.Context(), &newTargetPW, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to create product warehouse for target kandang %d: %v", targetDetail.ProjectFlockKandangId, err)) } + targetPW = &newTargetPW } else { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to get product warehouse for target kandang %d: %v", targetDetail.ProjectFlockKandangId, err)) } @@ -523,7 +531,7 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, TotalUsed: 0, ProductWarehouseId: &targetPW.Id, } - if err := dbTransaction.Create(&target).Error; err != nil { + if err := targetRepo.CreateOne(c.Context(), &target, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create transfer target") } } @@ -551,6 +559,7 @@ func (s transferLayingService) DeleteOne(c *fiber.Ctx, id uint) error { } approvalRepo := commonRepo.NewApprovalRepository(s.Repository.DB()) + latestApproval, err := approvalRepo.LatestByTarget(c.Context(), string(utils.ApprovalWorkflowTransferToLaying), id, nil) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusInternalServerError, "Failed to check approval status") @@ -565,8 +574,6 @@ func (s transferLayingService) DeleteOne(c *fiber.Ctx, id uint) error { err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { repoTx := s.Repository.WithTx(dbTransaction) - // Delete transfer - cascade akan menghapus sources dan targets - // FIFO akan menangani stock allocation cleanup via foreign key constraints if err := repoTx.DeleteOne(c.Context(), id); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete transfer laying") } @@ -618,6 +625,9 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { repoTx := s.Repository.WithTx(dbTransaction) approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) + + // Gunakan repo baru untuk transaction scope agar bisa akses method custom + sourceRepoTx := repository.NewLayingTransferSourceRepository(dbTransaction) targetRepoTx := repository.NewLayingTransferTargetRepository(dbTransaction) for _, approvableID := range approvableIDs { @@ -643,7 +653,7 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( if action == entity.ApprovalActionApproved { - sources, err := repository.NewLayingTransferSourceRepository(dbTransaction).GetByLayingTransferId(c.Context(), approvableID) + sources, err := sourceRepoTx.GetByLayingTransferId(c.Context(), approvableID) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil sources transfer") } @@ -677,18 +687,14 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal consume FIFO stock: %v", err)) } - // Update source usage tracking - if err := dbTransaction.Model(&entity.LayingTransferSource{}). - Where("id = ?", source.Id). - Updates(map[string]interface{}{ - "usage_qty": source.UsageQty + consumeResult.UsageQuantity, - "pending_usage_qty": consumeResult.PendingQuantity, - }).Error; err != nil { + if err := sourceRepoTx.PatchOne(c.Context(), source.Id, map[string]interface{}{ + "usage_qty": source.UsageQty + consumeResult.UsageQuantity, + "pending_usage_qty": consumeResult.PendingQuantity, + }, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Gagal update source usage qty") } } - // Replenish ke target warehouse for _, target := range targets { if target.ProductWarehouseId == nil { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Target product warehouse tidak ditemukan untuk transfer %d", approvableID)) @@ -707,9 +713,9 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal replenish stock ke target warehouse: %v", err)) } - if err := dbTransaction.Model(&entity.LayingTransferTarget{}). - Where("id = ?", target.Id). - Update("total_qty", replenishResult.AddedQuantity).Error; err != nil { + if err := targetRepoTx.PatchOne(c.Context(), target.Id, map[string]interface{}{ + "total_qty": replenishResult.AddedQuantity, + }, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Gagal update target total qty") } } @@ -777,9 +783,8 @@ func (s *transferLayingService) getOrCreateProductWarehouse(ctx context.Context, newWarehouse := &entity.ProductWarehouse{ ProductId: productID, WarehouseId: warehouseID, - ProjectFlockKandangId: projectFlockKandangId, // Set flock ID agar bisa di-chickin di target flock + ProjectFlockKandangId: projectFlockKandangId, Quantity: quantity, - // CreatedBy: actorID, } if err := productWarehouseRepoTx.CreateOne(ctx, newWarehouse, nil); err != nil { @@ -830,7 +835,7 @@ func (s *transferLayingService) validateKandangOwnership( ) error { for _, kandangID := range kandangIDs { - // validasi terlebih dahulu apakah kandangnya itu ada atau gak + projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(ctx, kandangID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { From 525ff650f2a32ab3d12afff118228c4d5dc5ec06 Mon Sep 17 00:00:00 2001 From: ragilap Date: Sun, 11 Jan 2026 15:40:47 +0700 Subject: [PATCH 03/18] feat(BE-390): calculation dashboard --- internal/entities/dashboard.go | 18 + .../controllers/dashboard.controller.go | 193 ++++ .../modules/dashboards/dto/dashboard.dto.go | 82 ++ internal/modules/dashboards/module.go | 26 + .../repositories/dashboard.repository.go | 44 + .../dashboard_stats.repository.go | 672 ++++++++++++++ internal/modules/dashboards/route.go | 26 + .../dashboards/services/dashboard.service.go | 867 ++++++++++++++++++ .../validations/dashboard.validation.go | 54 ++ internal/response/response.go | 8 + internal/route/route.go | 2 + 11 files changed, 1992 insertions(+) create mode 100644 internal/entities/dashboard.go create mode 100644 internal/modules/dashboards/controllers/dashboard.controller.go create mode 100644 internal/modules/dashboards/dto/dashboard.dto.go create mode 100644 internal/modules/dashboards/module.go create mode 100644 internal/modules/dashboards/repositories/dashboard.repository.go create mode 100644 internal/modules/dashboards/repositories/dashboard_stats.repository.go create mode 100644 internal/modules/dashboards/route.go create mode 100644 internal/modules/dashboards/services/dashboard.service.go create mode 100644 internal/modules/dashboards/validations/dashboard.validation.go diff --git a/internal/entities/dashboard.go b/internal/entities/dashboard.go new file mode 100644 index 00000000..ab9f4ea5 --- /dev/null +++ b/internal/entities/dashboard.go @@ -0,0 +1,18 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type Dashboard struct { + Id uint `gorm:"primaryKey"` + Name string `gorm:"not null;uniqueIndex:idx_name,where:deleted_at IS NULL"` + CreatedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` +} diff --git a/internal/modules/dashboards/controllers/dashboard.controller.go b/internal/modules/dashboards/controllers/dashboard.controller.go new file mode 100644 index 00000000..47e8d5bc --- /dev/null +++ b/internal/modules/dashboards/controllers/dashboard.controller.go @@ -0,0 +1,193 @@ +package controller + +import ( + "math" + "strconv" + "strings" + "time" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type DashboardController struct { + DashboardService service.DashboardService +} + +func NewDashboardController(dashboardService service.DashboardService) *DashboardController { + return &DashboardController{ + DashboardService: dashboardService, + } +} + +func (u *DashboardController) GetAll(c *fiber.Ctx) error { + parseStringListParam := func(param string) ([]string, error) { + if param == "" { + return nil, nil + } + parts := strings.Split(param, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed == "" { + return nil, strconv.ErrSyntax + } + result = append(result, trimmed) + } + return result, nil + } + + parseUintListParam := func(param string) ([]uint, error) { + if param == "" { + return nil, nil + } + parts := strings.Split(param, ",") + ids := make([]uint, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed == "" { + return nil, strconv.ErrSyntax + } + parsed, err := strconv.ParseUint(trimmed, 10, 64) + if err != nil { + return nil, err + } + ids = append(ids, uint(parsed)) + } + return ids, nil + } + + lokasiIds, err := parseUintListParam(c.Query("lokasi_ids", "")) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid lokasi_ids") + } + + flockIds, err := parseUintListParam(c.Query("flock_ids", "")) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid flock_ids") + } + + kandangIds, err := parseUintListParam(c.Query("kandang_ids", "")) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid kandang_ids") + } + + include, err := parseStringListParam(strings.ToLower(c.Query("include", ""))) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid include") + } + + analysisMode := strings.ToUpper(strings.TrimSpace(c.Query("analysis_mode", validation.AnalysisModeOverview))) + metric := strings.ToLower(strings.TrimSpace(c.Query("metric", ""))) + + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: strings.TrimSpace(c.Query("search", "")), + PerformanceOverviewFilter: validation.PerformanceOverviewFilter{ + StartDate: c.Query("start_date", ""), + EndDate: c.Query("end_date", ""), + AnalysisMode: analysisMode, + ComparisonType: strings.ToUpper(strings.TrimSpace(c.Query("comparison_type", ""))), + Metric: metric, + LokasiIds: lokasiIds, + FlockIds: flockIds, + KandangIds: kandangIds, + Include: include, + }, + } + + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + if query.AnalysisMode == validation.AnalysisModeComparison && query.ComparisonType == "" { + return fiber.NewError(fiber.StatusBadRequest, "comparison_type is required for comparison mode") + } + + location, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "failed to load timezone configuration") + } + + startDate, endDate, endExclusive, err := parsePeriodDates(query.StartDate, query.EndDate, location) + if err != nil { + return err + } + + query.PeriodStart = startDate + query.PeriodEnd = endDate + query.PeriodEndExclusive = endExclusive + + result, totalResults, err := u.DashboardService.GetAll(c.Context(), query) + if err != nil { + return err + } + + filters := dto.DashboardFiltersDTO{ + StartDate: query.StartDate, + EndDate: query.EndDate, + AnalysisMode: query.AnalysisMode, + ComparisonType: query.ComparisonType, + Metric: query.Metric, + LokasiIds: defaultUintSlice(query.LokasiIds), + FlockIds: defaultUintSlice(query.FlockIds), + KandangIds: defaultUintSlice(query.KandangIds), + Include: query.Include, + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithMeta{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get dashboard successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + Filters: filters, + }, + Data: result, + }) +} + +func defaultUintSlice(values []uint) []uint { + if values == nil { + return []uint{} + } + return values +} + +func parsePeriodDates(startDateRaw, endDateRaw string, location *time.Location) (time.Time, time.Time, time.Time, error) { + now := time.Now().In(location) + startDate := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, location) + endDate := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, location) + + if startDateRaw != "" { + parsed, err := time.ParseInLocation("2006-01-02", startDateRaw, location) + if err != nil { + return time.Time{}, time.Time{}, time.Time{}, fiber.NewError(fiber.StatusBadRequest, "start_date must follow format YYYY-MM-DD") + } + startDate = parsed + } + + if endDateRaw != "" { + parsed, err := time.ParseInLocation("2006-01-02", endDateRaw, location) + if err != nil { + return time.Time{}, time.Time{}, time.Time{}, fiber.NewError(fiber.StatusBadRequest, "end_date must follow format YYYY-MM-DD") + } + endDate = parsed + } + + if endDate.Before(startDate) { + return time.Time{}, time.Time{}, time.Time{}, fiber.NewError(fiber.StatusBadRequest, "end_date must be greater than or equal to start_date") + } + + endExclusive := endDate.AddDate(0, 0, 1) + return startDate, endDate, endExclusive, nil +} diff --git a/internal/modules/dashboards/dto/dashboard.dto.go b/internal/modules/dashboards/dto/dashboard.dto.go new file mode 100644 index 00000000..bf7d0f91 --- /dev/null +++ b/internal/modules/dashboards/dto/dashboard.dto.go @@ -0,0 +1,82 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" +) + +// === DTO Structs === + +type DashboardListDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type DashboardDetailDTO struct { + DashboardListDTO +} + +type DashboardFiltersDTO struct { + StartDate string `json:"start_date"` + EndDate string `json:"end_date"` + AnalysisMode string `json:"analysis_mode"` + ComparisonType string `json:"comparison_type,omitempty"` + Metric string `json:"metric,omitempty"` + LokasiIds []uint `json:"lokasi_ids"` + FlockIds []uint `json:"flock_ids"` + KandangIds []uint `json:"kandang_ids"` + Include []string `json:"include,omitempty"` +} + +type DashboardStatisticsDTO struct { + Label string `json:"label"` + Value float64 `json:"value"` + PercentLastMonth float64 `json:"percent_last_month"` +} + +type DashboardPerformanceOverviewDTO struct { + StatisticsData []DashboardStatisticsDTO `json:"statistics_data"` + Charts map[string]DashboardChartDTO `json:"charts,omitempty"` +} + +type DashboardChartSeriesDTO struct { + Id string `json:"id"` + Label string `json:"label"` + Unit string `json:"unit,omitempty"` +} + +type DashboardChartDTO struct { + Series []DashboardChartSeriesDTO `json:"series"` + Dataset []map[string]interface{} `json:"dataset"` +} + +// === Mapper Functions === + +func ToDashboardListDTO(e entity.Dashboard) DashboardListDTO { + var createdUser *userDTO.UserRelationDTO + if e.CreatedUser.Id != 0 { + mapped := userDTO.ToUserRelationDTO(e.CreatedUser) + createdUser = &mapped + } + + return DashboardListDTO{ + Id: e.Id, + Name: e.Name, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, + } +} + +func ToDashboardListDTOs(e []entity.Dashboard) []DashboardListDTO { + result := make([]DashboardListDTO, len(e)) + for i, r := range e { + result[i] = ToDashboardListDTO(r) + } + return result +} diff --git a/internal/modules/dashboards/module.go b/internal/modules/dashboards/module.go new file mode 100644 index 00000000..24574dc7 --- /dev/null +++ b/internal/modules/dashboards/module.go @@ -0,0 +1,26 @@ +package dashboards + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + rDashboard "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/repositories" + sDashboard "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type DashboardModule struct{} + +func (DashboardModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + dashboardRepo := rDashboard.NewDashboardRepository(db) + userRepo := rUser.NewUserRepository(db) + + dashboardService := sDashboard.NewDashboardService(dashboardRepo, validate) + userService := sUser.NewUserService(userRepo, validate) + + DashboardRoutes(router, userService, dashboardService) +} + diff --git a/internal/modules/dashboards/repositories/dashboard.repository.go b/internal/modules/dashboards/repositories/dashboard.repository.go new file mode 100644 index 00000000..90ee3bf8 --- /dev/null +++ b/internal/modules/dashboards/repositories/dashboard.repository.go @@ -0,0 +1,44 @@ +package repository + +import ( + "context" + "time" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/validations" + "gorm.io/gorm" +) + +type DashboardRepository interface { + repository.BaseRepository[entity.Dashboard] + GetFeedUsageByUom(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]FeedUsageByUom, error) + SumDepletions(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error) + SumInitialPopulation(ctx context.Context, endDate time.Time, filters *validation.DashboardFilter) (float64, error) + SumSapronakCost(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error) + SumBopCost(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error) + SumEkspedisiCost(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error) + SumSellingPrice(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (SellingPriceAggregate, error) + SumEggProductionWeightGrams(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error) + SumEggProductionWeightKg(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error) + GetRecordingWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]RecordingWeeklyMetric, error) + GetUniformityWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]UniformityWeeklyMetric, error) + GetStandardWeeklyMetrics(ctx context.Context, weeks []int, filters *validation.DashboardFilter) ([]StandardWeeklyMetric, error) + GetStandardFcrWeekly(ctx context.Context, weeks []int, filters *validation.DashboardFilter) ([]StandardWeeklyFcrMetric, error) + GetComparisonSeries(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter, comparisonType string) ([]ComparisonSeries, error) + GetComparisonWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter, comparisonType, metric string) ([]ComparisonWeeklyMetric, error) + GetComparisonWeeklyUniformityMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter, comparisonType string) ([]ComparisonUniformityMetric, error) + GetEggQualityWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]EggQualityWeeklyMetric, error) + GetEggWeightWeeklyGrams(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]WeeklyEggWeightMetric, error) + GetFeedUsageWeeklyByUom(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]WeeklyFeedUsageMetric, error) +} + +type DashboardRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Dashboard] +} + +func NewDashboardRepository(db *gorm.DB) DashboardRepository { + return &DashboardRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Dashboard](db), + } +} diff --git a/internal/modules/dashboards/repositories/dashboard_stats.repository.go b/internal/modules/dashboards/repositories/dashboard_stats.repository.go new file mode 100644 index 00000000..a06fdb71 --- /dev/null +++ b/internal/modules/dashboards/repositories/dashboard_stats.repository.go @@ -0,0 +1,672 @@ +package repository + +import ( + "context" + "fmt" + "strings" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gorm.io/gorm" +) + +type SellingPriceAggregate struct { + TotalPrice float64 + TotalWeight float64 +} + +type FeedUsageByUom struct { + TotalQty float64 + UomName string +} + +type RecordingWeeklyMetric struct { + Week int + HandDay float64 + EggWeight float64 + FeedIntake float64 + FcrValue float64 + CumDepletionRate float64 +} + +type UniformityWeeklyMetric struct { + Week int + Uniformity float64 + AverageWeight float64 +} + +type StandardWeeklyMetric struct { + Week int + StdLaying float64 + StdEggWeight float64 + StdFeedIntake float64 + StdUniformity float64 + StdDepletion float64 + StdBodyWeight float64 +} + +type StandardWeeklyFcrMetric struct { + Week int + StdFcr float64 +} + +type ComparisonSeries struct { + Id uint + Label string +} + +type ComparisonWeeklyMetric struct { + Week int + SeriesId uint + Value float64 +} + +type ComparisonUniformityMetric struct { + Week int + SeriesId uint + Uniformity float64 + AverageWeight float64 +} + +type EggQualityWeeklyMetric struct { + Week int + NormalQty float64 + AbnormalQty float64 + TotalQty float64 +} + +type WeeklyEggWeightMetric struct { + Week int + EggWeightGrams float64 +} + +type WeeklyFeedUsageMetric struct { + Week int + TotalQty float64 + UomName string +} + +func applyDashboardFilters(db *gorm.DB, filters *validation.DashboardFilter) *gorm.DB { + if filters == nil { + return db + } + if len(filters.FlockIds) > 0 { + db = db.Where("pfk.project_flock_id IN ?", filters.FlockIds) + } + if len(filters.KandangIds) > 0 { + db = db.Where("k.id IN ?", filters.KandangIds) + } + if len(filters.LokasiIds) > 0 { + db = db.Where("k.location_id IN ?", filters.LokasiIds) + } + return db +} + +func (r *DashboardRepositoryImpl) GetRecordingWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]RecordingWeeklyMetric, error) { + var rows []RecordingWeeklyMetric + + db := r.DB().WithContext(ctx). + Table("recordings AS r"). + Select(`((r.day - 1) / 7 + 1) AS week, + COALESCE(AVG(r.hen_day), 0) AS hand_day, + COALESCE(AVG(r.egg_weight), 0) AS egg_weight, + COALESCE(AVG(r.feed_intake), 0) AS feed_intake, + COALESCE(AVG(r.fcr_value), 0) AS fcr_value, + COALESCE(AVG(r.cum_depletion_rate), 0) AS cum_depletion_rate`). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL"). + Where("r.day IS NOT NULL AND r.day > 0") + + db = applyDashboardFilters(db, filters) + + if err := db.Group("week").Order("week ASC").Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *DashboardRepositoryImpl) GetUniformityWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]UniformityWeeklyMetric, error) { + var rows []UniformityWeeklyMetric + + db := r.DB().WithContext(ctx). + Table("project_flock_kandang_uniformity AS u"). + Select(`u.week AS week, + COALESCE(AVG(u.uniformity), 0) AS uniformity, + COALESCE(AVG((u.chart_data->'statistics'->>'average_weight')::numeric), 0) AS average_weight`). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = u.project_flock_kandang_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Where("u.uniform_date IS NOT NULL"). + Where("u.uniform_date >= ? AND u.uniform_date < ?", start, end) + + db = applyDashboardFilters(db, filters) + + if err := db.Group("u.week").Order("u.week ASC").Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *DashboardRepositoryImpl) GetStandardWeeklyMetrics(ctx context.Context, weeks []int, filters *validation.DashboardFilter) ([]StandardWeeklyMetric, error) { + if len(weeks) == 0 { + return nil, nil + } + + standardIDs := r.standardIDSubquery(filters) + if standardIDs == nil { + return nil, nil + } + + var rows []StandardWeeklyMetric + db := r.DB().WithContext(ctx). + Table("standard_growth_details AS sgd"). + Select(`sgd.week AS week, + COALESCE(AVG(psd.target_hen_day_production), 0) AS std_laying, + COALESCE(AVG(psd.target_egg_weight), 0) AS std_egg_weight, + COALESCE(AVG(sgd.feed_intake), 0) AS std_feed_intake, + COALESCE(AVG(sgd.min_uniformity), 0) AS std_uniformity, + COALESCE(AVG(sgd.max_depletion), 0) AS std_depletion, + COALESCE(AVG(sgd.target_mean_bw), 0) AS std_body_weight`). + Joins("LEFT JOIN production_standard_details AS psd ON psd.production_standard_id = sgd.production_standard_id AND psd.week = sgd.week"). + Where("sgd.week IN ?", weeks). + Where("sgd.production_standard_id IN (?)", standardIDs) + + if err := db.Group("sgd.week").Order("sgd.week ASC").Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *DashboardRepositoryImpl) GetStandardFcrWeekly(ctx context.Context, weeks []int, filters *validation.DashboardFilter) ([]StandardWeeklyFcrMetric, error) { + if len(weeks) == 0 { + return nil, nil + } + + source := r.standardSourceSubquery(filters) + if source == nil { + return nil, nil + } + + var rows []StandardWeeklyFcrMetric + db := r.DB().WithContext(ctx). + Table("standard_growth_details AS sgd"). + Select(` + sgd.week AS week, + COALESCE(AVG( + COALESCE( + ( + SELECT fs.fcr_number + FROM fcr_standards fs + WHERE fs.fcr_id = src.fcr_id + AND fs.weight >= CASE WHEN sgd.target_mean_bw > 10 THEN sgd.target_mean_bw / 1000 ELSE sgd.target_mean_bw END + ORDER BY fs.weight ASC + LIMIT 1 + ), + ( + SELECT fs.fcr_number + FROM fcr_standards fs + WHERE fs.fcr_id = src.fcr_id + ORDER BY fs.weight DESC + LIMIT 1 + ) + ) + ), 0) AS std_fcr`). + Joins("JOIN (?) AS src ON src.production_standard_id = sgd.production_standard_id", source). + Where("sgd.week IN ?", weeks) + + if err := db.Group("sgd.week").Order("sgd.week ASC").Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *DashboardRepositoryImpl) SumEggProductionWeightGrams(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error) { + var total float64 + + db := r.DB().WithContext(ctx). + Table("recording_eggs AS re"). + Select("COALESCE(SUM(re.qty * re.weight), 0)"). + Joins("JOIN recordings AS r ON r.id = re.recording_id"). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL") + + db = applyDashboardFilters(db, filters) + + if err := db.Scan(&total).Error; err != nil { + return 0, err + } + + return total, nil +} + +func (r *DashboardRepositoryImpl) SumEggProductionWeightKg(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error) { + grams, err := r.SumEggProductionWeightGrams(ctx, start, end, filters) + if err != nil { + return 0, err + } + return grams / 1000, nil +} + +func (r *DashboardRepositoryImpl) GetFeedUsageByUom(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]FeedUsageByUom, error) { + var rows []FeedUsageByUom + + db := r.DB().WithContext(ctx). + Table("recording_stocks AS rs"). + Select("COALESCE(SUM(rs.usage_qty), 0) + COALESCE(SUM(rs.pending_qty), 0) AS total_qty, LOWER(uoms.name) AS uom_name"). + Joins("JOIN recordings AS r ON r.id = rs.recording_id"). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id"). + Joins("JOIN products AS p ON p.id = pw.product_id"). + Joins("JOIN uoms ON uoms.id = p.uom_id"). + Joins("JOIN flags AS f ON f.flagable_id = p.id AND f.flagable_type = ? AND UPPER(f.name) = ?", entity.FlagableTypeProduct, "PAKAN"). + Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL") + + db = applyDashboardFilters(db, filters) + + if err := db.Group("LOWER(uoms.name)").Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *DashboardRepositoryImpl) SumDepletions(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error) { + var total float64 + + db := r.DB().WithContext(ctx). + Table("recording_depletions AS rd"). + Select("COALESCE(SUM(rd.qty), 0)"). + Joins("JOIN recordings AS r ON r.id = rd.recording_id"). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL") + + db = applyDashboardFilters(db, filters) + + if err := db.Scan(&total).Error; err != nil { + return 0, err + } + + return total, nil +} + +func (r *DashboardRepositoryImpl) SumInitialPopulation(ctx context.Context, endDate time.Time, filters *validation.DashboardFilter) (float64, error) { + var total float64 + endOfDate := endDate.AddDate(0, 0, 1) + + db := r.DB().WithContext(ctx). + Table("project_chickins AS pc"). + Select("COALESCE(SUM(pc.usage_qty), 0)"). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = pc.project_flock_kandang_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Where("pc.chick_in_date < ?", endOfDate). + Where("pc.deleted_at IS NULL") + + db = applyDashboardFilters(db, filters) + + if err := db.Scan(&total).Error; err != nil { + return 0, err + } + + return total, nil +} + +func (r *DashboardRepositoryImpl) SumSellingPrice(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (SellingPriceAggregate, error) { + var result SellingPriceAggregate + + db := r.DB().WithContext(ctx). + Table("marketing_delivery_products AS mdp"). + Select("COALESCE(SUM(mdp.total_price), 0) AS total_price, COALESCE(SUM(mdp.total_weight), 0) AS total_weight"). + Joins("JOIN marketing_products AS mp ON mp.id = mdp.marketing_product_id"). + Joins("JOIN product_warehouses AS pw ON pw.id = mp.product_warehouse_id"). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = pw.project_flock_kandang_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Where("mdp.delivery_date IS NOT NULL"). + Where("mdp.delivery_date >= ? AND mdp.delivery_date < ?", start, end) + + db = applyDashboardFilters(db, filters) + + if err := db.Scan(&result).Error; err != nil { + return SellingPriceAggregate{}, err + } + + return result, nil +} + +func (r *DashboardRepositoryImpl) SumSapronakCost(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error) { + var total float64 + + db := r.DB().WithContext(ctx). + Table("purchase_items AS pi"). + Select("COALESCE(SUM(pi.total_price), 0) AS total"). + Joins("JOIN products AS p ON p.id = pi.product_id"). + Joins("JOIN flags AS f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Joins("LEFT JOIN product_warehouses AS pw ON pw.id = pi.product_warehouse_id"). + Joins("LEFT JOIN project_flock_kandangs AS pfk ON pfk.id = COALESCE(pi.project_flock_kandang_id, pw.project_flock_kandang_id)"). + Joins("LEFT JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Where("f.name IN ?", []utils.FlagType{utils.FlagDOC, utils.FlagPakan, utils.FlagOVK}). + Where("pi.received_date IS NOT NULL"). + Where("pi.received_date >= ? AND pi.received_date < ?", start, end) + + db = applyDashboardFilters(db, filters) + + if err := db.Scan(&total).Error; err != nil { + return 0, err + } + + return total, nil +} + +func (r *DashboardRepositoryImpl) SumBopCost(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error) { + return r.sumExpenseRealization(ctx, start, end, filters, func(db *gorm.DB) *gorm.DB { + return db. + Where("e.category = ?", utils.ExpenseCategoryBOP). + Joins("LEFT JOIN nonstocks AS n ON n.id = en.nonstock_id"). + Joins("LEFT JOIN flags AS f ON f.flagable_id = n.id AND f.flagable_type = ? AND f.name = ?", entity.FlagableTypeNonstock, utils.FlagEkspedisi). + Where("f.id IS NULL") + }) +} + +func (r *DashboardRepositoryImpl) SumEkspedisiCost(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error) { + return r.sumExpenseRealization(ctx, start, end, filters, func(db *gorm.DB) *gorm.DB { + return db. + Joins("JOIN nonstocks AS n ON n.id = en.nonstock_id"). + Joins("JOIN flags AS f ON f.flagable_id = n.id AND f.flagable_type = ?", entity.FlagableTypeNonstock). + Where("f.name = ?", utils.FlagEkspedisi) + }) +} + +func (r *DashboardRepositoryImpl) sumExpenseRealization(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter, modifier func(*gorm.DB) *gorm.DB) (float64, error) { + var total float64 + + db := r.DB().WithContext(ctx). + Table("expense_realizations AS er"). + Select("COALESCE(SUM(er.qty * er.price), 0) AS total"). + Joins("JOIN expense_nonstocks AS en ON en.id = er.expense_nonstock_id"). + Joins("JOIN expenses AS e ON e.id = en.expense_id"). + Joins("LEFT JOIN project_flock_kandangs AS pfk ON pfk.id = en.project_flock_kandang_id"). + Joins("LEFT JOIN kandangs AS k ON k.id = COALESCE(en.kandang_id, pfk.kandang_id)"). + Where("e.realization_date >= ? AND e.realization_date < ?", start, end) + + db = applyDashboardFilters(db, filters) + + if modifier != nil { + db = modifier(db) + } + + if err := db.Scan(&total).Error; err != nil { + return 0, err + } + + return total, nil +} + +func (r *DashboardRepositoryImpl) standardIDSubquery(filters *validation.DashboardFilter) *gorm.DB { + db := r.DB(). + Table("project_flocks AS pf"). + Select("DISTINCT pf.production_standard_id"). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.project_flock_id = pf.id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Where("pf.production_standard_id > 0") + + if filters != nil { + if len(filters.FlockIds) > 0 { + db = db.Where("pf.id IN ?", filters.FlockIds) + } + if len(filters.KandangIds) > 0 { + db = db.Where("k.id IN ?", filters.KandangIds) + } + if len(filters.LokasiIds) > 0 { + db = db.Where("k.location_id IN ?", filters.LokasiIds) + } + } + + return db +} + +func (r *DashboardRepositoryImpl) standardSourceSubquery(filters *validation.DashboardFilter) *gorm.DB { + db := r.DB(). + Table("project_flocks AS pf"). + Select("DISTINCT pf.production_standard_id, pf.fcr_id"). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.project_flock_id = pf.id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Where("pf.production_standard_id > 0"). + Where("pf.fcr_id > 0") + + if filters != nil { + if len(filters.FlockIds) > 0 { + db = db.Where("pf.id IN ?", filters.FlockIds) + } + if len(filters.KandangIds) > 0 { + db = db.Where("k.id IN ?", filters.KandangIds) + } + if len(filters.LokasiIds) > 0 { + db = db.Where("k.location_id IN ?", filters.LokasiIds) + } + } + + return db +} + +func (r *DashboardRepositoryImpl) GetComparisonSeries(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter, comparisonType string) ([]ComparisonSeries, error) { + seriesExpr, labelExpr, groupExpr, orderExpr, err := comparisonSeriesColumns(comparisonType) + if err != nil { + return nil, err + } + + var rows []ComparisonSeries + db := r.DB().WithContext(ctx). + Table("recordings AS r"). + Select(fmt.Sprintf("%s AS id, %s AS label", seriesExpr, labelExpr)). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id"). + Joins("JOIN locations AS loc ON loc.id = k.location_id"). + Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL") + + db = applyDashboardFilters(db, filters) + + if err := db.Group(groupExpr).Order(orderExpr).Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *DashboardRepositoryImpl) GetComparisonWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter, comparisonType, metric string) ([]ComparisonWeeklyMetric, error) { + seriesExpr, _, groupExpr, orderExpr, err := comparisonSeriesColumns(comparisonType) + if err != nil { + return nil, err + } + + metricExpr, err := comparisonMetricColumn(metric) + if err != nil { + return nil, err + } + + var rows []ComparisonWeeklyMetric + db := r.DB().WithContext(ctx). + Table("recordings AS r"). + Select(fmt.Sprintf(`((r.day - 1) / 7 + 1) AS week, + %s AS series_id, + COALESCE(AVG(%s), 0) AS value`, seriesExpr, metricExpr)). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id"). + Joins("JOIN locations AS loc ON loc.id = k.location_id"). + Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL"). + Where("r.day IS NOT NULL AND r.day > 0") + + db = applyDashboardFilters(db, filters) + + groupBy := fmt.Sprintf("week, %s", groupExpr) + orderBy := fmt.Sprintf("week ASC, %s", orderExpr) + if err := db.Group(groupBy).Order(orderBy).Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *DashboardRepositoryImpl) GetComparisonWeeklyUniformityMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter, comparisonType string) ([]ComparisonUniformityMetric, error) { + seriesExpr, _, groupExpr, orderExpr, err := comparisonSeriesColumns(comparisonType) + if err != nil { + return nil, err + } + + var rows []ComparisonUniformityMetric + db := r.DB().WithContext(ctx). + Table("project_flock_kandang_uniformity AS u"). + Select(fmt.Sprintf(`u.week AS week, + %s AS series_id, + COALESCE(AVG(u.uniformity), 0) AS uniformity, + COALESCE(AVG((u.chart_data->'statistics'->>'average_weight')::numeric), 0) AS average_weight`, seriesExpr)). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = u.project_flock_kandang_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id"). + Joins("JOIN locations AS loc ON loc.id = k.location_id"). + Where("u.uniform_date IS NOT NULL"). + Where("u.uniform_date >= ? AND u.uniform_date < ?", start, end) + + db = applyDashboardFilters(db, filters) + + groupBy := fmt.Sprintf("u.week, %s", groupExpr) + orderBy := fmt.Sprintf("u.week ASC, %s", orderExpr) + if err := db.Group(groupBy).Order(orderBy).Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *DashboardRepositoryImpl) GetEggQualityWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]EggQualityWeeklyMetric, error) { + var rows []EggQualityWeeklyMetric + + db := r.DB().WithContext(ctx). + Table("recording_eggs AS re"). + Select(` + ((r.day - 1) / 7 + 1) AS week, + COALESCE(SUM(CASE WHEN f.name = ? THEN re.qty ELSE 0 END), 0) AS normal_qty, + COALESCE(SUM(CASE WHEN f.name IN (?, ?, ?) THEN re.qty ELSE 0 END), 0) AS abnormal_qty, + COALESCE(SUM(re.qty), 0) AS total_qty`, + utils.FlagTelurUtuh, + utils.FlagTelurPutih, + utils.FlagTelurRetak, + utils.FlagTelurPecah, + ). + Joins("JOIN recordings AS r ON r.id = re.recording_id"). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Joins("JOIN product_warehouses AS pw ON pw.id = re.product_warehouse_id"). + Joins("JOIN products AS p ON p.id = pw.product_id"). + Joins("JOIN flags AS f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Where("f.name IN ?", []utils.FlagType{utils.FlagTelurUtuh, utils.FlagTelurPutih, utils.FlagTelurRetak, utils.FlagTelurPecah}). + Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL"). + Where("r.day IS NOT NULL AND r.day > 0") + + db = applyDashboardFilters(db, filters) + + if err := db.Group("week").Order("week ASC").Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *DashboardRepositoryImpl) GetEggWeightWeeklyGrams(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]WeeklyEggWeightMetric, error) { + var rows []WeeklyEggWeightMetric + + db := r.DB().WithContext(ctx). + Table("recording_eggs AS re"). + Select(` + ((r.day - 1) / 7 + 1) AS week, + COALESCE(SUM(re.qty * re.weight), 0) AS egg_weight_grams`). + Joins("JOIN recordings AS r ON r.id = re.recording_id"). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL"). + Where("r.day IS NOT NULL AND r.day > 0") + + db = applyDashboardFilters(db, filters) + + if err := db.Group("week").Order("week ASC").Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *DashboardRepositoryImpl) GetFeedUsageWeeklyByUom(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]WeeklyFeedUsageMetric, error) { + var rows []WeeklyFeedUsageMetric + + db := r.DB().WithContext(ctx). + Table("recording_stocks AS rs"). + Select(` + ((r.day - 1) / 7 + 1) AS week, + COALESCE(SUM(rs.usage_qty), 0) + COALESCE(SUM(rs.pending_qty), 0) AS total_qty, + LOWER(uoms.name) AS uom_name`). + Joins("JOIN recordings AS r ON r.id = rs.recording_id"). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id"). + Joins("JOIN products AS p ON p.id = pw.product_id"). + Joins("JOIN uoms ON uoms.id = p.uom_id"). + Joins("JOIN flags AS f ON f.flagable_id = p.id AND f.flagable_type = ? AND UPPER(f.name) = ?", entity.FlagableTypeProduct, "PAKAN"). + Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL"). + Where("r.day IS NOT NULL AND r.day > 0") + + db = applyDashboardFilters(db, filters) + + if err := db.Group("week, LOWER(uoms.name)").Order("week ASC").Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func comparisonSeriesColumns(comparisonType string) (string, string, string, string, error) { + switch strings.ToUpper(strings.TrimSpace(comparisonType)) { + case validation.ComparisonTypeFarm: + return "loc.id", "loc.name", "loc.id, loc.name", "loc.name", nil + case validation.ComparisonTypeFlock: + return "pf.id", "pf.flock_name", "pf.id, pf.flock_name", "pf.flock_name", nil + case validation.ComparisonTypeKandang: + return "k.id", "k.name", "k.id, k.name", "k.name", nil + default: + return "", "", "", "", fmt.Errorf("invalid comparison_type") + } +} + +func comparisonMetricColumn(metric string) (string, error) { + switch strings.ToLower(strings.TrimSpace(metric)) { + case validation.MetricFcr: + return "r.fcr_value", nil + case validation.MetricMortality: + return "r.cum_depletion_rate", nil + case validation.MetricLaying: + return "r.hen_day", nil + case validation.MetricEggWeight: + return "r.egg_weight", nil + case validation.MetricFeedIntake: + return "r.feed_intake", nil + default: + return "", fmt.Errorf("invalid metric") + } +} diff --git a/internal/modules/dashboards/route.go b/internal/modules/dashboards/route.go new file mode 100644 index 00000000..e4df0a4d --- /dev/null +++ b/internal/modules/dashboards/route.go @@ -0,0 +1,26 @@ +package dashboards + +import ( + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/controllers" + dashboard "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func DashboardRoutes(v1 fiber.Router, u user.UserService, s dashboard.DashboardService) { + ctrl := controller.NewDashboardController(s) + + route := v1.Group("/dashboards") + route.Use(m.Auth(u)) + + // route.Get("/", m.RequirePermissions(m.P_FinanceGetAll), ctrl.GetAll) + // route.Post("/", m.RequirePermissions(m.P_FinanceGetOne), ctrl.CreateOne) + // route.Get("/:id", m.RequirePermissions(m.P_FinanceCreateOne), ctrl.GetOne) + // route.Patch("/:id", m.RequirePermissions(m.P_FinanceUpdateOne), ctrl.UpdateOne) + // route.Delete("/:id", m.RequirePermissions(m.P_FinanceDeleteOne), ctrl.DeleteOne) + + route.Get("/", ctrl.GetAll) + // route.Get("/:id", ctrl.GetOne) +} diff --git a/internal/modules/dashboards/services/dashboard.service.go b/internal/modules/dashboards/services/dashboard.service.go new file mode 100644 index 00000000..6653d669 --- /dev/null +++ b/internal/modules/dashboards/services/dashboard.service.go @@ -0,0 +1,867 @@ +package service + +import ( + "context" + "errors" + "fmt" + "math" + "sort" + "strconv" + "strings" + "time" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/dto" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "github.com/go-playground/validator/v10" + "github.com/sirupsen/logrus" +) + +type DashboardService interface { + GetAll(ctx context.Context, params *validation.Query) (dto.DashboardPerformanceOverviewDTO, int64, error) +} + +type dashboardService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.DashboardRepository +} + +func NewDashboardService(repo repository.DashboardRepository, validate *validator.Validate) DashboardService { + return &dashboardService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + } +} + +func (s dashboardService) GetAll(ctx context.Context, params *validation.Query) (dto.DashboardPerformanceOverviewDTO, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return dto.DashboardPerformanceOverviewDTO{}, 0, err + } + + filter := &validation.DashboardFilter{ + LokasiIds: params.LokasiIds, + FlockIds: params.FlockIds, + KandangIds: params.KandangIds, + } + + statistics, err := s.buildPerformanceStatistics(ctx, params, filter) + if err != nil { + return dto.DashboardPerformanceOverviewDTO{}, 0, err + } + + charts, err := s.buildPerformanceCharts(ctx, params, filter) + if err != nil { + return dto.DashboardPerformanceOverviewDTO{}, 0, err + } + + response := dto.DashboardPerformanceOverviewDTO{ + StatisticsData: statistics, + Charts: charts, + } + + if len(params.Include) > 0 { + include := map[string]bool{} + for _, item := range params.Include { + include[item] = true + } + if !include["statistics"] { + response.StatisticsData = []dto.DashboardStatisticsDTO{} + } + if !include["charts"] { + response.Charts = map[string]dto.DashboardChartDTO{} + } + } + if response.StatisticsData == nil { + response.StatisticsData = []dto.DashboardStatisticsDTO{} + } + if response.Charts == nil { + response.Charts = map[string]dto.DashboardChartDTO{} + } + + return response, 1, nil +} + +func (s dashboardService) buildPerformanceStatistics(ctx context.Context, params *validation.Query, filter *validation.DashboardFilter) ([]dto.DashboardStatisticsDTO, error) { + location, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + return nil, fmt.Errorf("failed to load timezone configuration: %w", err) + } + + if params.PeriodStart.IsZero() || params.PeriodEnd.IsZero() || params.PeriodEndExclusive.IsZero() { + return nil, errors.New("period dates are not initialized") + } + startDate := params.PeriodStart + endDate := params.PeriodEnd + endExclusive := params.PeriodEndExclusive + + hppCurrent, hppLast, err := s.calculateHppGlobal(ctx, filter, startDate, endExclusive, endDate, location) + if err != nil { + return nil, err + } + + sellingCurrent, sellingLast, err := s.calculateSellingPrice(ctx, filter, endDate, location) + if err != nil { + return nil, err + } + + fcrCurrent, fcrLast, err := s.calculateFcr(ctx, filter, startDate, endExclusive, endDate, location) + if err != nil { + return nil, err + } + + mortalityCurrent, mortalityLast, err := s.calculateMortality(ctx, filter, startDate, endExclusive, endDate, location) + if err != nil { + return nil, err + } + + hppPercent := 0.0 + if hppLast > 0 { + hppPercent = (hppCurrent - hppLast) / hppLast * 100 + } + + sellingPercent := 0.0 + if sellingLast > 0 { + sellingPercent = sellingCurrent / sellingLast * 100 + } + + fcrPercent := 0.0 + if fcrLast > 0 { + fcrPercent = (fcrCurrent - fcrLast) / fcrLast * 100 + } + + mortalityPercent := 0.0 + if mortalityLast > 0 { + mortalityPercent = (mortalityCurrent - mortalityLast) / mortalityLast * 100 + } + + return []dto.DashboardStatisticsDTO{ + { + Label: "HPP Global", + Value: roundTo(hppCurrent, 0), + PercentLastMonth: hppPercent, + }, + { + Label: "Avg. Selling Price", + Value: roundTo(sellingCurrent, 0), + PercentLastMonth: sellingPercent, + }, + { + Label: "FCR", + Value: roundTo(fcrCurrent, 2), + PercentLastMonth: fcrPercent, + }, + { + Label: "Mortality", + Value: roundTo(mortalityCurrent, 2), + PercentLastMonth: mortalityPercent, + }, + }, nil +} + +func (s dashboardService) buildPerformanceCharts(ctx context.Context, params *validation.Query, filter *validation.DashboardFilter) (map[string]dto.DashboardChartDTO, error) { + if params.AnalysisMode == validation.AnalysisModeComparison { + return s.buildComparisonCharts(ctx, params, filter) + } + + if params.PeriodStart.IsZero() || params.PeriodEndExclusive.IsZero() { + return nil, errors.New("period dates are not initialized") + } + + startDate := params.PeriodStart + endExclusive := params.PeriodEndExclusive + + recordings, err := s.Repository.GetRecordingWeeklyMetrics(ctx, startDate, endExclusive, filter) + if err != nil { + return nil, err + } + + uniformities, err := s.Repository.GetUniformityWeeklyMetrics(ctx, startDate, endExclusive, filter) + if err != nil { + return nil, err + } + + weekSet := map[int]struct{}{} + for _, row := range recordings { + if row.Week > 0 { + weekSet[row.Week] = struct{}{} + } + } + for _, row := range uniformities { + if row.Week > 0 { + weekSet[row.Week] = struct{}{} + } + } + + weeks := make([]int, 0, len(weekSet)) + for week := range weekSet { + weeks = append(weeks, week) + } + sort.Ints(weeks) + + standards, err := s.Repository.GetStandardWeeklyMetrics(ctx, weeks, filter) + if err != nil { + return nil, err + } + + standardFcr, err := s.Repository.GetStandardFcrWeekly(ctx, weeks, filter) + if err != nil { + return nil, err + } + + recordingMap := map[int]repository.RecordingWeeklyMetric{} + for _, row := range recordings { + recordingMap[row.Week] = row + } + + uniformityMap := map[int]repository.UniformityWeeklyMetric{} + for _, row := range uniformities { + uniformityMap[row.Week] = row + } + + standardMap := map[int]repository.StandardWeeklyMetric{} + for _, row := range standards { + standardMap[row.Week] = row + } + + standardFcrMap := map[int]float64{} + for _, row := range standardFcr { + standardFcrMap[row.Week] = row.StdFcr + } + + weeklyEggs, err := s.Repository.GetEggWeightWeeklyGrams(ctx, startDate, endExclusive, filter) + if err != nil { + return nil, err + } + weeklyFeedRows, err := s.Repository.GetFeedUsageWeeklyByUom(ctx, startDate, endExclusive, filter) + if err != nil { + return nil, err + } + + weeklyEggMap := map[int]float64{} + for _, row := range weeklyEggs { + weeklyEggMap[row.Week] = row.EggWeightGrams + } + + weeklyFeedMap := map[int]float64{} + for _, row := range weeklyFeedRows { + weeklyFeedMap[row.Week] += feedUsageRowToGrams(row.TotalQty, row.UomName) + } + + bodyWeightDataset := make([]map[string]interface{}, 0, len(weeks)) + performanceDataset := make([]map[string]interface{}, 0, len(weeks)) + fcrDataset := make([]map[string]interface{}, 0, len(weeks)) + deplesiDataset := make([]map[string]interface{}, 0, len(weeks)) + qualityDataset := make([]map[string]interface{}, 0, len(weeks)) + + cumEgg := 0.0 + cumFeed := 0.0 + + for _, week := range weeks { + rec := recordingMap[week] + uni := uniformityMap[week] + std := standardMap[week] + stdFcr := standardFcrMap[week] + weekEgg := weeklyEggMap[week] + weekFeed := weeklyFeedMap[week] + + actFcr := 0.0 + if weekFeed > 0 { + actFcr = weekEgg / weekFeed + } + + cumEgg += weekEgg + cumFeed += weekFeed + actFcrCum := 0.0 + if cumFeed > 0 { + actFcrCum = cumEgg / cumFeed + } + + bodyWeightDataset = append(bodyWeightDataset, map[string]interface{}{ + "week": week, + "body_weight": roundTo(uni.AverageWeight, 2), + "std_body_weight": roundTo(std.StdBodyWeight, 2), + }) + + performanceDataset = append(performanceDataset, map[string]interface{}{ + "week": week, + "act_laying": roundTo(rec.HandDay, 2), + "std_laying": roundTo(std.StdLaying, 2), + "act_egg_weight": roundTo(rec.EggWeight, 2), + "std_egg_weight": roundTo(std.StdEggWeight, 2), + "act_feed_intake": roundTo(rec.FeedIntake, 2), + "std_feed_intake": roundTo(std.StdFeedIntake, 2), + "act_uniformity": roundTo(uni.Uniformity, 2), + "std_uniformity": roundTo(std.StdUniformity, 2), + }) + + fcrDataset = append(fcrDataset, map[string]interface{}{ + "week": week, + "act_fcr": roundTo(actFcr, 2), + "std_fcr": roundTo(stdFcr, 2), + "act_fcr_cum": roundTo(actFcrCum, 2), + "std_fcr_cum": roundTo(stdFcr, 2), + }) + + deplesiDataset = append(deplesiDataset, map[string]interface{}{ + "week": week, + "act_deplesi": roundTo(rec.CumDepletionRate, 2), + "std_deplesi": roundTo(std.StdDepletion, 2), + }) + } + + qualityRows, err := s.Repository.GetEggQualityWeeklyMetrics(ctx, startDate, endExclusive, filter) + if err != nil { + return nil, err + } + + for _, row := range qualityRows { + normalPercent := 0.0 + abnormalPercent := 0.0 + if row.TotalQty > 0 { + normalPercent = (row.NormalQty / row.TotalQty) * 100 + abnormalPercent = (row.AbnormalQty / row.TotalQty) * 100 + } + qualityDataset = append(qualityDataset, map[string]interface{}{ + "week": row.Week, + "normal": roundTo(normalPercent, 2), + "abnormal": roundTo(abnormalPercent, 2), + }) + } + + charts := map[string]dto.DashboardChartDTO{ + "body_weight": { + Series: []dto.DashboardChartSeriesDTO{ + {Id: "body_weight", Label: "Body Weight", Unit: "g"}, + {Id: "std_body_weight", Label: "STD. Body Weight", Unit: "g"}, + }, + Dataset: bodyWeightDataset, + }, + "performance": { + Series: []dto.DashboardChartSeriesDTO{ + {Id: "act_laying", Label: "Act. % Laying", Unit: "%"}, + {Id: "std_laying", Label: "STD. % Laying", Unit: "%"}, + {Id: "act_egg_weight", Label: "Act. Egg Weight", Unit: "%"}, + {Id: "std_egg_weight", Label: "STD. Egg Weight", Unit: "%"}, + {Id: "act_feed_intake", Label: "Act. Feed Intake", Unit: "%"}, + {Id: "std_feed_intake", Label: "STD. Feed Intake", Unit: "%"}, + {Id: "act_uniformity", Label: "Act. Uniformity", Unit: "%"}, + {Id: "std_uniformity", Label: "STD. Uniformity", Unit: "%"}, + }, + Dataset: performanceDataset, + }, + "fcr": { + Series: []dto.DashboardChartSeriesDTO{ + {Id: "act_fcr", Label: "Act. FCR", Unit: "%"}, + {Id: "std_fcr", Label: "STD. FCR", Unit: "%"}, + {Id: "act_fcr_cum", Label: "Act. FCR Cummulative", Unit: "%"}, + {Id: "std_fcr_cum", Label: "STD. FCR Cummulative", Unit: "%"}, + }, + Dataset: fcrDataset, + }, + "deplesi": { + Series: []dto.DashboardChartSeriesDTO{ + {Id: "act_deplesi", Label: "Act. Deplesi", Unit: "%"}, + {Id: "std_deplesi", Label: "STD. Deplesi", Unit: "%"}, + }, + Dataset: deplesiDataset, + }, + "quality_control": { + Series: []dto.DashboardChartSeriesDTO{ + {Id: "normal", Label: "Normal", Unit: "%"}, + {Id: "abnormal", Label: "Abnormal", Unit: "%"}, + }, + Dataset: qualityDataset, + }, + } + + return charts, nil +} + +func (s dashboardService) buildComparisonCharts(ctx context.Context, params *validation.Query, filter *validation.DashboardFilter) (map[string]dto.DashboardChartDTO, error) { + if params.PeriodStart.IsZero() || params.PeriodEndExclusive.IsZero() { + return nil, errors.New("period dates are not initialized") + } + + startDate := params.PeriodStart + endExclusive := params.PeriodEndExclusive + + metric := strings.ToLower(strings.TrimSpace(params.Metric)) + if metric == "" { + return s.buildComparisonChartsAll(ctx, startDate, endExclusive, params, filter) + } + + seriesRows, err := s.Repository.GetComparisonSeries(ctx, startDate, endExclusive, filter, params.ComparisonType) + if err != nil { + return nil, err + } + + metricRows, err := s.Repository.GetComparisonWeeklyMetrics(ctx, startDate, endExclusive, filter, params.ComparisonType, metric) + if err != nil { + return nil, err + } + + weeks, actualMap := mapComparisonWeeklyMetricRows(metricRows) + if len(weeks) == 0 { + return map[string]dto.DashboardChartDTO{}, nil + } + + standardMap, err := s.standardComparisonMap(ctx, weeks, metric, filter) + if err != nil { + return nil, err + } + + chart := buildComparisonPercentChart(seriesRows, weeks, actualMap, standardMap) + return map[string]dto.DashboardChartDTO{ + strings.ToLower(params.ComparisonType): chart, + }, nil +} + +func (s dashboardService) buildComparisonChartsAll(ctx context.Context, startDate, endExclusive time.Time, params *validation.Query, filter *validation.DashboardFilter) (map[string]dto.DashboardChartDTO, error) { + seriesRows, err := s.Repository.GetComparisonSeries(ctx, startDate, endExclusive, filter, params.ComparisonType) + if err != nil { + return nil, err + } + + layingRows, err := s.Repository.GetComparisonWeeklyMetrics(ctx, startDate, endExclusive, filter, params.ComparisonType, validation.MetricLaying) + if err != nil { + return nil, err + } + eggWeightRows, err := s.Repository.GetComparisonWeeklyMetrics(ctx, startDate, endExclusive, filter, params.ComparisonType, validation.MetricEggWeight) + if err != nil { + return nil, err + } + feedIntakeRows, err := s.Repository.GetComparisonWeeklyMetrics(ctx, startDate, endExclusive, filter, params.ComparisonType, validation.MetricFeedIntake) + if err != nil { + return nil, err + } + fcrRows, err := s.Repository.GetComparisonWeeklyMetrics(ctx, startDate, endExclusive, filter, params.ComparisonType, validation.MetricFcr) + if err != nil { + return nil, err + } + deplesiRows, err := s.Repository.GetComparisonWeeklyMetrics(ctx, startDate, endExclusive, filter, params.ComparisonType, validation.MetricMortality) + if err != nil { + return nil, err + } + uniformityRows, err := s.Repository.GetComparisonWeeklyUniformityMetrics(ctx, startDate, endExclusive, filter, params.ComparisonType) + if err != nil { + return nil, err + } + + weeks := mergeComparisonWeeks( + layingRows, + eggWeightRows, + feedIntakeRows, + fcrRows, + deplesiRows, + uniformityRows, + ) + if len(weeks) == 0 { + return map[string]dto.DashboardChartDTO{}, nil + } + + standards, err := s.Repository.GetStandardWeeklyMetrics(ctx, weeks, filter) + if err != nil { + return nil, err + } + standardFcr, err := s.Repository.GetStandardFcrWeekly(ctx, weeks, filter) + if err != nil { + return nil, err + } + + stdBodyWeight := map[int]float64{} + stdLaying := map[int]float64{} + stdEggWeight := map[int]float64{} + stdFeedIntake := map[int]float64{} + stdUniformity := map[int]float64{} + stdDeplesi := map[int]float64{} + for _, row := range standards { + stdBodyWeight[row.Week] = row.StdBodyWeight + stdLaying[row.Week] = row.StdLaying + stdEggWeight[row.Week] = row.StdEggWeight + stdFeedIntake[row.Week] = row.StdFeedIntake + stdUniformity[row.Week] = row.StdUniformity + stdDeplesi[row.Week] = row.StdDepletion + } + + stdFcr := map[int]float64{} + for _, row := range standardFcr { + stdFcr[row.Week] = row.StdFcr + } + + layingWeeks, layingActual := mapComparisonWeeklyMetricRows(layingRows) + eggWeightWeeks, eggWeightActual := mapComparisonWeeklyMetricRows(eggWeightRows) + feedWeeks, feedActual := mapComparisonWeeklyMetricRows(feedIntakeRows) + fcrWeeks, fcrActual := mapComparisonWeeklyMetricRows(fcrRows) + deplesiWeeks, deplesiActual := mapComparisonWeeklyMetricRows(deplesiRows) + bodyWeightWeeks, bodyWeightActual, uniformityWeeks, uniformityActual := mapComparisonUniformityRows(uniformityRows) + + charts := map[string]dto.DashboardChartDTO{} + if len(bodyWeightWeeks) > 0 { + charts["body_weight"] = buildComparisonPercentChart(seriesRows, bodyWeightWeeks, bodyWeightActual, stdBodyWeight) + } + if len(layingWeeks) > 0 { + charts["laying"] = buildComparisonPercentChart(seriesRows, layingWeeks, layingActual, stdLaying) + } + if len(eggWeightWeeks) > 0 { + charts["egg_weight"] = buildComparisonPercentChart(seriesRows, eggWeightWeeks, eggWeightActual, stdEggWeight) + } + if len(feedWeeks) > 0 { + charts["feed_intake"] = buildComparisonPercentChart(seriesRows, feedWeeks, feedActual, stdFeedIntake) + } + if len(uniformityWeeks) > 0 { + charts["uniformity"] = buildComparisonPercentChart(seriesRows, uniformityWeeks, uniformityActual, stdUniformity) + } + if len(fcrWeeks) > 0 { + charts["fcr"] = buildComparisonPercentChart(seriesRows, fcrWeeks, fcrActual, stdFcr) + } + if len(deplesiWeeks) > 0 { + charts["deplesi"] = buildComparisonPercentChart(seriesRows, deplesiWeeks, deplesiActual, stdDeplesi) + } + + return charts, nil +} + +func (s dashboardService) standardComparisonMap(ctx context.Context, weeks []int, metric string, filter *validation.DashboardFilter) (map[int]float64, error) { + switch metric { + case validation.MetricFcr: + rows, err := s.Repository.GetStandardFcrWeekly(ctx, weeks, filter) + if err != nil { + return nil, err + } + result := map[int]float64{} + for _, row := range rows { + result[row.Week] = row.StdFcr + } + return result, nil + case validation.MetricLaying, validation.MetricEggWeight, validation.MetricFeedIntake, validation.MetricMortality: + rows, err := s.Repository.GetStandardWeeklyMetrics(ctx, weeks, filter) + if err != nil { + return nil, err + } + result := map[int]float64{} + for _, row := range rows { + switch metric { + case validation.MetricLaying: + result[row.Week] = row.StdLaying + case validation.MetricEggWeight: + result[row.Week] = row.StdEggWeight + case validation.MetricFeedIntake: + result[row.Week] = row.StdFeedIntake + case validation.MetricMortality: + result[row.Week] = row.StdDepletion + } + } + return result, nil + default: + return map[int]float64{}, nil + } +} + +func mapComparisonWeeklyMetricRows(rows []repository.ComparisonWeeklyMetric) ([]int, map[int]map[uint]float64) { + weekSet := map[int]struct{}{} + values := map[int]map[uint]float64{} + for _, row := range rows { + if row.Week <= 0 { + continue + } + weekSet[row.Week] = struct{}{} + if values[row.Week] == nil { + values[row.Week] = map[uint]float64{} + } + values[row.Week][row.SeriesId] = row.Value + } + + weeks := make([]int, 0, len(weekSet)) + for week := range weekSet { + weeks = append(weeks, week) + } + sort.Ints(weeks) + return weeks, values +} + +func mapComparisonUniformityRows(rows []repository.ComparisonUniformityMetric) ([]int, map[int]map[uint]float64, []int, map[int]map[uint]float64) { + bodyWeightSet := map[int]struct{}{} + bodyWeightValues := map[int]map[uint]float64{} + uniformitySet := map[int]struct{}{} + uniformityValues := map[int]map[uint]float64{} + + for _, row := range rows { + if row.Week <= 0 { + continue + } + bodyWeightSet[row.Week] = struct{}{} + uniformitySet[row.Week] = struct{}{} + if bodyWeightValues[row.Week] == nil { + bodyWeightValues[row.Week] = map[uint]float64{} + } + if uniformityValues[row.Week] == nil { + uniformityValues[row.Week] = map[uint]float64{} + } + bodyWeightValues[row.Week][row.SeriesId] = row.AverageWeight + uniformityValues[row.Week][row.SeriesId] = row.Uniformity + } + + bodyWeightWeeks := make([]int, 0, len(bodyWeightSet)) + for week := range bodyWeightSet { + bodyWeightWeeks = append(bodyWeightWeeks, week) + } + sort.Ints(bodyWeightWeeks) + + uniformityWeeks := make([]int, 0, len(uniformitySet)) + for week := range uniformitySet { + uniformityWeeks = append(uniformityWeeks, week) + } + sort.Ints(uniformityWeeks) + + return bodyWeightWeeks, bodyWeightValues, uniformityWeeks, uniformityValues +} + +func mergeComparisonWeeks(rows ...interface{}) []int { + weekSet := map[int]struct{}{} + for _, row := range rows { + switch typed := row.(type) { + case []repository.ComparisonWeeklyMetric: + for _, item := range typed { + if item.Week > 0 { + weekSet[item.Week] = struct{}{} + } + } + case []repository.ComparisonUniformityMetric: + for _, item := range typed { + if item.Week > 0 { + weekSet[item.Week] = struct{}{} + } + } + } + } + + weeks := make([]int, 0, len(weekSet)) + for week := range weekSet { + weeks = append(weeks, week) + } + sort.Ints(weeks) + return weeks +} + +func buildComparisonPercentChart(seriesRows []repository.ComparisonSeries, weeks []int, actual map[int]map[uint]float64, standard map[int]float64) dto.DashboardChartDTO { + series := make([]dto.DashboardChartSeriesDTO, 0, len(seriesRows)) + for _, row := range seriesRows { + series = append(series, dto.DashboardChartSeriesDTO{ + Id: strconv.FormatUint(uint64(row.Id), 10), + Label: row.Label, + Unit: "%", + }) + } + + dataset := make([]map[string]interface{}, 0, len(weeks)) + for _, week := range weeks { + row := map[string]interface{}{ + "week": week, + } + std := standard[week] + for _, sRow := range seriesRows { + key := strconv.FormatUint(uint64(sRow.Id), 10) + actualVal := actual[week][sRow.Id] + percent := 0.0 + if std > 0 { + percent = (actualVal / std) * 100 + } + row[key] = roundTo(percent, 2) + } + dataset = append(dataset, row) + } + + return dto.DashboardChartDTO{ + Series: series, + Dataset: dataset, + } +} + +func (s dashboardService) calculateHppGlobal(ctx context.Context, filter *validation.DashboardFilter, startDate, endExclusive, endDate time.Time, location *time.Location) (float64, float64, error) { + totalEggKg, err := s.Repository.SumEggProductionWeightKg(ctx, startDate, endExclusive, filter) + if err != nil { + return 0, 0, err + } + totalCost, err := s.sumHppCost(ctx, filter, startDate, endExclusive) + if err != nil { + return 0, 0, err + } + + hppCurrent := 0.0 + if totalEggKg > 0 { + hppCurrent = totalCost / totalEggKg + } + + lastMonthStart, lastMonthEndExclusive := monthRange(endDate.AddDate(0, -1, 0), location) + lastEggKg, err := s.Repository.SumEggProductionWeightKg(ctx, lastMonthStart, lastMonthEndExclusive, filter) + if err != nil { + return 0, 0, err + } + lastCost, err := s.sumHppCost(ctx, filter, lastMonthStart, lastMonthEndExclusive) + if err != nil { + return 0, 0, err + } + + hppLast := 0.0 + if lastEggKg > 0 { + hppLast = lastCost / lastEggKg + } + + return hppCurrent, hppLast, nil +} + +func (s dashboardService) calculateSellingPrice(ctx context.Context, filter *validation.DashboardFilter, endDate time.Time, location *time.Location) (float64, float64, error) { + startPrevMonth, endPrevMonthExclusive := monthRange(endDate.AddDate(0, -1, 0), location) + currentEndExclusive := endDate.AddDate(0, 0, 1) + + currentAvg, err := s.avgSellingPrice(ctx, filter, startPrevMonth, currentEndExclusive) + if err != nil { + return 0, 0, err + } + + lastAvg, err := s.avgSellingPrice(ctx, filter, startPrevMonth, endPrevMonthExclusive) + if err != nil { + return 0, 0, err + } + + return currentAvg, lastAvg, nil +} + +func (s dashboardService) calculateFcr(ctx context.Context, filter *validation.DashboardFilter, startDate, endExclusive, endDate time.Time, location *time.Location) (float64, float64, error) { + current, err := s.fcrValue(ctx, filter, startDate, endExclusive) + if err != nil { + return 0, 0, err + } + + lastMonthStart, lastMonthEndExclusive := monthRange(endDate.AddDate(0, -1, 0), location) + last, err := s.fcrValue(ctx, filter, lastMonthStart, lastMonthEndExclusive) + if err != nil { + return 0, 0, err + } + + return current, last, nil +} + +func (s dashboardService) calculateMortality(ctx context.Context, filter *validation.DashboardFilter, startDate, endExclusive, endDate time.Time, location *time.Location) (float64, float64, error) { + current, err := s.mortalityValue(ctx, filter, startDate, endExclusive) + if err != nil { + return 0, 0, err + } + + lastMonthStart, lastMonthEndExclusive := monthRange(endDate.AddDate(0, -1, 0), location) + last, err := s.mortalityValue(ctx, filter, lastMonthStart, lastMonthEndExclusive) + if err != nil { + return 0, 0, err + } + + return current, last, nil +} + +func (s dashboardService) fcrValue(ctx context.Context, filter *validation.DashboardFilter, startDate, endExclusive time.Time) (float64, error) { + eggWeightGrams, err := s.Repository.SumEggProductionWeightGrams(ctx, startDate, endExclusive, filter) + if err != nil { + return 0, err + } + + feedRows, err := s.Repository.GetFeedUsageByUom(ctx, startDate, endExclusive, filter) + if err != nil { + return 0, err + } + feedUsageGrams := feedUsageToGrams(feedRows) + + if feedUsageGrams <= 0 { + return 0, nil + } + + return eggWeightGrams / feedUsageGrams, nil +} + +func (s dashboardService) mortalityValue(ctx context.Context, filter *validation.DashboardFilter, startDate, endExclusive time.Time) (float64, error) { + depletions, err := s.Repository.SumDepletions(ctx, startDate, endExclusive, filter) + if err != nil { + return 0, err + } + + initialPopulation, err := s.Repository.SumInitialPopulation(ctx, endExclusive.AddDate(0, 0, -1), filter) + if err != nil { + return 0, err + } + + if initialPopulation <= 0 { + return 0, nil + } + + return (depletions / initialPopulation) * 100, nil +} + +func (s dashboardService) sumHppCost(ctx context.Context, filter *validation.DashboardFilter, startDate, endExclusive time.Time) (float64, error) { + sapronak, err := s.Repository.SumSapronakCost(ctx, startDate, endExclusive, filter) + if err != nil { + return 0, err + } + + bop, err := s.Repository.SumBopCost(ctx, startDate, endExclusive, filter) + if err != nil { + return 0, err + } + + ekspedisi, err := s.Repository.SumEkspedisiCost(ctx, startDate, endExclusive, filter) + if err != nil { + return 0, err + } + + return sapronak + bop + ekspedisi, nil +} + +func (s dashboardService) avgSellingPrice(ctx context.Context, filter *validation.DashboardFilter, startDate, endExclusive time.Time) (float64, error) { + result, err := s.Repository.SumSellingPrice(ctx, startDate, endExclusive, filter) + if err != nil { + return 0, err + } + + if result.TotalWeight <= 0 { + return 0, nil + } + + return result.TotalPrice / result.TotalWeight, nil +} + +func feedUsageToGrams(rows []repository.FeedUsageByUom) float64 { + total := 0.0 + for _, row := range rows { + total += feedUsageRowToGrams(row.TotalQty, row.UomName) + } + return total +} + +func feedUsageRowToGrams(totalQty float64, uomName string) float64 { + if totalQty <= 0 { + return 0 + } + switch strings.TrimSpace(strings.ToLower(uomName)) { + case "kilogram", "kg", "kilograms", "kilo": + return totalQty * 1000 + case "gram", "g", "grams": + return totalQty + default: + return totalQty + } +} + +func roundTo(value float64, decimals int) float64 { + if decimals <= 0 { + return math.Round(value) + } + multiplier := math.Pow(10, float64(decimals)) + return math.Round(value*multiplier) / multiplier +} + +func monthRange(t time.Time, location *time.Location) (time.Time, time.Time) { + start := time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, location) + endExclusive := start.AddDate(0, 1, 0) + return start, endExclusive +} diff --git a/internal/modules/dashboards/validations/dashboard.validation.go b/internal/modules/dashboards/validations/dashboard.validation.go new file mode 100644 index 00000000..7e582a4f --- /dev/null +++ b/internal/modules/dashboards/validations/dashboard.validation.go @@ -0,0 +1,54 @@ +package validation + +import "time" + +type Create struct { + Name string `json:"name" validate:"required_strict,min=3"` +} + +const ( + AnalysisModeOverview = "OVERVIEW" + AnalysisModeComparison = "COMPARASION" + + ComparisonTypeFarm = "FARM" + ComparisonTypeFlock = "FLOCK" + ComparisonTypeKandang = "KANDANG" + + MetricFcr = "fcr" + MetricMortality = "mortality" + MetricLaying = "laying" + MetricEggWeight = "egg_weight" + MetricFeedIntake = "feed_intake" +) + +type Update struct { + Name *string `json:"name,omitempty" validate:"omitempty"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` + PerformanceOverviewFilter + PeriodStart time.Time `json:"-" query:"-"` + PeriodEnd time.Time `json:"-" query:"-"` + PeriodEndExclusive time.Time `json:"-" query:"-"` +} + +type PerformanceOverviewFilter struct { + StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"` + EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"` + AnalysisMode string `query:"analysis_mode" validate:"omitempty,oneof=OVERVIEW COMPARASION"` + ComparisonType string `query:"comparison_type" validate:"omitempty,oneof=FARM FLOCK KANDANG"` + Metric string `query:"metric" validate:"omitempty,oneof=fcr mortality laying egg_weight feed_intake"` + LokasiIds []uint `query:"lokasi_ids" validate:"omitempty,dive,gt=0"` + FlockIds []uint `query:"flock_ids" validate:"omitempty,dive,gt=0"` + KandangIds []uint `query:"kandang_ids" validate:"omitempty,dive,gt=0"` + Include []string `query:"include" validate:"omitempty,dive,oneof=statistics charts"` +} + +type DashboardFilter struct { + LokasiIds []uint + FlockIds []uint + KandangIds []uint +} diff --git a/internal/response/response.go b/internal/response/response.go index 710d320e..a6bc087f 100644 --- a/internal/response/response.go +++ b/internal/response/response.go @@ -29,6 +29,14 @@ type SuccessWithPaginate[T any] struct { Data []T `json:"data"` } +type SuccessWithMeta struct { + Code int `json:"code"` + Status string `json:"status"` + Message string `json:"message"` + Meta Meta `json:"meta"` + Data interface{} `json:"data"` +} + type ErrorDetails struct { Code int `json:"code"` Status string `json:"status"` diff --git a/internal/route/route.go b/internal/route/route.go index 519ea5aa..71682d2b 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -22,6 +22,7 @@ import ( repports "gitlab.com/mbugroup/lti-api.git/internal/modules/repports" ssoModule "gitlab.com/mbugroup/lti-api.git/internal/modules/sso" users "gitlab.com/mbugroup/lti-api.git/internal/modules/users" + dashboards "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards" // MODULE IMPORTS ) @@ -48,6 +49,7 @@ func Routes(app *fiber.App, db *gorm.DB) { repports.RepportModule{}, finance.FinanceModule{}, dailyChecklists.DailyChecklistModule{}, + dashboards.DashboardModule{}, // MODULE REGISTRY } From a54129866e1f680fb9232d87f804452a4264edc5 Mon Sep 17 00:00:00 2001 From: ragilap Date: Sun, 11 Jan 2026 18:35:23 +0700 Subject: [PATCH 04/18] feat(BE-390): adjustment calculate dashboard --- .../dashboards/repositories/dashboard_stats.repository.go | 4 ++-- internal/modules/dashboards/services/dashboard.service.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/modules/dashboards/repositories/dashboard_stats.repository.go b/internal/modules/dashboards/repositories/dashboard_stats.repository.go index a06fdb71..948c1b56 100644 --- a/internal/modules/dashboards/repositories/dashboard_stats.repository.go +++ b/internal/modules/dashboards/repositories/dashboard_stats.repository.go @@ -24,7 +24,7 @@ type FeedUsageByUom struct { type RecordingWeeklyMetric struct { Week int - HandDay float64 + HenDay float64 EggWeight float64 FeedIntake float64 FcrValue float64 @@ -110,7 +110,7 @@ func (r *DashboardRepositoryImpl) GetRecordingWeeklyMetrics(ctx context.Context, db := r.DB().WithContext(ctx). Table("recordings AS r"). Select(`((r.day - 1) / 7 + 1) AS week, - COALESCE(AVG(r.hen_day), 0) AS hand_day, + COALESCE(AVG(r.hen_day), 0) AS hen_day, COALESCE(AVG(r.egg_weight), 0) AS egg_weight, COALESCE(AVG(r.feed_intake), 0) AS feed_intake, COALESCE(AVG(r.fcr_value), 0) AS fcr_value, diff --git a/internal/modules/dashboards/services/dashboard.service.go b/internal/modules/dashboards/services/dashboard.service.go index 6653d669..614307d3 100644 --- a/internal/modules/dashboards/services/dashboard.service.go +++ b/internal/modules/dashboards/services/dashboard.service.go @@ -288,7 +288,7 @@ func (s dashboardService) buildPerformanceCharts(ctx context.Context, params *va performanceDataset = append(performanceDataset, map[string]interface{}{ "week": week, - "act_laying": roundTo(rec.HandDay, 2), + "act_laying": roundTo(rec.HenDay, 2), "std_laying": roundTo(std.StdLaying, 2), "act_egg_weight": roundTo(rec.EggWeight, 2), "std_egg_weight": roundTo(std.StdEggWeight, 2), From dc7dc0ba475e5d2e802fc74189f07aaec9f8b8e5 Mon Sep 17 00:00:00 2001 From: ragilap Date: Sun, 11 Jan 2026 18:54:05 +0700 Subject: [PATCH 05/18] adjustment meta --- .../controllers/dashboard.controller.go | 37 ++++++--- .../modules/dashboards/dto/dashboard.dto.go | 2 +- .../dashboards/services/dashboard.service.go | 75 +++++++++++-------- .../validations/dashboard.validation.go | 2 +- 4 files changed, 72 insertions(+), 44 deletions(-) diff --git a/internal/modules/dashboards/controllers/dashboard.controller.go b/internal/modules/dashboards/controllers/dashboard.controller.go index 47e8d5bc..bebad10f 100644 --- a/internal/modules/dashboards/controllers/dashboard.controller.go +++ b/internal/modules/dashboards/controllers/dashboard.controller.go @@ -61,9 +61,9 @@ func (u *DashboardController) GetAll(c *fiber.Ctx) error { return ids, nil } - lokasiIds, err := parseUintListParam(c.Query("lokasi_ids", "")) + lokasiIds, err := parseUintListParam(c.Query("location_ids", "")) if err != nil { - return fiber.NewError(fiber.StatusBadRequest, "Invalid lokasi_ids") + return fiber.NewError(fiber.StatusBadRequest, "Invalid location_ids") } flockIds, err := parseUintListParam(c.Query("flock_ids", "")) @@ -128,16 +128,29 @@ func (u *DashboardController) GetAll(c *fiber.Ctx) error { return err } - filters := dto.DashboardFiltersDTO{ - StartDate: query.StartDate, - EndDate: query.EndDate, - AnalysisMode: query.AnalysisMode, - ComparisonType: query.ComparisonType, - Metric: query.Metric, - LokasiIds: defaultUintSlice(query.LokasiIds), - FlockIds: defaultUintSlice(query.FlockIds), - KandangIds: defaultUintSlice(query.KandangIds), - Include: query.Include, + hasFilter := query.StartDate != "" || + query.EndDate != "" || + len(query.LokasiIds) > 0 || + len(query.FlockIds) > 0 || + len(query.KandangIds) > 0 || + len(query.Include) > 0 || + query.ComparisonType != "" || + query.Metric != "" || + query.AnalysisMode != validation.AnalysisModeOverview + + var filters interface{} + if hasFilter { + filters = dto.DashboardFiltersDTO{ + StartDate: query.StartDate, + EndDate: query.EndDate, + AnalysisMode: query.AnalysisMode, + ComparisonType: query.ComparisonType, + Metric: query.Metric, + LokasiIds: defaultUintSlice(query.LokasiIds), + FlockIds: defaultUintSlice(query.FlockIds), + KandangIds: defaultUintSlice(query.KandangIds), + Include: query.Include, + } } return c.Status(fiber.StatusOK). diff --git a/internal/modules/dashboards/dto/dashboard.dto.go b/internal/modules/dashboards/dto/dashboard.dto.go index bf7d0f91..affa02a6 100644 --- a/internal/modules/dashboards/dto/dashboard.dto.go +++ b/internal/modules/dashboards/dto/dashboard.dto.go @@ -27,7 +27,7 @@ type DashboardFiltersDTO struct { AnalysisMode string `json:"analysis_mode"` ComparisonType string `json:"comparison_type,omitempty"` Metric string `json:"metric,omitempty"` - LokasiIds []uint `json:"lokasi_ids"` + LokasiIds []uint `json:"location_ids"` FlockIds []uint `json:"flock_ids"` KandangIds []uint `json:"kandang_ids"` Include []string `json:"include,omitempty"` diff --git a/internal/modules/dashboards/services/dashboard.service.go b/internal/modules/dashboards/services/dashboard.service.go index 614307d3..7c083c98 100644 --- a/internal/modules/dashboards/services/dashboard.service.go +++ b/internal/modules/dashboards/services/dashboard.service.go @@ -108,14 +108,20 @@ func (s dashboardService) buildPerformanceStatistics(ctx context.Context, params return nil, err } - fcrCurrent, fcrLast, err := s.calculateFcr(ctx, filter, startDate, endExclusive, endDate, location) - if err != nil { - return nil, err - } - - mortalityCurrent, mortalityLast, err := s.calculateMortality(ctx, filter, startDate, endExclusive, endDate, location) - if err != nil { - return nil, err + hasFilter := filter != nil && (len(filter.LokasiIds) > 0 || len(filter.FlockIds) > 0 || len(filter.KandangIds) > 0) + fcrCurrent := 0.0 + fcrLast := 0.0 + mortalityCurrent := 0.0 + mortalityLast := 0.0 + if hasFilter { + fcrCurrent, fcrLast, err = s.calculateFcr(ctx, filter, startDate, endExclusive, endDate, location) + if err != nil { + return nil, err + } + mortalityCurrent, mortalityLast, err = s.calculateMortality(ctx, filter, startDate, endExclusive, endDate, location) + if err != nil { + return nil, err + } } hppPercent := 0.0 @@ -128,17 +134,7 @@ func (s dashboardService) buildPerformanceStatistics(ctx context.Context, params sellingPercent = sellingCurrent / sellingLast * 100 } - fcrPercent := 0.0 - if fcrLast > 0 { - fcrPercent = (fcrCurrent - fcrLast) / fcrLast * 100 - } - - mortalityPercent := 0.0 - if mortalityLast > 0 { - mortalityPercent = (mortalityCurrent - mortalityLast) / mortalityLast * 100 - } - - return []dto.DashboardStatisticsDTO{ + stats := []dto.DashboardStatisticsDTO{ { Label: "HPP Global", Value: roundTo(hppCurrent, 0), @@ -149,17 +145,32 @@ func (s dashboardService) buildPerformanceStatistics(ctx context.Context, params Value: roundTo(sellingCurrent, 0), PercentLastMonth: sellingPercent, }, - { - Label: "FCR", - Value: roundTo(fcrCurrent, 2), - PercentLastMonth: fcrPercent, - }, - { - Label: "Mortality", - Value: roundTo(mortalityCurrent, 2), - PercentLastMonth: mortalityPercent, - }, - }, nil + } + + if hasFilter { + fcrPercent := 0.0 + if fcrLast > 0 { + fcrPercent = (fcrCurrent - fcrLast) / fcrLast * 100 + } + mortalityPercent := 0.0 + if mortalityLast > 0 { + mortalityPercent = (mortalityCurrent - mortalityLast) / mortalityLast * 100 + } + stats = append(stats, + dto.DashboardStatisticsDTO{ + Label: "FCR", + Value: roundTo(fcrCurrent, 2), + PercentLastMonth: fcrPercent, + }, + dto.DashboardStatisticsDTO{ + Label: "Mortality", + Value: roundTo(mortalityCurrent, 2), + PercentLastMonth: mortalityPercent, + }, + ) + } + + return stats, nil } func (s dashboardService) buildPerformanceCharts(ctx context.Context, params *validation.Query, filter *validation.DashboardFilter) (map[string]dto.DashboardChartDTO, error) { @@ -171,6 +182,10 @@ func (s dashboardService) buildPerformanceCharts(ctx context.Context, params *va return nil, errors.New("period dates are not initialized") } + if filter == nil || (len(filter.LokasiIds) == 0 && len(filter.FlockIds) == 0 && len(filter.KandangIds) == 0) { + return map[string]dto.DashboardChartDTO{}, nil + } + startDate := params.PeriodStart endExclusive := params.PeriodEndExclusive diff --git a/internal/modules/dashboards/validations/dashboard.validation.go b/internal/modules/dashboards/validations/dashboard.validation.go index 7e582a4f..dcd698c7 100644 --- a/internal/modules/dashboards/validations/dashboard.validation.go +++ b/internal/modules/dashboards/validations/dashboard.validation.go @@ -41,7 +41,7 @@ type PerformanceOverviewFilter struct { AnalysisMode string `query:"analysis_mode" validate:"omitempty,oneof=OVERVIEW COMPARASION"` ComparisonType string `query:"comparison_type" validate:"omitempty,oneof=FARM FLOCK KANDANG"` Metric string `query:"metric" validate:"omitempty,oneof=fcr mortality laying egg_weight feed_intake"` - LokasiIds []uint `query:"lokasi_ids" validate:"omitempty,dive,gt=0"` + LokasiIds []uint `query:"location_ids" validate:"omitempty,dive,gt=0"` FlockIds []uint `query:"flock_ids" validate:"omitempty,dive,gt=0"` KandangIds []uint `query:"kandang_ids" validate:"omitempty,dive,gt=0"` Include []string `query:"include" validate:"omitempty,dive,oneof=statistics charts"` From 473f4504ea4252986dd38341a94f37d117f2460b Mon Sep 17 00:00:00 2001 From: ragilap Date: Sun, 11 Jan 2026 19:09:43 +0700 Subject: [PATCH 06/18] feat(BE-309): changes COMPARASION TO COMPARISON --- .../modules/dashboards/validations/dashboard.validation.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/modules/dashboards/validations/dashboard.validation.go b/internal/modules/dashboards/validations/dashboard.validation.go index dcd698c7..b372f493 100644 --- a/internal/modules/dashboards/validations/dashboard.validation.go +++ b/internal/modules/dashboards/validations/dashboard.validation.go @@ -8,7 +8,7 @@ type Create struct { const ( AnalysisModeOverview = "OVERVIEW" - AnalysisModeComparison = "COMPARASION" + AnalysisModeComparison = "COMPARISON" ComparisonTypeFarm = "FARM" ComparisonTypeFlock = "FLOCK" @@ -38,7 +38,7 @@ type Query struct { type PerformanceOverviewFilter struct { StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"` EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"` - AnalysisMode string `query:"analysis_mode" validate:"omitempty,oneof=OVERVIEW COMPARASION"` + AnalysisMode string `query:"analysis_mode" validate:"omitempty,oneof=OVERVIEW COMPARISON"` ComparisonType string `query:"comparison_type" validate:"omitempty,oneof=FARM FLOCK KANDANG"` Metric string `query:"metric" validate:"omitempty,oneof=fcr mortality laying egg_weight feed_intake"` LokasiIds []uint `query:"location_ids" validate:"omitempty,dive,gt=0"` From 167d18fe87720bc7bbec9fae0bad3f9156b9c5cb Mon Sep 17 00:00:00 2001 From: ragilap Date: Sun, 11 Jan 2026 19:26:25 +0700 Subject: [PATCH 07/18] feat(BE-309): add permission dashboard --- internal/middleware/permissions.go | 3 +++ internal/modules/dashboards/route.go | 10 +--------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/internal/middleware/permissions.go b/internal/middleware/permissions.go index e9148927..d04c8ac6 100644 --- a/internal/middleware/permissions.go +++ b/internal/middleware/permissions.go @@ -1,5 +1,8 @@ package middleware +const( + P_DashboardGetAll = "lti.dashboard.list" +) // project-flock const ( P_ProjectFlockKandangsClosing = "lti.production.project_flock_kandangs.closing" diff --git a/internal/modules/dashboards/route.go b/internal/modules/dashboards/route.go index e4df0a4d..34f2d00b 100644 --- a/internal/modules/dashboards/route.go +++ b/internal/modules/dashboards/route.go @@ -14,13 +14,5 @@ func DashboardRoutes(v1 fiber.Router, u user.UserService, s dashboard.DashboardS route := v1.Group("/dashboards") route.Use(m.Auth(u)) - - // route.Get("/", m.RequirePermissions(m.P_FinanceGetAll), ctrl.GetAll) - // route.Post("/", m.RequirePermissions(m.P_FinanceGetOne), ctrl.CreateOne) - // route.Get("/:id", m.RequirePermissions(m.P_FinanceCreateOne), ctrl.GetOne) - // route.Patch("/:id", m.RequirePermissions(m.P_FinanceUpdateOne), ctrl.UpdateOne) - // route.Delete("/:id", m.RequirePermissions(m.P_FinanceDeleteOne), ctrl.DeleteOne) - - route.Get("/", ctrl.GetAll) - // route.Get("/:id", ctrl.GetOne) + route.Get("/",m.RequirePermissions(m.P_DashboardGetAll) ,ctrl.GetAll) } From 3422fceec759cf140ac7eef58b2a166daea122fa Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Sun, 11 Jan 2026 20:10:19 +0700 Subject: [PATCH 08/18] feat(BE-ExpenseApproval): add unit vice president approval step and permissions --- internal/middleware/permissions.go | 25 +++++++------- .../controllers/expense.controller.go | 2 ++ internal/modules/expenses/route.go | 3 ++ .../expenses/services/expense.service.go | 17 +++++++--- .../services/transfer_expense_bridge.go | 34 +++++++------------ .../purchases/services/expense_bridge.go | 3 ++ internal/utils/constant.go | 24 +++++++------ 7 files changed, 60 insertions(+), 48 deletions(-) diff --git a/internal/middleware/permissions.go b/internal/middleware/permissions.go index e9148927..5820db27 100644 --- a/internal/middleware/permissions.go +++ b/internal/middleware/permissions.go @@ -19,18 +19,19 @@ const ( ) const ( - P_ExpenseGetAll = "lti.expense.list" - P_ExpenseCreateOne = "lti.expense.create" - P_ExpenseUpdateOne = "lti.expense.update" - P_ExpenseGetOne = "lti.expense.detail" - P_ExpenseDeleteOne = "lti.expense.delete" - P_ExpenseApprovalManager = "lti.expense.approve.manager" - P_ExpenseApprovalFinance = "lti.expense.approve.finance" - P_ExpenseCreateRealizations = "lti.expense.create.realization" - P_ExpenseUpdateRealizations = "lti.expense.update.realization" - P_ExpenseCompleteExpense = "lti.expense.complete.expense" - P_ExpenseDocument = "lti.expense.document" - P_ExpenseDocumentRealizations = "lti.expense.document.realization" + P_ExpenseGetAll = "lti.expense.list" + P_ExpenseCreateOne = "lti.expense.create" + P_ExpenseUpdateOne = "lti.expense.update" + P_ExpenseGetOne = "lti.expense.detail" + P_ExpenseDeleteOne = "lti.expense.delete" + P_ExpenseApprovalManager = "lti.expense.approve.manager" + P_ExpenseApprovalFinance = "lti.expense.approve.finance" + P_ExpenseApprovalUnitVicePresident = "lti.expense.approve.unit_vice_president" + P_ExpenseCreateRealizations = "lti.expense.create.realization" + P_ExpenseUpdateRealizations = "lti.expense.update.realization" + P_ExpenseCompleteExpense = "lti.expense.complete.expense" + P_ExpenseDocument = "lti.expense.document" + P_ExpenseDocumentRealizations = "lti.expense.document.realization" ) const ( P_AdjustmentGetAll = "lti.inventory.list" diff --git a/internal/modules/expenses/controllers/expense.controller.go b/internal/modules/expenses/controllers/expense.controller.go index 666642ca..125aeb0c 100644 --- a/internal/modules/expenses/controllers/expense.controller.go +++ b/internal/modules/expenses/controllers/expense.controller.go @@ -233,6 +233,8 @@ func (u *ExpenseController) Approval(c *fiber.Ctx) error { approvalType = "manager" } else if strings.Contains(path, "/approvals/finance") { approvalType = "finance" + } else if strings.Contains(path, "/approvals/unit-vice-president") { + approvalType = "unit-vice-president" } else { return fiber.NewError(fiber.StatusBadRequest, "Invalid approval path") } diff --git a/internal/modules/expenses/route.go b/internal/modules/expenses/route.go index 9c22bde3..cfb4dd23 100644 --- a/internal/modules/expenses/route.go +++ b/internal/modules/expenses/route.go @@ -27,8 +27,11 @@ func ExpenseRoutes(v1 fiber.Router, u user.UserService, s expense.ExpenseService route.Get("/:id", m.RequirePermissions(m.P_ExpenseGetOne), ctrl.GetOne) route.Patch("/:id", m.RequirePermissions(m.P_ExpenseUpdateOne), ctrl.UpdateOne) route.Delete("/:id", m.RequirePermissions(m.P_ExpenseDeleteOne), ctrl.DeleteOne) + route.Post("/approvals/manager", m.RequirePermissions(m.P_ExpenseApprovalManager), ctrl.Approval) route.Post("/approvals/finance", m.RequirePermissions(m.P_ExpenseApprovalFinance), ctrl.Approval) + route.Post("/approvals/unit-vice-president", m.RequirePermissions(m.P_ExpenseApprovalUnitVicePresident), ctrl.Approval) + route.Post("/:id/realizations", m.RequirePermissions(m.P_ExpenseCreateRealizations), ctrl.CreateRealization) route.Patch("/:id/realizations", m.RequirePermissions(m.P_ExpenseUpdateRealizations), ctrl.UpdateRealization) route.Post("/:id/complete", m.RequirePermissions(m.P_ExpenseCompleteExpense), ctrl.CompleteExpense) diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index 3e50da26..8afbac28 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -1055,15 +1055,24 @@ func (s *expenseService) Approval(c *fiber.Ctx, req *validation.ApprovalRequest, if latestApproval.StepNumber != uint16(utils.ExpenseStepPengajuan) { currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)] return fiber.NewError(fiber.StatusBadRequest, - fmt.Sprintf("Cannot process at Manager step. Latest approval is at %s step. Expected previous step: Pengajuan", currentStepName)) + fmt.Sprintf("Cannot process at Head Area step. Latest approval is at %s step. Expected previous step: Pengajuan", currentStepName)) } - } else if approvalType == "finance" { + } else if approvalType == "unit-vice-president" { - stepNumber = utils.ExpenseStepFinance + stepNumber = utils.ExpenseStepUnitVicePresident if latestApproval.StepNumber != uint16(utils.ExpenseStepManager) { currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)] return fiber.NewError(fiber.StatusBadRequest, - fmt.Sprintf("Cannot process at Finance step. Latest approval is at %s step. Expected previous step: Manager", currentStepName)) + fmt.Sprintf("Cannot process at Unit Vice President step. Latest approval is at %s step. Expected previous step: Head Area", currentStepName)) + } + + } else if approvalType == "finance" { + + stepNumber = utils.ExpenseStepFinance + if latestApproval.StepNumber != uint16(utils.ExpenseStepUnitVicePresident) { + currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)] + return fiber.NewError(fiber.StatusBadRequest, + fmt.Sprintf("Cannot process at Finance step. Latest approval is at %s step. Expected previous step: Unit Vice President", currentStepName)) } } else { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid approval type: %v", approvalType)) diff --git a/internal/modules/inventory/transfers/services/transfer_expense_bridge.go b/internal/modules/inventory/transfers/services/transfer_expense_bridge.go index 90350c18..d4322be6 100644 --- a/internal/modules/inventory/transfers/services/transfer_expense_bridge.go +++ b/internal/modules/inventory/transfers/services/transfer_expense_bridge.go @@ -39,12 +39,12 @@ type TransferExpenseReceivingPayload struct { } type groupedTransferItem struct { - detail *entity.StockTransferDetail - payload TransferExpenseReceivingPayload - projectFK *uint - kandangID *uint - totalPrice float64 - shippingCostTotal float64 + detail *entity.StockTransferDetail + payload TransferExpenseReceivingPayload + projectFK *uint + kandangID *uint + totalPrice float64 + shippingCostTotal float64 } func groupingKey(supplierID uint, date time.Time, warehouseID uint) string { @@ -84,7 +84,6 @@ func (b *transferExpenseBridge) OnItemsDeleted(ctx context.Context, _ uint64, it expenseIDs := make(map[uint64]struct{}) expenseNonstockIDs := make([]uint64, 0) - for _, item := range items { if item.ExpenseNonstockId != nil && *item.ExpenseNonstockId != 0 { expenseNonstockIDs = append(expenseNonstockIDs, *item.ExpenseNonstockId) @@ -92,7 +91,7 @@ func (b *transferExpenseBridge) OnItemsDeleted(ctx context.Context, _ uint64, it } if len(expenseNonstockIDs) > 0 { - + for _, nsID := range expenseNonstockIDs { var expenseID uint64 if err := tx.Model(&entity.ExpenseNonstock{}). @@ -106,13 +105,11 @@ func (b *transferExpenseBridge) OnItemsDeleted(ctx context.Context, _ uint64, it } } - if err := tx.Delete(&entity.ExpenseNonstock{}, expenseNonstockIDs).Error; err != nil { return err } } - approvalRepoTx := commonRepo.NewApprovalRepository(tx) for expenseID := range expenseIDs { var count int64 @@ -122,7 +119,6 @@ func (b *transferExpenseBridge) OnItemsDeleted(ctx context.Context, _ uint64, it return err } - if count == 0 { if err := approvalRepoTx.DeleteByTarget(ctx, utils.ApprovalWorkflowExpense.String(), uint(expenseID)); err != nil { return err @@ -220,7 +216,6 @@ func (b *transferExpenseBridge) createExpenseViaService( for _, gi := range items { note := fmt.Sprintf("stock_transfer_detail:%d", gi.detail.Id) - price := gi.shippingCostTotal if gi.payload.TransportPerItem != nil { price = *gi.payload.TransportPerItem * gi.payload.DeliveredQty @@ -228,7 +223,7 @@ func (b *transferExpenseBridge) createExpenseViaService( costItems = append(costItems, expenseValidation.CostItem{ NonstockID: expeditionNonstockID, - Quantity: 1, + Quantity: 1, Price: price, Notes: note, }) @@ -251,7 +246,6 @@ func (b *transferExpenseBridge) createExpenseViaService( return nil, err } - action := entity.ApprovalActionApproved actorID := uint(transfer.CreatedBy) if actorID == 0 { @@ -261,6 +255,9 @@ func (b *transferExpenseBridge) createExpenseViaService( if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepManager, &action, actorID, nil); err != nil { return nil, err } + if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepUnitVicePresident, &action, actorID, nil); err != nil { + return nil, err + } if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepFinance, &action, actorID, nil); err != nil { return nil, err } @@ -328,7 +325,6 @@ func (b *transferExpenseBridge) OnItemsDelivered(c *fiber.Ctx, transferID uint64 ctx := c.Context() - transfer, err := b.transferRepo.GetByID(ctx, uint(transferID), func(db *gorm.DB) *gorm.DB { return db. Preload("Details"). @@ -348,11 +344,10 @@ func (b *transferExpenseBridge) OnItemsDelivered(c *fiber.Ctx, transferID uint64 for i := range transfer.Details { detailMap[transfer.Details[i].Id] = &transfer.Details[i] - for _, deliveryItem := range transfer.Details[i].DeliveryItems { if deliveryItem.StockTransferDelivery != nil { shippingCostMap[transfer.Details[i].Id] = deliveryItem.StockTransferDelivery.ShippingCostTotal - break + break } } } @@ -395,17 +390,14 @@ func (b *transferExpenseBridge) OnItemsDelivered(c *fiber.Ctx, transferID uint64 } } - shippingCostTotal := shippingCostMap[detail.Id] - totalPrice := shippingCostTotal if payload.TransportPerItem != nil { - + totalPrice = *payload.TransportPerItem * payload.DeliveredQty } - warehouseID := uint(payload.WarehouseID) if warehouseID == 0 && transfer.ToWarehouse != nil { warehouseID = uint(transfer.ToWarehouse.Id) diff --git a/internal/modules/purchases/services/expense_bridge.go b/internal/modules/purchases/services/expense_bridge.go index 23b95c58..094b99c1 100644 --- a/internal/modules/purchases/services/expense_bridge.go +++ b/internal/modules/purchases/services/expense_bridge.go @@ -621,6 +621,9 @@ func (b *expenseBridge) createExpenseViaService( if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepManager, &action, actorID, nil); err != nil { return nil, err } + if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepUnitVicePresident, &action, actorID, nil); err != nil { + return nil, err + } if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepFinance, &action, actorID, nil); err != nil { return nil, err } diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 6ec50447..44c79e35 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -354,20 +354,22 @@ var MarketingApprovalSteps = map[approvalutils.ApprovalStep]string{ // ------------------------------------------------------------------- const ( - ApprovalWorkflowExpense approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("EXPENSES") - ExpenseStepPengajuan approvalutils.ApprovalStep = 1 - ExpenseStepManager approvalutils.ApprovalStep = 2 - ExpenseStepFinance approvalutils.ApprovalStep = 3 - ExpenseStepRealisasi approvalutils.ApprovalStep = 4 - ExpenseStepSelesai approvalutils.ApprovalStep = 5 + ApprovalWorkflowExpense approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("EXPENSES") + ExpenseStepPengajuan approvalutils.ApprovalStep = 1 + ExpenseStepManager approvalutils.ApprovalStep = 2 + ExpenseStepUnitVicePresident approvalutils.ApprovalStep = 3 + ExpenseStepFinance approvalutils.ApprovalStep = 4 + ExpenseStepRealisasi approvalutils.ApprovalStep = 5 + ExpenseStepSelesai approvalutils.ApprovalStep = 6 ) var ExpenseApprovalSteps = map[approvalutils.ApprovalStep]string{ - ExpenseStepPengajuan: "Pengajuan", - ExpenseStepManager: "Approval Manager", - ExpenseStepFinance: "Approval Finance", - ExpenseStepRealisasi: "Realisasi", - ExpenseStepSelesai: "Selesai", + ExpenseStepPengajuan: "Pengajuan", + ExpenseStepManager: "Approval Head Area", + ExpenseStepUnitVicePresident: "Approval Business Unit Vice President", + ExpenseStepFinance: "Approval Finance", + ExpenseStepRealisasi: "Realisasi", + ExpenseStepSelesai: "Selesai", } // ------------------------------------------------------------------- From ae414227762e3b0f81d750b13b04db30a7a40ac4 Mon Sep 17 00:00:00 2001 From: MacBook Air M1 Date: Sun, 11 Jan 2026 22:02:21 +0700 Subject: [PATCH 09/18] add api upload documents daily checklist --- internal/entities/phase.go | 13 +- .../controllers/daily-checklist.controller.go | 19 ++- .../dto/daily-checklist.dto.go | 13 +- internal/modules/daily-checklists/module.go | 12 +- .../services/daily-checklist.service.go | 115 ++++++++++++++++-- .../validations/daily-checklist.validation.go | 10 +- .../modules/master/phasess/dto/phases.dto.go | 26 ++-- .../master/phasess/services/phases.service.go | 34 ++++++ internal/utils/constant.go | 2 + 9 files changed, 209 insertions(+), 35 deletions(-) diff --git a/internal/entities/phase.go b/internal/entities/phase.go index 178ed695..0d924a1a 100644 --- a/internal/entities/phase.go +++ b/internal/entities/phase.go @@ -7,12 +7,13 @@ import ( ) type Phases struct { - Id uint `gorm:"primaryKey"` - Name string `gorm:"not null"` - IsActive bool `gorm:"not null;default:true"` - Category string `gorm:"type:category_code;not null"` - CreatedAt time.Time `gorm:"autoCreateTime"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + Id uint `gorm:"primaryKey"` + Name string `gorm:"not null"` + IsActive bool `gorm:"not null;default:true"` + Category string `gorm:"type:category_code;not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + ActivityCount int `gorm:"-" json:"-"` Activities []PhaseActivity `gorm:"foreignKey:PhaseId;references:Id"` } diff --git a/internal/modules/daily-checklists/controllers/daily-checklist.controller.go b/internal/modules/daily-checklists/controllers/daily-checklist.controller.go index 7c92664a..5819d03e 100644 --- a/internal/modules/daily-checklists/controllers/daily-checklist.controller.go +++ b/internal/modules/daily-checklists/controllers/daily-checklist.controller.go @@ -74,6 +74,7 @@ func (u *DailyChecklistController) GetAll(c *fiber.Ctx) error { Name: name, Status: status, Category: item.Category, + RejectReason: item.RejectReason, Date: item.Date, Kandang: kandang, CreatedUser: nil, @@ -303,12 +304,22 @@ func (u *DailyChecklistController) GetOne(c *fiber.Ctx) error { return err } + documentDTOs := make([]dto.DailyChecklistDocumentDTO, len(detail.DocumentURLs)) + for i, doc := range detail.DocumentURLs { + documentDTOs[i] = dto.DailyChecklistDocumentDTO{ + Id: doc.ID, + Name: doc.Name, + Size: doc.Size, + URL: doc.URL, + } + } + return c.Status(fiber.StatusOK). JSON(response.Success{ Code: fiber.StatusOK, Status: "success", Message: "Get dailyChecklist successfully", - Data: dto.ToDailyChecklistDetailDTO(detail.Checklist, detail.Phases, detail.Tasks, detail.AssignedEmployees, detail.TotalActivities, detail.Progress), + Data: dto.ToDailyChecklistDetailDTO(detail.Checklist, detail.Phases, detail.Tasks, detail.AssignedEmployees, detail.TotalActivities, detail.Progress, documentDTOs), }) } @@ -342,6 +353,12 @@ func (u *DailyChecklistController) UpdateOne(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") } + form, err := c.MultipartForm() + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid multipart form") + } + req.Documents = form.File["documents"] + if err := c.BodyParser(req); err != nil { return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") } diff --git a/internal/modules/daily-checklists/dto/daily-checklist.dto.go b/internal/modules/daily-checklists/dto/daily-checklist.dto.go index d133b76e..62195382 100644 --- a/internal/modules/daily-checklists/dto/daily-checklist.dto.go +++ b/internal/modules/daily-checklists/dto/daily-checklist.dto.go @@ -31,6 +31,7 @@ type DailyChecklistListDTO struct { TotalPhase int `json:"total_phase"` TotalActivity int `json:"total_activity"` Progress int `json:"progress"` + RejectReason *string `json:"reject_reason"` } type DailyChecklistDetailDTO struct { @@ -40,6 +41,14 @@ type DailyChecklistDetailDTO struct { AssignedEmployees []employeeDTO.EmployeesRelationDTO `json:"assigned_employees"` TotalActivity int `json:"total_activity"` Progress float64 `json:"progress"` + DocumentURLs []DailyChecklistDocumentDTO `json:"document_urls"` +} + +type DailyChecklistDocumentDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + Size float64 `json:"size"` + URL string `json:"url"` } type DailyChecklistSummaryDTO struct { @@ -165,10 +174,11 @@ func ToDailyChecklistListDTO(e entity.DailyChecklist) DailyChecklistListDTO { TotalPhase: 0, TotalActivity: 0, Progress: 0, + RejectReason: e.RejectReason, } } -func ToDailyChecklistDetailDTO(checklist entity.DailyChecklist, phases []entity.DailyChecklistPhase, tasks []entity.DailyChecklistActivityTask, assignedEmployees []entity.Employee, totalActivities int, progress float64) DailyChecklistDetailDTO { +func ToDailyChecklistDetailDTO(checklist entity.DailyChecklist, phases []entity.DailyChecklistPhase, tasks []entity.DailyChecklistActivityTask, assignedEmployees []entity.Employee, totalActivities int, progress float64, documentURLs []DailyChecklistDocumentDTO) DailyChecklistDetailDTO { phaseDTOs := make([]DailyChecklistPhaseDTO, 0, len(phases)) for _, phase := range phases { phaseDTOs = append(phaseDTOs, DailyChecklistPhaseDTO{ @@ -228,5 +238,6 @@ func ToDailyChecklistDetailDTO(checklist entity.DailyChecklist, phases []entity. AssignedEmployees: assignedDTOs, TotalActivity: totalActivities, Progress: progress, + DocumentURLs: documentURLs, } } diff --git a/internal/modules/daily-checklists/module.go b/internal/modules/daily-checklists/module.go index bc82d5f6..a1455501 100644 --- a/internal/modules/daily-checklists/module.go +++ b/internal/modules/daily-checklists/module.go @@ -1,10 +1,15 @@ package dailyChecklists import ( + "context" + "fmt" + "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "gorm.io/gorm" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" rDailyChecklist "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/repositories" sDailyChecklist "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/services" rPhases "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/repositories" @@ -19,8 +24,13 @@ func (DailyChecklistModule) RegisterRoutes(router fiber.Router, db *gorm.DB, val dailyChecklistRepo := rDailyChecklist.NewDailyChecklistRepository(db) phasesRepo := rPhases.NewPhasesRepository(db) userRepo := rUser.NewUserRepository(db) + documentRepo := commonRepo.NewDocumentRepository(db) + documentSvc, err := commonSvc.NewDocumentServiceFromConfig(context.Background(), documentRepo) + if err != nil { + panic(fmt.Sprintf("failed to create document service: %v", err)) + } - dailyChecklistService := sDailyChecklist.NewDailyChecklistService(dailyChecklistRepo, phasesRepo, validate) + dailyChecklistService := sDailyChecklist.NewDailyChecklistService(dailyChecklistRepo, phasesRepo, validate, documentSvc) userService := sUser.NewUserService(userRepo, validate) DailyChecklistRoutes(router, userService, dailyChecklistService) diff --git a/internal/modules/daily-checklists/services/daily-checklist.service.go b/internal/modules/daily-checklists/services/daily-checklist.service.go index 2ed15fad..f306c74d 100644 --- a/internal/modules/daily-checklists/services/daily-checklist.service.go +++ b/internal/modules/daily-checklists/services/daily-checklist.service.go @@ -9,6 +9,7 @@ import ( "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + middleware "gitlab.com/mbugroup/lti-api.git/internal/middleware" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/validations" phaseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/repositories" @@ -17,6 +18,7 @@ import ( "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "github.com/sirupsen/logrus" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" "gorm.io/gorm" "gorm.io/gorm/clause" ) @@ -39,10 +41,18 @@ type DailyChecklistService interface { } type dailyChecklistService struct { - Log *logrus.Logger - Validate *validator.Validate - Repository repository.DailyChecklistRepository - PhaseRepo phaseRepo.PhasesRepository + Log *logrus.Logger + Validate *validator.Validate + Repository repository.DailyChecklistRepository + PhaseRepo phaseRepo.PhasesRepository + DocumentSvc commonSvc.DocumentService +} + +type DailyChecklistDocument struct { + ID uint + Name string + Size float64 + URL string } type DailyChecklistDetail struct { @@ -52,6 +62,7 @@ type DailyChecklistDetail struct { AssignedEmployees []entity.Employee TotalActivities int Progress float64 + DocumentURLs []DailyChecklistDocument } type DailyChecklistListItem struct { @@ -60,6 +71,7 @@ type DailyChecklistListItem struct { Date time.Time Category string Status *string + RejectReason *string CreatedAt time.Time UpdatedAt time.Time Kandang entity.Kandang @@ -108,12 +120,13 @@ type DailyChecklistReportCategory struct { Baik int } -func NewDailyChecklistService(repo repository.DailyChecklistRepository, phaseRepo phaseRepo.PhasesRepository, validate *validator.Validate) DailyChecklistService { +func NewDailyChecklistService(repo repository.DailyChecklistRepository, phaseRepo phaseRepo.PhasesRepository, validate *validator.Validate, documentSvc commonSvc.DocumentService) DailyChecklistService { return &dailyChecklistService{ - Log: utils.Log, - Validate: validate, - Repository: repo, - PhaseRepo: phaseRepo, + Log: utils.Log, + Validate: validate, + Repository: repo, + PhaseRepo: phaseRepo, + DocumentSvc: documentSvc, } } @@ -158,7 +171,7 @@ func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([ if params.Search != "" { like := "%" + params.Search + "%" - db = db.Where("(k.name ILIKE ? OR dc.category ILIKE ?)", like, like) + db = db.Where("(k.name ILIKE ? OR dc.category::text ILIKE ?)", like, like) } countDB := db.Session(&gorm.Session{}) @@ -174,6 +187,7 @@ func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([ Date time.Time Category string Status *string + RejectReason *string CreatedAt time.Time UpdatedAt time.Time KandangID uint @@ -192,6 +206,7 @@ func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([ dc.date, dc.category, dc.status, + dc.reject_reason, dc.created_at, dc.updated_at, dc.kandang_id, @@ -265,6 +280,7 @@ func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([ Date: row.Date, Category: row.Category, Status: row.Status, + RejectReason: row.RejectReason, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, Kandang: kandangMap[row.KandangID], @@ -345,6 +361,29 @@ func (s dailyChecklistService) GetDetail(c *fiber.Ctx, id uint) (*DailyChecklist progress = math.Round((float64(completedAssignments) / float64(totalAssignments)) * 100) } + documentURLs := make([]DailyChecklistDocument, 0) + if s.DocumentSvc != nil { + documents, err := s.DocumentSvc.ListByTarget(c.Context(), string(utils.DocumentTypeDailyChecklist), uint64(id)) + if err != nil { + s.Log.Errorf("Failed to list documents for daily checklist %d: %+v", id, err) + return nil, err + } + + for _, doc := range documents { + url, err := s.DocumentSvc.PresignURL(c.Context(), doc, 0) + if err != nil { + s.Log.Errorf("Failed to presign document %d for daily checklist %d: %+v", doc.Id, id, err) + continue + } + documentURLs = append(documentURLs, DailyChecklistDocument{ + ID: doc.Id, + Name: doc.Name, + Size: doc.Size, + URL: url, + }) + } + } + return &DailyChecklistDetail{ Checklist: *checklist, Phases: phases, @@ -352,6 +391,7 @@ func (s dailyChecklistService) GetDetail(c *fiber.Ctx, id uint) (*DailyChecklist AssignedEmployees: assignedEmployees, TotalActivities: totalActivities, Progress: progress, + DocumentURLs: documentURLs, }, nil } @@ -377,7 +417,7 @@ func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create) err = s.Repository.DB().WithContext(c.Context()).Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "date"}, {Name: "kandang_id"}, {Name: "category"}}, - DoUpdates: clause.Assignments(map[string]any{"status": status, "updated_at": time.Now()}), + DoUpdates: clause.Assignments(map[string]any{"updated_at": time.Now()}), }).Create(createBody).Error if err != nil { s.Log.Errorf("Failed to upsert dailyChecklist: %+v", err) @@ -392,6 +432,22 @@ func (s dailyChecklistService) UpdateOne(c *fiber.Ctx, req *validation.Update, i return nil, err } + deletedIDs := make([]uint, 0) + if req.DeletedDocumentIDs != nil { + parts := strings.Split(*req.DeletedDocumentIDs, ",") + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + parsedID, err := strconv.ParseUint(part, 10, 64) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "invalid deleted_document_ids") + } + deletedIDs = append(deletedIDs, uint(parsedID)) + } + } + updateBody := map[string]any{ "status": req.Status, } @@ -400,6 +456,40 @@ func (s dailyChecklistService) UpdateOne(c *fiber.Ctx, req *validation.Update, i updateBody["reject_reason"] = *req.RejectReason } + actorID, err := middleware.ActorIDFromContext(c) + if err != nil { + return &entity.DailyChecklist{}, fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context") + } + + if len(deletedIDs) > 0 && s.DocumentSvc != nil { + if err := s.DocumentSvc.DeleteDocuments(c.Context(), deletedIDs, true); err != nil { + s.Log.Errorf("Failed to delete daily checklist documents: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to delete daily checklist documents") + } + } + + if len(req.Documents) > 0 { + documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents)) + for idx, file := range req.Documents { + documentFiles = append(documentFiles, commonSvc.DocumentFile{ + File: file, + Type: string(utils.DocumentTypeDailyChecklist), + Index: &idx, + }) + } + + _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ + DocumentableType: string(utils.DocumentTypeDailyChecklist), + DocumentableID: uint64(id), + CreatedBy: &actorID, + Files: documentFiles, + }) + if err != nil { + s.Log.Errorf("Failed to upload daily checklist documents: %+v", err) + return &entity.DailyChecklist{}, fiber.NewError(fiber.StatusInternalServerError, "Failed to upload daily checklist documents") + } + } + if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found") @@ -869,7 +959,8 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report Joins("JOIN areas a ON a.id = loc.area_id"). Joins("JOIN phases p ON p.id = dcat.phase_id"). Where("EXTRACT(MONTH FROM dc.date) = ?", params.Month). - Where("EXTRACT(YEAR FROM dc.date) = ?", params.Year) + Where("EXTRACT(YEAR FROM dc.date) = ?", params.Year). + Where("dc.status = ?", "APPROVED") if params.AreaID != nil { db = db.Where("a.id = ?", *params.AreaID) diff --git a/internal/modules/daily-checklists/validations/daily-checklist.validation.go b/internal/modules/daily-checklists/validations/daily-checklist.validation.go index 81bb5eff..35ef8bb9 100644 --- a/internal/modules/daily-checklists/validations/daily-checklist.validation.go +++ b/internal/modules/daily-checklists/validations/daily-checklist.validation.go @@ -1,5 +1,9 @@ package validation +import ( + "mime/multipart" +) + type Create struct { Date string `json:"date" validate:"required"` KandangId uint `json:"kandang_id" validate:"required"` @@ -8,8 +12,10 @@ type Create struct { } type Update struct { - Status string `json:"status" validate:"required"` - RejectReason *string `json:"reject_reason"` + Status string `form:"status" json:"status" validate:"required"` + RejectReason *string `form:"reject_reason" json:"reject_reason"` + Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"` + DeletedDocumentIDs *string `form:"deleted_document_ids" json:"deleted_document_ids"` } type Query struct { diff --git a/internal/modules/master/phasess/dto/phases.dto.go b/internal/modules/master/phasess/dto/phases.dto.go index 51724556..79a2db72 100644 --- a/internal/modules/master/phasess/dto/phases.dto.go +++ b/internal/modules/master/phasess/dto/phases.dto.go @@ -15,12 +15,13 @@ type PhasesRelationDTO struct { } type PhasesListDTO struct { - Id uint `json:"id"` - Name string `json:"name"` - Category string `json:"category"` - IsActive bool `json:"is_active"` - CreatedUser *userDTO.UserRelationDTO `json:"created_user"` - CreatedAt time.Time `json:"created_at"` + Id uint `json:"id"` + Name string `json:"name"` + Category string `json:"category"` + IsActive bool `json:"is_active"` + ActivityCount int `json:"activity_count"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` } type PhasesDetailDTO struct { @@ -44,12 +45,13 @@ func ToPhasesListDTO(e entity.Phases) PhasesListDTO { // } return PhasesListDTO{ - Id: e.Id, - Name: e.Name, - Category: e.Category, - IsActive: e.IsActive, - CreatedAt: e.CreatedAt, - CreatedUser: createdUser, + Id: e.Id, + Name: e.Name, + Category: e.Category, + IsActive: e.IsActive, + ActivityCount: e.ActivityCount, + CreatedAt: e.CreatedAt, + CreatedUser: createdUser, } } diff --git a/internal/modules/master/phasess/services/phases.service.go b/internal/modules/master/phasess/services/phases.service.go index 98e73bef..cfcba600 100644 --- a/internal/modules/master/phasess/services/phases.service.go +++ b/internal/modules/master/phasess/services/phases.service.go @@ -63,6 +63,40 @@ func (s phasesService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity. s.Log.Errorf("Failed to get phasess: %+v", err) return nil, 0, err } + + if len(phasess) > 0 { + ids := make([]uint, 0, len(phasess)) + for _, phase := range phasess { + ids = append(ids, phase.Id) + } + + type activityCountRow struct { + PhaseID uint + Count int64 + } + + var rows []activityCountRow + if err := s.Repository.DB().WithContext(c.Context()). + Table("phase_activities"). + Select("phase_id, COUNT(*) AS count"). + Where("phase_id IN ? AND deleted_at IS NULL", ids). + Group("phase_id"). + Scan(&rows).Error; err != nil { + s.Log.Errorf("Failed to count phase activities: %+v", err) + return nil, 0, err + } + + countMap := make(map[uint]int64, len(rows)) + for _, row := range rows { + countMap[row.PhaseID] = row.Count + } + + for i := range phasess { + if count, ok := countMap[phasess[i].Id]; ok { + phasess[i].ActivityCount = int(count) + } + } + } return phasess, total, nil } diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 6ec50447..35ce3132 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -432,6 +432,8 @@ const ( DocumentableTypeExpense DocumentableType = "EXPENSE" DocumentableTypeExpenseRealization DocumentableType = "EXPENSE_REALIZATION" DocumentableTypePurchaseItem DocumentableType = "PURCHASE_ITEM" + + DocumentTypeDailyChecklist DocumentType = "DAILY_CHECKLIST_DOCUMENT" ) // ------------------------------------------------------------------- From 1217f34dcd354a9923c8f8a9fbdfb5fecf3583f8 Mon Sep 17 00:00:00 2001 From: ragilap Date: Sun, 11 Jan 2026 22:19:20 +0700 Subject: [PATCH 10/18] feat(BE):change standart egg in fcr master data --- .../dashboard_stats.repository.go | 113 +++++++--- .../dashboards/services/dashboard.service.go | 199 +++++++++++++++--- 2 files changed, 254 insertions(+), 58 deletions(-) diff --git a/internal/modules/dashboards/repositories/dashboard_stats.repository.go b/internal/modules/dashboards/repositories/dashboard_stats.repository.go index 948c1b56..7582680b 100644 --- a/internal/modules/dashboards/repositories/dashboard_stats.repository.go +++ b/internal/modules/dashboards/repositories/dashboard_stats.repository.go @@ -188,39 +188,92 @@ func (r *DashboardRepositoryImpl) GetStandardFcrWeekly(ctx context.Context, week return nil, nil } - source := r.standardSourceSubquery(filters) - if source == nil { - return nil, nil + filterClause := "" + filterArgs := make([]interface{}, 0) + if filters != nil { + if len(filters.FlockIds) > 0 { + filterClause += " AND pf.id IN ?" + filterArgs = append(filterArgs, filters.FlockIds) + } + if len(filters.KandangIds) > 0 { + filterClause += " AND k.id IN ?" + filterArgs = append(filterArgs, filters.KandangIds) + } + if len(filters.LokasiIds) > 0 { + filterClause += " AND k.location_id IN ?" + filterArgs = append(filterArgs, filters.LokasiIds) + } } - var rows []StandardWeeklyFcrMetric - db := r.DB().WithContext(ctx). - Table("standard_growth_details AS sgd"). - Select(` - sgd.week AS week, - COALESCE(AVG( - COALESCE( - ( - SELECT fs.fcr_number - FROM fcr_standards fs - WHERE fs.fcr_id = src.fcr_id - AND fs.weight >= CASE WHEN sgd.target_mean_bw > 10 THEN sgd.target_mean_bw / 1000 ELSE sgd.target_mean_bw END - ORDER BY fs.weight ASC - LIMIT 1 - ), - ( - SELECT fs.fcr_number - FROM fcr_standards fs - WHERE fs.fcr_id = src.fcr_id - ORDER BY fs.weight DESC - LIMIT 1 - ) - ) - ), 0) AS std_fcr`). - Joins("JOIN (?) AS src ON src.production_standard_id = sgd.production_standard_id", source). - Where("sgd.week IN ?", weeks) + query := fmt.Sprintf(` +WITH src AS ( + SELECT DISTINCT pf.production_standard_id, pf.fcr_id + FROM project_flocks pf + JOIN project_flock_kandangs pfk ON pfk.project_flock_id = pf.id + JOIN kandangs k ON k.id = pfk.kandang_id + WHERE pf.production_standard_id > 0 AND pf.fcr_id > 0 + %s +), +actual AS ( + SELECT u.week AS week, + pf.fcr_id AS fcr_id, + AVG((u.chart_data->'statistics'->>'average_weight')::numeric) AS avg_weight + FROM project_flock_kandang_uniformity u + JOIN project_flock_kandangs pfk ON pfk.id = u.project_flock_kandang_id + JOIN project_flocks pf ON pf.id = pfk.project_flock_id + JOIN kandangs k ON k.id = pfk.kandang_id + WHERE u.week IN ? AND u.uniform_date IS NOT NULL AND pf.fcr_id > 0 + %s + GROUP BY u.week, pf.fcr_id +), +target AS ( + SELECT sgd.week AS week, + src.fcr_id AS fcr_id, + AVG(sgd.target_mean_bw) AS target_mean_bw + FROM standard_growth_details sgd + JOIN src ON src.production_standard_id = sgd.production_standard_id + WHERE sgd.week IN ? + GROUP BY sgd.week, src.fcr_id +), +weights AS ( + SELECT COALESCE(a.week, t.week) AS week, + COALESCE(a.fcr_id, t.fcr_id) AS fcr_id, + COALESCE( + CASE WHEN a.avg_weight > 10 THEN a.avg_weight / 1000 ELSE a.avg_weight END, + CASE WHEN t.target_mean_bw > 10 THEN t.target_mean_bw / 1000 ELSE t.target_mean_bw END + ) AS weight + FROM actual a + FULL OUTER JOIN target t ON t.week = a.week AND t.fcr_id = a.fcr_id +) +SELECT w.week AS week, + COALESCE(AVG( + COALESCE( + (SELECT fs.fcr_number + FROM fcr_standards fs + WHERE fs.fcr_id = w.fcr_id + AND fs.weight >= w.weight + ORDER BY fs.weight ASC + LIMIT 1), + (SELECT fs.fcr_number + FROM fcr_standards fs + WHERE fs.fcr_id = w.fcr_id + ORDER BY fs.weight DESC + LIMIT 1) + ) + ), 0) AS std_fcr +FROM weights w +GROUP BY w.week +ORDER BY w.week ASC +`, filterClause, filterClause) - if err := db.Group("sgd.week").Order("sgd.week ASC").Scan(&rows).Error; err != nil { + args := make([]interface{}, 0, len(filterArgs)*2+2) + args = append(args, filterArgs...) + args = append(args, weeks) + args = append(args, filterArgs...) + args = append(args, weeks) + + var rows []StandardWeeklyFcrMetric + if err := r.DB().WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil { return nil, err } diff --git a/internal/modules/dashboards/services/dashboard.service.go b/internal/modules/dashboards/services/dashboard.service.go index 7c083c98..f8d532f0 100644 --- a/internal/modules/dashboards/services/dashboard.service.go +++ b/internal/modules/dashboards/services/dashboard.service.go @@ -507,37 +507,180 @@ func (s dashboardService) buildComparisonChartsAll(ctx context.Context, startDat stdFcr[row.Week] = row.StdFcr } - layingWeeks, layingActual := mapComparisonWeeklyMetricRows(layingRows) - eggWeightWeeks, eggWeightActual := mapComparisonWeeklyMetricRows(eggWeightRows) - feedWeeks, feedActual := mapComparisonWeeklyMetricRows(feedIntakeRows) - fcrWeeks, fcrActual := mapComparisonWeeklyMetricRows(fcrRows) - deplesiWeeks, deplesiActual := mapComparisonWeeklyMetricRows(deplesiRows) - bodyWeightWeeks, bodyWeightActual, uniformityWeeks, uniformityActual := mapComparisonUniformityRows(uniformityRows) + _, layingActual := mapComparisonWeeklyMetricRows(layingRows) + _, eggWeightActual := mapComparisonWeeklyMetricRows(eggWeightRows) + _, feedActual := mapComparisonWeeklyMetricRows(feedIntakeRows) + _, fcrActual := mapComparisonWeeklyMetricRows(fcrRows) + _, deplesiActual := mapComparisonWeeklyMetricRows(deplesiRows) + _, bodyWeightActual, _, uniformityActual := mapComparisonUniformityRows(uniformityRows) - charts := map[string]dto.DashboardChartDTO{} - if len(bodyWeightWeeks) > 0 { - charts["body_weight"] = buildComparisonPercentChart(seriesRows, bodyWeightWeeks, bodyWeightActual, stdBodyWeight) - } - if len(layingWeeks) > 0 { - charts["laying"] = buildComparisonPercentChart(seriesRows, layingWeeks, layingActual, stdLaying) - } - if len(eggWeightWeeks) > 0 { - charts["egg_weight"] = buildComparisonPercentChart(seriesRows, eggWeightWeeks, eggWeightActual, stdEggWeight) - } - if len(feedWeeks) > 0 { - charts["feed_intake"] = buildComparisonPercentChart(seriesRows, feedWeeks, feedActual, stdFeedIntake) - } - if len(uniformityWeeks) > 0 { - charts["uniformity"] = buildComparisonPercentChart(seriesRows, uniformityWeeks, uniformityActual, stdUniformity) - } - if len(fcrWeeks) > 0 { - charts["fcr"] = buildComparisonPercentChart(seriesRows, fcrWeeks, fcrActual, stdFcr) - } - if len(deplesiWeeks) > 0 { - charts["deplesi"] = buildComparisonPercentChart(seriesRows, deplesiWeeks, deplesiActual, stdDeplesi) + aggregateActual := buildAggregateComparisonPercent(weeks, seriesRows, aggregateComparisonInput{ + BodyWeightActual: bodyWeightActual, + LayingActual: layingActual, + EggWeightActual: eggWeightActual, + FeedIntakeActual: feedActual, + UniformityActual: uniformityActual, + FcrActual: fcrActual, + DeplesiActual: deplesiActual, + StdBodyWeight: stdBodyWeight, + StdLaying: stdLaying, + StdEggWeight: stdEggWeight, + StdFeedIntake: stdFeedIntake, + StdUniformity: stdUniformity, + StdFcr: stdFcr, + StdDeplesi: stdDeplesi, + }) + + if len(aggregateActual) == 0 { + return map[string]dto.DashboardChartDTO{}, nil } - return charts, nil + chartKey := strings.ToLower(params.ComparisonType) + return map[string]dto.DashboardChartDTO{ + chartKey: buildComparisonAggregateChart(seriesRows, weeks, aggregateActual), + }, nil +} + +type aggregateComparisonInput struct { + BodyWeightActual map[int]map[uint]float64 + LayingActual map[int]map[uint]float64 + EggWeightActual map[int]map[uint]float64 + FeedIntakeActual map[int]map[uint]float64 + UniformityActual map[int]map[uint]float64 + FcrActual map[int]map[uint]float64 + DeplesiActual map[int]map[uint]float64 + StdBodyWeight map[int]float64 + StdLaying map[int]float64 + StdEggWeight map[int]float64 + StdFeedIntake map[int]float64 + StdUniformity map[int]float64 + StdFcr map[int]float64 + StdDeplesi map[int]float64 +} + +func buildAggregateComparisonPercent(weeks []int, seriesRows []repository.ComparisonSeries, input aggregateComparisonInput) map[int]map[uint]float64 { + result := map[int]map[uint]float64{} + + for _, week := range weeks { + stdBodyWeight := input.StdBodyWeight[week] + stdLaying := input.StdLaying[week] + stdEggWeight := input.StdEggWeight[week] + stdFeedIntake := input.StdFeedIntake[week] + stdUniformity := input.StdUniformity[week] + stdFcr := input.StdFcr[week] + stdDeplesi := input.StdDeplesi[week] + + for _, series := range seriesRows { + sum := 0.0 + count := 0.0 + + if percent, ok := higherIsBetterPercent(input.LayingActual, week, series.Id, stdLaying); ok { + sum += percent + count++ + } + if percent, ok := higherIsBetterPercent(input.EggWeightActual, week, series.Id, stdEggWeight); ok { + sum += percent + count++ + } + if percent, ok := higherIsBetterPercent(input.UniformityActual, week, series.Id, stdUniformity); ok { + sum += percent + count++ + } + if percent, ok := lowerIsBetterPercent(input.FcrActual, week, series.Id, stdFcr); ok { + sum += percent + count++ + } + if percent, ok := lowerIsBetterPercent(input.DeplesiActual, week, series.Id, stdDeplesi); ok { + sum += percent + count++ + } + if percent, ok := higherIsBetterPercent(input.BodyWeightActual, week, series.Id, stdBodyWeight); ok { + sum += percent + count++ + } + if percent, ok := lowerIsBetterPercent(input.FeedIntakeActual, week, series.Id, stdFeedIntake); ok { + sum += percent + count++ + } + + if count == 0 { + continue + } + + if result[week] == nil { + result[week] = map[uint]float64{} + } + result[week][series.Id] = sum / count + } + } + + return result +} + +func higherIsBetterPercent(actual map[int]map[uint]float64, week int, seriesId uint, standard float64) (float64, bool) { + if standard <= 0 { + return 0, false + } + val, ok := metricValue(actual, week, seriesId) + if !ok || val <= 0 { + return 0, false + } + return (val / standard) * 100, true +} + +func lowerIsBetterPercent(actual map[int]map[uint]float64, week int, seriesId uint, standard float64) (float64, bool) { + if standard <= 0 { + return 0, false + } + val, ok := metricValue(actual, week, seriesId) + if !ok || val <= 0 { + return 0, false + } + return (standard / val) * 100, true +} + +func metricValue(actual map[int]map[uint]float64, week int, seriesId uint) (float64, bool) { + weekRows, ok := actual[week] + if !ok { + return 0, false + } + val, ok := weekRows[seriesId] + return val, ok +} + +func buildComparisonAggregateChart(seriesRows []repository.ComparisonSeries, weeks []int, actual map[int]map[uint]float64) dto.DashboardChartDTO { + series := make([]dto.DashboardChartSeriesDTO, 0, len(seriesRows)) + for _, sRow := range seriesRows { + series = append(series, dto.DashboardChartSeriesDTO{ + Id: strconv.FormatUint(uint64(sRow.Id), 10), + Label: sRow.Label, + Unit: "%", + }) + } + + dataset := make([]map[string]interface{}, 0, len(weeks)) + for _, week := range weeks { + row := map[string]interface{}{ + "week": week, + } + values, ok := actual[week] + if !ok { + continue + } + for _, sRow := range seriesRows { + if val, exists := values[sRow.Id]; exists { + row[strconv.FormatUint(uint64(sRow.Id), 10)] = roundTo(val, 2) + } + } + if len(row) > 1 { + dataset = append(dataset, row) + } + } + + return dto.DashboardChartDTO{ + Series: series, + Dataset: dataset, + } } func (s dashboardService) standardComparisonMap(ctx context.Context, weeks []int, metric string, filter *validation.DashboardFilter) (map[int]float64, error) { From 67f5165bfb1d06bd5443d4eee448978ec9dd0f3a Mon Sep 17 00:00:00 2001 From: ragilap Date: Sun, 11 Jan 2026 23:47:28 +0700 Subject: [PATCH 11/18] [FIX/BE] adjust case sensitive search --- internal/modules/closings/services/closing.service.go | 2 +- .../expenses/repositories/expense_realization.repository.go | 2 +- internal/modules/expenses/services/expense.service.go | 2 +- .../repositories/product_warehouse.repository.go | 4 ++-- .../modules/inventory/transfers/services/transfer.service.go | 2 +- internal/modules/master/areas/services/area.service.go | 2 +- internal/modules/master/banks/services/bank.service.go | 2 +- .../modules/master/customers/services/customer.service.go | 2 +- .../modules/master/employees/services/employees.service.go | 2 +- internal/modules/master/fcrs/services/fcr.service.go | 2 +- internal/modules/master/flocks/services/flock.service.go | 2 +- internal/modules/master/kandangs/services/kandang.service.go | 2 +- .../modules/master/locations/services/location.service.go | 2 +- .../modules/master/nonstocks/services/nonstock.service.go | 2 +- .../phase-activities/services/phase-activity.service.go | 2 +- internal/modules/master/phasess/services/phases.service.go | 2 +- .../product-categories/services/product-category.service.go | 2 +- .../services/production-standard.service.go | 2 +- internal/modules/master/products/services/product.service.go | 2 +- .../modules/master/suppliers/services/supplier.service.go | 4 ++-- internal/modules/master/uoms/services/uom.service.go | 2 +- .../modules/master/warehouses/services/warehouse.service.go | 2 +- .../modules/purchases/repositories/purchase.repository.go | 2 +- internal/modules/users/services/user.service.go | 2 +- 24 files changed, 26 insertions(+), 26 deletions(-) diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index a76085c4..245fd24c 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -94,7 +94,7 @@ func (s closingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]dto.Cl closings, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withClosingRelations(db) if params.Search != "" { - return db.Where("flock_name LIKE ?", "%"+params.Search+"%") + return db.Where("flock_name ILIKE ?", "%"+params.Search+"%") } return db.Order("created_at DESC").Order("updated_at DESC") }) diff --git a/internal/modules/expenses/repositories/expense_realization.repository.go b/internal/modules/expenses/repositories/expense_realization.repository.go index 474b2962..f1387483 100644 --- a/internal/modules/expenses/repositories/expense_realization.repository.go +++ b/internal/modules/expenses/repositories/expense_realization.repository.go @@ -75,7 +75,7 @@ func (r *ExpenseRealizationRepositoryImpl) GetAllWithFilters(ctx context.Context Joins("LEFT JOIN suppliers ON suppliers.id = expenses.supplier_id") if filters.Search != "" { - db = db.Where("expenses.category LIKE ? OR expenses.reference_number LIKE ? OR expenses.po_number LIKE ? OR expenses.notes LIKE ? OR suppliers.name LIKE ?", + db = db.Where("expenses.category ILIKE ? OR expenses.reference_number ILIKE ? OR expenses.po_number ILIKE ? OR expenses.notes ILIKE ? OR suppliers.name ILIKE ?", "%"+filters.Search+"%", "%"+filters.Search+"%", "%"+filters.Search+"%", "%"+filters.Search+"%", "%"+filters.Search+"%") } diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index 3e50da26..4e2e218f 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -92,7 +92,7 @@ func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expens expenses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.Search != "" { - return db.Where("category LIKE ?", "%"+params.Search+"%") + return db.Where("category ILIKE ?", "%"+params.Search+"%") } return db.Order("created_at DESC").Order("updated_at DESC") }) diff --git a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go index 6acb4f69..a7fe452b 100644 --- a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go +++ b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go @@ -399,11 +399,11 @@ func (r *ProductWarehouseRepositoryImpl) ListProductIDsByFlagPrefixes(ctx contex } like := prefix + "%" if !applied { - db = db.Where("flags.name LIKE ?", like) + db = db.Where("flags.name ILIKE ?", like) applied = true continue } - db = db.Or("flags.name LIKE ?", like) + db = db.Or("flags.name ILIKE ?", like) } if visibleStatus != nil { diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index dc6399d5..3f12b444 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -99,7 +99,7 @@ func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit transfers, total, err := s.StockTransferRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.Search != "" { - db = db.Where("movement_number LIKE ?", "%"+strings.TrimSpace(params.Search)+"%") + db = db.Where("movement_number ILIKE ?", "%"+strings.TrimSpace(params.Search)+"%") } return db.Order("created_at DESC").Order("updated_at DESC") }) diff --git a/internal/modules/master/areas/services/area.service.go b/internal/modules/master/areas/services/area.service.go index 0a976567..e6f9205c 100644 --- a/internal/modules/master/areas/services/area.service.go +++ b/internal/modules/master/areas/services/area.service.go @@ -52,7 +52,7 @@ func (s areaService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Ar areas, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.Search != "" { - return db.Where("name LIKE ?", "%"+params.Search+"%") + return db.Where("name ILIKE ?", "%"+params.Search+"%") } return db.Order("created_at DESC").Order("updated_at DESC") }) diff --git a/internal/modules/master/banks/services/bank.service.go b/internal/modules/master/banks/services/bank.service.go index 83d3029d..bc4abb72 100644 --- a/internal/modules/master/banks/services/bank.service.go +++ b/internal/modules/master/banks/services/bank.service.go @@ -51,7 +51,7 @@ func (s bankService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Ba banks, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.Search != "" { - return db.Where("name LIKE ?", "%"+params.Search+"%") + return db.Where("name ILIKE ?", "%"+params.Search+"%") } return db.Order("created_at DESC").Order("updated_at DESC") }) diff --git a/internal/modules/master/customers/services/customer.service.go b/internal/modules/master/customers/services/customer.service.go index 12a31441..fe4cb41e 100644 --- a/internal/modules/master/customers/services/customer.service.go +++ b/internal/modules/master/customers/services/customer.service.go @@ -53,7 +53,7 @@ func (s customerService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit customers, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.Search != "" { - return db.Where("name LIKE ?", "%"+params.Search+"%") + return db.Where("name ILIKE ?", "%"+params.Search+"%") } return db.Order("created_at DESC").Order("updated_at DESC") }) diff --git a/internal/modules/master/employees/services/employees.service.go b/internal/modules/master/employees/services/employees.service.go index 4998eaec..b3673eaf 100644 --- a/internal/modules/master/employees/services/employees.service.go +++ b/internal/modules/master/employees/services/employees.service.go @@ -53,7 +53,7 @@ func (s employeesService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti employeess, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.Search != "" { - db = db.Where("employees.name LIKE ?", "%"+params.Search+"%") + db = db.Where("employees.name ILIKE ?", "%"+params.Search+"%") } if params.KandangId != nil { db = db.Joins("JOIN employee_kandangs ek ON ek.employee_id = employees.id"). diff --git a/internal/modules/master/fcrs/services/fcr.service.go b/internal/modules/master/fcrs/services/fcr.service.go index f4125374..a9414e05 100644 --- a/internal/modules/master/fcrs/services/fcr.service.go +++ b/internal/modules/master/fcrs/services/fcr.service.go @@ -55,7 +55,7 @@ func (s fcrService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Fcr fcrs, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.Search != "" { - return db.Where("name LIKE ?", "%"+params.Search+"%") + return db.Where("name ILIKE ?", "%"+params.Search+"%") } return db.Order("created_at DESC").Order("updated_at DESC") }) diff --git a/internal/modules/master/flocks/services/flock.service.go b/internal/modules/master/flocks/services/flock.service.go index ad086920..2eaaa85d 100644 --- a/internal/modules/master/flocks/services/flock.service.go +++ b/internal/modules/master/flocks/services/flock.service.go @@ -52,7 +52,7 @@ func (s flockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.F flocks, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.Search != "" { - return db.Where("name LIKE ?", "%"+params.Search+"%") + return db.Where("name ILIKE ?", "%"+params.Search+"%") } return db.Order("created_at DESC").Order("updated_at DESC") }) diff --git a/internal/modules/master/kandangs/services/kandang.service.go b/internal/modules/master/kandangs/services/kandang.service.go index 35fe2c30..9f83f0ce 100644 --- a/internal/modules/master/kandangs/services/kandang.service.go +++ b/internal/modules/master/kandangs/services/kandang.service.go @@ -54,7 +54,7 @@ func (s kandangService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity kandangs, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.Search != "" { - return db.Where("name LIKE ?", "%"+params.Search+"%") + return db.Where("name ILIKE ?", "%"+params.Search+"%") } if params.LocationId != 0 { db = db.Where("location_id = ?", params.LocationId) diff --git a/internal/modules/master/locations/services/location.service.go b/internal/modules/master/locations/services/location.service.go index 19894d10..3a1d1e23 100644 --- a/internal/modules/master/locations/services/location.service.go +++ b/internal/modules/master/locations/services/location.service.go @@ -52,7 +52,7 @@ func (s locationService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit locations, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.Search != "" { - db = db.Where("name LIKE ?", "%"+params.Search+"%") + db = db.Where("name ILIKE ?", "%"+params.Search+"%") } if params.AreaId != 0 { db = db.Where("area_id = ?", params.AreaId) diff --git a/internal/modules/master/nonstocks/services/nonstock.service.go b/internal/modules/master/nonstocks/services/nonstock.service.go index 876d4c1e..ad044b08 100644 --- a/internal/modules/master/nonstocks/services/nonstock.service.go +++ b/internal/modules/master/nonstocks/services/nonstock.service.go @@ -68,7 +68,7 @@ func (s nonstockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit db = s.withRelations(db) if params.Search != "" { - return db.Where("name LIKE ?", "%"+params.Search+"%") + return db.Where("name ILIKE ?", "%"+params.Search+"%") } return db.Order("created_at DESC").Order("updated_at DESC") }) diff --git a/internal/modules/master/phase-activities/services/phase-activity.service.go b/internal/modules/master/phase-activities/services/phase-activity.service.go index 24b8272e..1c6b15ce 100644 --- a/internal/modules/master/phase-activities/services/phase-activity.service.go +++ b/internal/modules/master/phase-activities/services/phase-activity.service.go @@ -56,7 +56,7 @@ func (s phaseActivityService) GetAll(c *fiber.Ctx, params *validation.Query) ([] phaseActivitys, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.Search != "" { - db = db.Where("name LIKE ?", "%"+params.Search+"%") + db = db.Where("name ILIKE ?", "%"+params.Search+"%") } if params.PhaseIDs != "" { ids := parseIDs(params.PhaseIDs) diff --git a/internal/modules/master/phasess/services/phases.service.go b/internal/modules/master/phasess/services/phases.service.go index 98e73bef..c95d24e0 100644 --- a/internal/modules/master/phasess/services/phases.service.go +++ b/internal/modules/master/phasess/services/phases.service.go @@ -51,7 +51,7 @@ func (s phasesService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity. phasess, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.Search != "" { - return db.Where("name LIKE ?", "%"+params.Search+"%") + return db.Where("name ILIKE ?", "%"+params.Search+"%") } if params.Category != nil { db = db.Where("category = ?", *params.Category) diff --git a/internal/modules/master/product-categories/services/product-category.service.go b/internal/modules/master/product-categories/services/product-category.service.go index 90936d7b..ae1577f1 100644 --- a/internal/modules/master/product-categories/services/product-category.service.go +++ b/internal/modules/master/product-categories/services/product-category.service.go @@ -52,7 +52,7 @@ func (s productCategoryService) GetAll(c *fiber.Ctx, params *validation.Query) ( productCategories, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.Search != "" { - return db.Where("name LIKE ?", "%"+params.Search+"%") + return db.Where("name ILIKE ?", "%"+params.Search+"%") } return db.Order("created_at DESC").Order("updated_at DESC") }) diff --git a/internal/modules/master/production-standards/services/production-standard.service.go b/internal/modules/master/production-standards/services/production-standard.service.go index 4005b014..e1470170 100644 --- a/internal/modules/master/production-standards/services/production-standard.service.go +++ b/internal/modules/master/production-standards/services/production-standard.service.go @@ -63,7 +63,7 @@ func (s productionStandardService) GetAll(c *fiber.Ctx, params *validation.Query productionStandards, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { if params.Search != "" { - return db.Where("name LIKE ?", "%"+params.Search+"%") + return db.Where("name ILIKE ?", "%"+params.Search+"%") } if params.ProjectCategory != "" { return db.Where("project_category = ?", params.ProjectCategory) diff --git a/internal/modules/master/products/services/product.service.go b/internal/modules/master/products/services/product.service.go index f40d92be..dd890c76 100644 --- a/internal/modules/master/products/services/product.service.go +++ b/internal/modules/master/products/services/product.service.go @@ -72,7 +72,7 @@ func (s productService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity db = s.withRelations(db) db = db.Where("is_visible = ?", true) if params.Search != "" { - return db.Where("name LIKE ?", "%"+params.Search+"%") + return db.Where("name ILIKE ?", "%"+params.Search+"%") } if params.ProductCategoryID != 0 { return db.Where("product_category_id = ?", params.ProductCategoryID) diff --git a/internal/modules/master/suppliers/services/supplier.service.go b/internal/modules/master/suppliers/services/supplier.service.go index 75d8fa04..c331647d 100644 --- a/internal/modules/master/suppliers/services/supplier.service.go +++ b/internal/modules/master/suppliers/services/supplier.service.go @@ -65,11 +65,11 @@ func (s supplierService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit suppliers, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.Search != "" { - return db.Where("name LIKE ?", "%"+params.Search+"%") + return db.Where("name ILIKE ?", "%"+params.Search+"%") } if params.Category != "" { - db = db.Where("category LIKE ?", "%"+params.Category+"%") + db = db.Where("category ILIKE ?", "%"+params.Category+"%") } return db.Order("created_at DESC").Order("updated_at DESC") diff --git a/internal/modules/master/uoms/services/uom.service.go b/internal/modules/master/uoms/services/uom.service.go index 5396849b..8ec0742f 100644 --- a/internal/modules/master/uoms/services/uom.service.go +++ b/internal/modules/master/uoms/services/uom.service.go @@ -51,7 +51,7 @@ func (s uomService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Uom uoms, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.Search != "" { - return db.Where("name LIKE ?", "%"+params.Search+"%") + return db.Where("name ILIKE ?", "%"+params.Search+"%") } return db.Order("created_at DESC").Order("updated_at DESC") }) diff --git a/internal/modules/master/warehouses/services/warehouse.service.go b/internal/modules/master/warehouses/services/warehouse.service.go index 79c41284..7eeaad3d 100644 --- a/internal/modules/master/warehouses/services/warehouse.service.go +++ b/internal/modules/master/warehouses/services/warehouse.service.go @@ -53,7 +53,7 @@ func (s warehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti warehouses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.Search != "" { - db = db.Where("warehouses.name LIKE ?", "%"+params.Search+"%") + db = db.Where("warehouses.name ILIKE ?", "%"+params.Search+"%") } if params.AreaId != 0 { db = db.Where("area_id = ?", params.AreaId) diff --git a/internal/modules/purchases/repositories/purchase.repository.go b/internal/modules/purchases/repositories/purchase.repository.go index f6e48aeb..2cb0ba75 100644 --- a/internal/modules/purchases/repositories/purchase.repository.go +++ b/internal/modules/purchases/repositories/purchase.repository.go @@ -285,7 +285,7 @@ func (r *PurchaseRepositoryImpl) generateSequentialNumber(ctx context.Context, t var values []string err := db.WithContext(ctx). Model(&entity.Purchase{}). - Where(fmt.Sprintf("%s LIKE ?", column), prefix+"%"). + Where(fmt.Sprintf("%s ILIKE ?", column), prefix+"%"). Select(column). Order(fmt.Sprintf("%s DESC", column)). Limit(20). diff --git a/internal/modules/users/services/user.service.go b/internal/modules/users/services/user.service.go index 3b28197e..1e101793 100644 --- a/internal/modules/users/services/user.service.go +++ b/internal/modules/users/services/user.service.go @@ -45,7 +45,7 @@ func (s userService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Us users, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { if params.Search != "" { - return db.Where("name LIKE ?", "%"+params.Search+"%") + return db.Where("name ILIKE ?", "%"+params.Search+"%") } return db.Order("created_at DESC").Order("updated_at DESC") }) From d1d94357cf255ea3a1319392ad78de939914074e Mon Sep 17 00:00:00 2001 From: ragilap Date: Mon, 12 Jan 2026 00:27:53 +0700 Subject: [PATCH 12/18] [FIX/BE] add clamp maximum value --- .../dashboards/services/dashboard.service.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/internal/modules/dashboards/services/dashboard.service.go b/internal/modules/dashboards/services/dashboard.service.go index f8d532f0..a60f1555 100644 --- a/internal/modules/dashboards/services/dashboard.service.go +++ b/internal/modules/dashboards/services/dashboard.service.go @@ -625,7 +625,7 @@ func higherIsBetterPercent(actual map[int]map[uint]float64, week int, seriesId u if !ok || val <= 0 { return 0, false } - return (val / standard) * 100, true + return clampPercent((val / standard) * 100), true } func lowerIsBetterPercent(actual map[int]map[uint]float64, week int, seriesId uint, standard float64) (float64, bool) { @@ -636,7 +636,7 @@ func lowerIsBetterPercent(actual map[int]map[uint]float64, week int, seriesId ui if !ok || val <= 0 { return 0, false } - return (standard / val) * 100, true + return clampPercent((standard / val) * 100), true } func metricValue(actual map[int]map[uint]float64, week int, seriesId uint) (float64, bool) { @@ -648,6 +648,16 @@ func metricValue(actual map[int]map[uint]float64, week int, seriesId uint) (floa return val, ok } +func clampPercent(value float64) float64 { + if value < 0 { + return 0 + } + if value > 200 { + return 200 + } + return value +} + func buildComparisonAggregateChart(seriesRows []repository.ComparisonSeries, weeks []int, actual map[int]map[uint]float64) dto.DashboardChartDTO { series := make([]dto.DashboardChartSeriesDTO, 0, len(seriesRows)) for _, sRow := range seriesRows { From c15ff8a211503c7a1a72dc04af10a607c21f106f Mon Sep 17 00:00:00 2001 From: ragilap Date: Mon, 12 Jan 2026 01:15:31 +0700 Subject: [PATCH 13/18] [FIX/BE] add percent rasio in statistic dashboard --- .../dashboards/services/dashboard.service.go | 36 ++++++++----------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/internal/modules/dashboards/services/dashboard.service.go b/internal/modules/dashboards/services/dashboard.service.go index a60f1555..8fa0a2c9 100644 --- a/internal/modules/dashboards/services/dashboard.service.go +++ b/internal/modules/dashboards/services/dashboard.service.go @@ -124,48 +124,35 @@ func (s dashboardService) buildPerformanceStatistics(ctx context.Context, params } } - hppPercent := 0.0 - if hppLast > 0 { - hppPercent = (hppCurrent - hppLast) / hppLast * 100 - } - - sellingPercent := 0.0 - if sellingLast > 0 { - sellingPercent = sellingCurrent / sellingLast * 100 - } + hppPercent := percentDelta(hppCurrent, hppLast) + sellingPercent := percentDelta(sellingCurrent, sellingLast) stats := []dto.DashboardStatisticsDTO{ { Label: "HPP Global", Value: roundTo(hppCurrent, 0), - PercentLastMonth: hppPercent, + PercentLastMonth: roundTo(hppPercent*100, 2), }, { Label: "Avg. Selling Price", Value: roundTo(sellingCurrent, 0), - PercentLastMonth: sellingPercent, + PercentLastMonth: roundTo(sellingPercent*100, 2), }, } if hasFilter { - fcrPercent := 0.0 - if fcrLast > 0 { - fcrPercent = (fcrCurrent - fcrLast) / fcrLast * 100 - } - mortalityPercent := 0.0 - if mortalityLast > 0 { - mortalityPercent = (mortalityCurrent - mortalityLast) / mortalityLast * 100 - } + fcrPercent := percentDelta(fcrCurrent, fcrLast) + mortalityPercent := percentDelta(mortalityCurrent, mortalityLast) stats = append(stats, dto.DashboardStatisticsDTO{ Label: "FCR", Value: roundTo(fcrCurrent, 2), - PercentLastMonth: fcrPercent, + PercentLastMonth: roundTo(fcrPercent*100, 2), }, dto.DashboardStatisticsDTO{ Label: "Mortality", Value: roundTo(mortalityCurrent, 2), - PercentLastMonth: mortalityPercent, + PercentLastMonth: roundTo(mortalityPercent*100, 2), }, ) } @@ -849,6 +836,13 @@ func buildComparisonPercentChart(seriesRows []repository.ComparisonSeries, weeks } } +func percentDelta(current, last float64) float64 { + if last <= 0 { + return 0 + } + return (current - last) / last +} + func (s dashboardService) calculateHppGlobal(ctx context.Context, filter *validation.DashboardFilter, startDate, endExclusive, endDate time.Time, location *time.Location) (float64, float64, error) { totalEggKg, err := s.Repository.SumEggProductionWeightKg(ctx, startDate, endExclusive, filter) if err != nil { From 9515848d8ff86b438eb685c29a6c39d4e8141ca4 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Mon, 12 Jan 2026 10:11:31 +0700 Subject: [PATCH 14/18] feat(BE): update approval flow to use head area instead of manager --- internal/middleware/permissions.go | 2 +- internal/modules/expenses/controllers/expense.controller.go | 4 ++-- internal/modules/expenses/route.go | 2 +- internal/modules/expenses/services/expense.service.go | 6 +++--- .../inventory/transfers/services/transfer_expense_bridge.go | 2 +- internal/modules/purchases/services/expense_bridge.go | 2 +- internal/utils/constant.go | 4 ++-- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/internal/middleware/permissions.go b/internal/middleware/permissions.go index 5820db27..6e4fe6db 100644 --- a/internal/middleware/permissions.go +++ b/internal/middleware/permissions.go @@ -24,7 +24,7 @@ const ( P_ExpenseUpdateOne = "lti.expense.update" P_ExpenseGetOne = "lti.expense.detail" P_ExpenseDeleteOne = "lti.expense.delete" - P_ExpenseApprovalManager = "lti.expense.approve.manager" + P_ExpenseApprovalHeadArea = "lti.expense.approve.head_area" P_ExpenseApprovalFinance = "lti.expense.approve.finance" P_ExpenseApprovalUnitVicePresident = "lti.expense.approve.unit_vice_president" P_ExpenseCreateRealizations = "lti.expense.create.realization" diff --git a/internal/modules/expenses/controllers/expense.controller.go b/internal/modules/expenses/controllers/expense.controller.go index 125aeb0c..49c8f356 100644 --- a/internal/modules/expenses/controllers/expense.controller.go +++ b/internal/modules/expenses/controllers/expense.controller.go @@ -229,8 +229,8 @@ func (u *ExpenseController) Approval(c *fiber.Ctx) error { path := c.Path() approvalType := "" - if strings.Contains(path, "/approvals/manager") { - approvalType = "manager" + if strings.Contains(path, "/approvals/head-area") { + approvalType = "head-area" } else if strings.Contains(path, "/approvals/finance") { approvalType = "finance" } else if strings.Contains(path, "/approvals/unit-vice-president") { diff --git a/internal/modules/expenses/route.go b/internal/modules/expenses/route.go index cfb4dd23..6ddceb14 100644 --- a/internal/modules/expenses/route.go +++ b/internal/modules/expenses/route.go @@ -28,7 +28,7 @@ func ExpenseRoutes(v1 fiber.Router, u user.UserService, s expense.ExpenseService route.Patch("/:id", m.RequirePermissions(m.P_ExpenseUpdateOne), ctrl.UpdateOne) route.Delete("/:id", m.RequirePermissions(m.P_ExpenseDeleteOne), ctrl.DeleteOne) - route.Post("/approvals/manager", m.RequirePermissions(m.P_ExpenseApprovalManager), ctrl.Approval) + route.Post("/approvals/head-area", m.RequirePermissions(m.P_ExpenseApprovalHeadArea), ctrl.Approval) route.Post("/approvals/finance", m.RequirePermissions(m.P_ExpenseApprovalFinance), ctrl.Approval) route.Post("/approvals/unit-vice-president", m.RequirePermissions(m.P_ExpenseApprovalUnitVicePresident), ctrl.Approval) diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index 8afbac28..9a994bc9 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -1049,9 +1049,9 @@ func (s *expenseService) Approval(c *fiber.Ctx, req *validation.ApprovalRequest, } var stepNumber approvalutils.ApprovalStep - if approvalType == "manager" { + if approvalType == "head-area" { - stepNumber = utils.ExpenseStepManager + stepNumber = utils.ExpenseStepHeadArea if latestApproval.StepNumber != uint16(utils.ExpenseStepPengajuan) { currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)] return fiber.NewError(fiber.StatusBadRequest, @@ -1060,7 +1060,7 @@ func (s *expenseService) Approval(c *fiber.Ctx, req *validation.ApprovalRequest, } else if approvalType == "unit-vice-president" { stepNumber = utils.ExpenseStepUnitVicePresident - if latestApproval.StepNumber != uint16(utils.ExpenseStepManager) { + if latestApproval.StepNumber != uint16(utils.ExpenseStepHeadArea) { currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)] return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot process at Unit Vice President step. Latest approval is at %s step. Expected previous step: Head Area", currentStepName)) diff --git a/internal/modules/inventory/transfers/services/transfer_expense_bridge.go b/internal/modules/inventory/transfers/services/transfer_expense_bridge.go index d4322be6..c4f28354 100644 --- a/internal/modules/inventory/transfers/services/transfer_expense_bridge.go +++ b/internal/modules/inventory/transfers/services/transfer_expense_bridge.go @@ -252,7 +252,7 @@ func (b *transferExpenseBridge) createExpenseViaService( actorID = 1 } approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(b.db)) - if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepManager, &action, actorID, nil); err != nil { + if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepHeadArea, &action, actorID, nil); err != nil { return nil, err } if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepUnitVicePresident, &action, actorID, nil); err != nil { diff --git a/internal/modules/purchases/services/expense_bridge.go b/internal/modules/purchases/services/expense_bridge.go index 094b99c1..7e5cbd91 100644 --- a/internal/modules/purchases/services/expense_bridge.go +++ b/internal/modules/purchases/services/expense_bridge.go @@ -618,7 +618,7 @@ func (b *expenseBridge) createExpenseViaService( actorID = 1 } approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(b.db)) - if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepManager, &action, actorID, nil); err != nil { + if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepHeadArea, &action, actorID, nil); err != nil { return nil, err } if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepUnitVicePresident, &action, actorID, nil); err != nil { diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 44c79e35..ba0f51f1 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -356,7 +356,7 @@ var MarketingApprovalSteps = map[approvalutils.ApprovalStep]string{ const ( ApprovalWorkflowExpense approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("EXPENSES") ExpenseStepPengajuan approvalutils.ApprovalStep = 1 - ExpenseStepManager approvalutils.ApprovalStep = 2 + ExpenseStepHeadArea approvalutils.ApprovalStep = 2 ExpenseStepUnitVicePresident approvalutils.ApprovalStep = 3 ExpenseStepFinance approvalutils.ApprovalStep = 4 ExpenseStepRealisasi approvalutils.ApprovalStep = 5 @@ -365,7 +365,7 @@ const ( var ExpenseApprovalSteps = map[approvalutils.ApprovalStep]string{ ExpenseStepPengajuan: "Pengajuan", - ExpenseStepManager: "Approval Head Area", + ExpenseStepHeadArea: "Approval Head Area", ExpenseStepUnitVicePresident: "Approval Business Unit Vice President", ExpenseStepFinance: "Approval Finance", ExpenseStepRealisasi: "Realisasi", From d568b87e01ba8cfcf72145d8fcd3922f59c37164 Mon Sep 17 00:00:00 2001 From: MacBook Air M1 Date: Mon, 12 Jan 2026 10:37:33 +0700 Subject: [PATCH 15/18] adjust response api summary daily checklist --- .../controllers/daily-checklist.controller.go | 4 ++++ .../daily-checklists/dto/daily-checklist.dto.go | 11 ++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/internal/modules/daily-checklists/controllers/daily-checklist.controller.go b/internal/modules/daily-checklists/controllers/daily-checklist.controller.go index 5819d03e..d97424fa 100644 --- a/internal/modules/daily-checklists/controllers/daily-checklist.controller.go +++ b/internal/modules/daily-checklists/controllers/daily-checklist.controller.go @@ -151,6 +151,10 @@ func (u *DailyChecklistController) GetSummary(c *fiber.Ctx) error { performanceMap[summary.EmployeeID] = &dto.DailyChecklistPerformanceOverviewDTO{ EmployeeID: summary.EmployeeID, EmployeeName: summary.EmployeeName, + Kandang: dto.DailyChecklistReportEntityDTO{ + Id: summary.KandangID, + Name: summary.KandangName, + }, } } diff --git a/internal/modules/daily-checklists/dto/daily-checklist.dto.go b/internal/modules/daily-checklists/dto/daily-checklist.dto.go index 62195382..58ca6bb0 100644 --- a/internal/modules/daily-checklists/dto/daily-checklist.dto.go +++ b/internal/modules/daily-checklists/dto/daily-checklist.dto.go @@ -64,11 +64,12 @@ type DailyChecklistSummaryDTO struct { } type DailyChecklistPerformanceOverviewDTO struct { - EmployeeID uint `json:"employee_id"` - EmployeeName string `json:"employee_name"` - TotalActivity int `json:"total_activity"` - ActivityDone int `json:"activity_done"` - ActivityLeft int `json:"activity_left"` + EmployeeID uint `json:"employee_id"` + EmployeeName string `json:"employee_name"` + Kandang DailyChecklistReportEntityDTO `json:"kandang"` + TotalActivity int `json:"total_activity"` + ActivityDone int `json:"activity_done"` + ActivityLeft int `json:"activity_left"` } type DailyChecklistReportDTO struct { From 539081ce995ee2983837f7564682838ec80b463c Mon Sep 17 00:00:00 2001 From: "Hafizh A. Y" Date: Mon, 12 Jan 2026 10:37:55 +0700 Subject: [PATCH 16/18] fix(BE): is visible to true in product service --- internal/modules/master/products/services/product.service.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/modules/master/products/services/product.service.go b/internal/modules/master/products/services/product.service.go index f40d92be..db4fb8a7 100644 --- a/internal/modules/master/products/services/product.service.go +++ b/internal/modules/master/products/services/product.service.go @@ -176,6 +176,7 @@ func (s *productService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit SellingPrice: req.SellingPrice, Tax: req.Tax, ExpiryPeriod: req.ExpiryPeriod, + IsVisible: true, CreatedBy: 1, } From 15be8dcbeab9fc877c528fb91a82a23c3ce078a5 Mon Sep 17 00:00:00 2001 From: MacBook Air M1 Date: Mon, 12 Jan 2026 11:09:32 +0700 Subject: [PATCH 17/18] fix route daily checklist --- internal/modules/daily-checklists/route.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/modules/daily-checklists/route.go b/internal/modules/daily-checklists/route.go index 0f6657c0..9e576a05 100644 --- a/internal/modules/daily-checklists/route.go +++ b/internal/modules/daily-checklists/route.go @@ -1,7 +1,7 @@ package dailyChecklists import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/controllers" dailyChecklist "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,7 +13,7 @@ func DailyChecklistRoutes(v1 fiber.Router, u user.UserService, s dailyChecklist. ctrl := controller.NewDailyChecklistController(s) route := v1.Group("/daily-checklists") - // route.Use(m.Auth(u)) + route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) route.Get("/report", ctrl.GetReport) @@ -22,7 +22,7 @@ func DailyChecklistRoutes(v1 fiber.Router, u user.UserService, s dailyChecklist. route.Get("/report", ctrl.GetReport) - // create daily checklist + // upsert daily checklist route.Post("/", ctrl.CreateOne) // get detail data daily checklist by id From b47f26d448c7081e9a3f26070e8d9bcb14ef6f46 Mon Sep 17 00:00:00 2001 From: MacBook Air M1 Date: Mon, 12 Jan 2026 11:30:57 +0700 Subject: [PATCH 18/18] adjust max limit location and kandang --- .../modules/master/kandangs/validations/kandang.validation.go | 2 +- .../modules/master/locations/validations/location.validation.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/modules/master/kandangs/validations/kandang.validation.go b/internal/modules/master/kandangs/validations/kandang.validation.go index f4adc55e..63f03d12 100644 --- a/internal/modules/master/kandangs/validations/kandang.validation.go +++ b/internal/modules/master/kandangs/validations/kandang.validation.go @@ -20,7 +20,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"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=500"` Search string `query:"search" validate:"omitempty,max=50"` LocationId int `query:"location_id" validate:"omitempty,number,gt=0"` PicId int `query:"pic_id" validate:"omitempty,number,gt=0"` diff --git a/internal/modules/master/locations/validations/location.validation.go b/internal/modules/master/locations/validations/location.validation.go index 61ab4125..a2ac6175 100644 --- a/internal/modules/master/locations/validations/location.validation.go +++ b/internal/modules/master/locations/validations/location.validation.go @@ -14,7 +14,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"` + 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"` }