package service import ( "context" "errors" "sort" "strings" "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/dto" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/validations" projectFlockRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "github.com/sirupsen/logrus" "gorm.io/gorm" ) type ClosingService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, error) GetSapronakReport(ctx *fiber.Ctx, params *validation.SapronakQuery) ([]dto.SapronakReportDTO, error) } type closingService struct { Log *logrus.Logger Validate *validator.Validate Repository repository.ClosingRepository ProjectFlockKandangRepo projectFlockRepo.ProjectFlockKandangRepository } func NewClosingService( repo repository.ClosingRepository, projectFlockKandangRepo projectFlockRepo.ProjectFlockKandangRepository, validate *validator.Validate, ) ClosingService { return &closingService{ Log: utils.Log, Validate: validate, Repository: repo, ProjectFlockKandangRepo: projectFlockKandangRepo, } } func (s closingService) withRelations(db *gorm.DB) *gorm.DB { return db.Preload("CreatedUser") } func (s closingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err } offset := (params.Page - 1) * params.Limit closings, 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.Order("created_at DESC").Order("updated_at DESC") }) if err != nil { s.Log.Errorf("Failed to get closings: %+v", err) return nil, 0, err } return closings, total, nil } func (s closingService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlock, error) { closing, err := s.Repository.GetByID(c.Context(), id, s.withRelations) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Closing not found") } if err != nil { s.Log.Errorf("Failed get closing by id: %+v", err) return nil, err } return closing, nil } var sapronakFlags = []string{ string(utils.FlagDOC), string(utils.FlagPakan), string(utils.FlagOVK), } type sapronakIncomingRow struct { ProductID uint ProductName string Flag string Qty float64 Value float64 DefaultPrice float64 } type sapronakUsageRow struct { ProductID uint ProductName string Flag string Qty float64 DefaultPrice float64 } func (s closingService) GetSapronakReport(c *fiber.Ctx, params *validation.SapronakQuery) ([]dto.SapronakReportDTO, error) { if err := s.Validate.Struct(params); err != nil { return nil, err } pfks, err := s.loadProjectFlockKandangs(c, params) if err != nil { return nil, err } if len(pfks) == 0 { return []dto.SapronakReportDTO{}, nil } startMap, err := s.mapStartDates(c.Context(), pfks) if err != nil { s.Log.Errorf("Failed to prepare start dates for sapronak report: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to prepare sapronak report") } statusMap, nextStartMap := s.computeStatusAndNextStart(pfks, startMap) filterStatus := strings.ToLower(strings.TrimSpace(params.Status)) if filterStatus == "" { filterStatus = "all" } results := make([]dto.SapronakReportDTO, 0, len(pfks)) for _, pfk := range pfks { status := statusMap[pfk.Id] if status == "" { status = "closing" } if (filterStatus == "active" && status != "active") || (filterStatus == "closing" && status != "closing") { continue } start := startMap[pfk.Id] var startPtr *time.Time if !start.IsZero() { startCopy := start startPtr = &startCopy } var endPtr *time.Time if end, ok := nextStartMap[pfk.Id]; ok { endCopy := end endPtr = &endCopy } items, totalIncoming, totalUsage, err := s.buildSapronakItems(c.Context(), pfk, startPtr, endPtr) if err != nil { s.Log.Errorf("Failed to build sapronak items for pfk %d: %+v", pfk.Id, err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to calculate sapronak report") } results = append(results, dto.SapronakReportDTO{ ProjectFlockKandangID: pfk.Id, ProjectFlockID: pfk.ProjectFlockId, ProjectName: pfk.ProjectFlock.FlockName, KandangID: pfk.KandangId, KandangName: pfk.Kandang.Name, Period: pfk.Period, Status: status, StartDate: startPtr, EndDate: endPtr, TotalIncomingValue: totalIncoming, TotalUsageValue: totalUsage, Items: items, }) } sort.Slice(results, func(i, j int) bool { if results[i].KandangID == results[j].KandangID { if results[i].Period == results[j].Period { return results[i].ProjectFlockKandangID < results[j].ProjectFlockKandangID } return results[i].Period < results[j].Period } return results[i].KandangID < results[j].KandangID }) return results, nil } func (s closingService) loadProjectFlockKandangs(c *fiber.Ctx, params *validation.SapronakQuery) ([]entity.ProjectFlockKandang, error) { db := s.ProjectFlockKandangRepo.DB(). WithContext(c.Context()). Preload("ProjectFlock"). Preload("Kandang") if params != nil { if params.ProjectFlockID > 0 { db = db.Where("project_flock_kandangs.project_flock_id = ?", params.ProjectFlockID) } if params.KandangID > 0 { db = db.Where("project_flock_kandangs.kandang_id = ?", params.KandangID) } } var pfks []entity.ProjectFlockKandang if err := db.Find(&pfks).Error; err != nil { s.Log.Errorf("Failed to load project flock kandangs for sapronak report: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load project flock kandangs") } return pfks, nil } func (s closingService) mapStartDates(ctx context.Context, pfks []entity.ProjectFlockKandang) (map[uint]time.Time, error) { result := make(map[uint]time.Time, len(pfks)) if len(pfks) == 0 { return result, nil } ids := make([]uint, len(pfks)) for i, pfk := range pfks { ids[i] = pfk.Id } var rows []struct { ProjectFlockKandangID uint `gorm:"column:project_flock_kandang_id"` StartDate *time.Time `gorm:"column:start_date"` } if err := s.ProjectFlockKandangRepo.DB(). WithContext(ctx). Table("project_chickins"). Select("project_flock_kandang_id, MIN(chick_in_date) AS start_date"). Where("project_flock_kandang_id IN ?", ids). Group("project_flock_kandang_id"). Scan(&rows).Error; err != nil { return nil, err } for _, row := range rows { if row.StartDate != nil { result[row.ProjectFlockKandangID] = row.StartDate.UTC() } } for _, pfk := range pfks { if _, exists := result[pfk.Id]; !exists { result[pfk.Id] = pfk.CreatedAt.UTC() } } return result, nil } func (s closingService) computeStatusAndNextStart(pfks []entity.ProjectFlockKandang, startMap map[uint]time.Time) (map[uint]string, map[uint]time.Time) { statusMap := make(map[uint]string, len(pfks)) nextStartMap := make(map[uint]time.Time, len(pfks)) if len(pfks) == 0 { return statusMap, nextStartMap } grouped := make(map[uint][]entity.ProjectFlockKandang) for _, pfk := range pfks { grouped[pfk.KandangId] = append(grouped[pfk.KandangId], pfk) } for _, list := range grouped { sort.Slice(list, func(i, j int) bool { if list[i].Period == list[j].Period { return startMap[list[i].Id].Before(startMap[list[j].Id]) } return list[i].Period < list[j].Period }) for idx, item := range list { if idx < len(list)-1 { next := list[idx+1] if start, ok := startMap[next.Id]; ok { nextStartMap[item.Id] = start } statusMap[item.Id] = "closing" continue } statusMap[item.Id] = "active" } } return statusMap, nextStartMap } func (s closingService) buildSapronakItems(ctx context.Context, pfk entity.ProjectFlockKandang, start, end *time.Time) ([]dto.SapronakItemDTO, float64, float64, error) { incoming, err := s.fetchIncomingSapronak(ctx, pfk.KandangId, start, end) if err != nil { return nil, 0, 0, err } usage, err := s.fetchUsageSapronak(ctx, pfk.Id) if err != nil { return nil, 0, 0, err } itemMap := make(map[uint]dto.SapronakItemDTO, len(incoming)+len(usage)) for _, row := range incoming { avgPrice := row.DefaultPrice if row.Qty > 0 && row.Value > 0 { avgPrice = row.Value / row.Qty } itemMap[row.ProductID] = dto.SapronakItemDTO{ ProductID: row.ProductID, ProductName: row.ProductName, Flag: row.Flag, IncomingQty: row.Qty, IncomingValue: row.Value, RemainingQty: row.Qty, AveragePrice: avgPrice, } } for _, row := range usage { existing := itemMap[row.ProductID] price := existing.AveragePrice if price == 0 { price = row.DefaultPrice } usageValue := row.Qty * price existing.ProductID = row.ProductID if existing.ProductName == "" { existing.ProductName = row.ProductName } if existing.Flag == "" { existing.Flag = row.Flag } existing.AveragePrice = price existing.UsageQty += row.Qty existing.UsageValue += usageValue if existing.IncomingQty >= existing.UsageQty { existing.RemainingQty = existing.IncomingQty - existing.UsageQty } else { existing.RemainingQty = 0 } itemMap[row.ProductID] = existing } items := make([]dto.SapronakItemDTO, 0, len(itemMap)) var totalIncoming, totalUsage float64 for _, item := range itemMap { totalIncoming += item.IncomingValue totalUsage += item.UsageValue items = append(items, item) } sort.Slice(items, func(i, j int) bool { if items[i].Flag == items[j].Flag { return strings.ToLower(items[i].ProductName) < strings.ToLower(items[j].ProductName) } return items[i].Flag < items[j].Flag }) return items, totalIncoming, totalUsage, nil } func (s closingService) fetchIncomingSapronak(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint]sapronakIncomingRow, error) { rows := make([]sapronakIncomingRow, 0) db := s.Repository.DB(). WithContext(ctx). Table("purchase_items AS pi"). Select(` pi.product_id AS product_id, p.name AS product_name, f.name AS flag, COALESCE(SUM(pi.total_qty), 0) AS qty, COALESCE(SUM(pi.total_qty * pi.price), 0) AS value, COALESCE(p.product_price, 0) AS default_price `). Joins("JOIN purchases po ON po.id = pi.purchase_id AND po.deleted_at IS NULL"). Joins("JOIN products p ON p.id = pi.product_id"). Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). Joins("JOIN warehouses w ON w.id = pi.warehouse_id"). Where("w.kandang_id = ?", kandangID). Where("f.name IN ?", sapronakFlags). Where("pi.received_date IS NOT NULL") if start != nil { db = db.Where("pi.received_date >= ?", *start) } if end != nil { db = db.Where("pi.received_date < ?", *end) } if err := db.Group("pi.product_id, p.name, f.name, p.product_price").Scan(&rows).Error; err != nil { return nil, err } result := make(map[uint]sapronakIncomingRow, len(rows)) for _, row := range rows { result[row.ProductID] = row } return result, nil } func (s closingService) fetchUsageSapronak(ctx context.Context, pfkID uint) (map[uint]sapronakUsageRow, error) { rows := make([]sapronakUsageRow, 0) if pfkID == 0 { return map[uint]sapronakUsageRow{}, nil } db := s.Repository.DB(). WithContext(ctx). Table("recording_stocks AS rs"). Select(` pw.product_id AS product_id, p.name AS product_name, f.name AS flag, COALESCE(SUM(rs.usage_qty), 0) AS qty, COALESCE(p.product_price, 0) AS default_price `). Joins("JOIN recordings r ON r.id = rs.recording_id AND r.deleted_at IS NULL"). Joins("JOIN product_warehouses pw ON pw.id = rs.product_warehouse_id"). Joins("JOIN products p ON p.id = pw.product_id"). Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). Where("r.project_flock_kandangs_id = ?", pfkID). Where("f.name IN ?", sapronakFlags) if err := db.Group("pw.product_id, p.name, f.name, p.product_price").Scan(&rows).Error; err != nil { return nil, err } result := make(map[uint]sapronakUsageRow, len(rows)) for _, row := range rows { result[row.ProductID] = row } return result, nil }