package service import ( "context" "fmt" "strings" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "github.com/sirupsen/logrus" 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" projectflockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" ) type SapronakService interface { GetSapronakByProject(ctx *fiber.Ctx, projectFlockID uint, flag string) ([]dto.SapronakReportDTO, map[uint][]string, error) GetSapronakByKandang(ctx *fiber.Ctx, projectFlockID uint, pfkID uint, flag string) (*dto.SapronakReportDTO, map[uint][]string, error) } type sapronakService struct { Log *logrus.Logger Validate *validator.Validate Repository repository.ClosingRepository ProjectFlockKandangRepo projectflockRepository.ProjectFlockKandangRepository } func NewSapronakService( repo repository.ClosingRepository, pfkRepo projectflockRepository.ProjectFlockKandangRepository, validate *validator.Validate, ) SapronakService { return &sapronakService{ Log: utils.Log, Validate: validate, Repository: repo, ProjectFlockKandangRepo: pfkRepo, } } func (s sapronakService) GetSapronakByProject(c *fiber.Ctx, projectFlockID uint, flag string) ([]dto.SapronakReportDTO, map[uint][]string, error) { if projectFlockID == 0 { return nil, nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_id is required") } reports, err := s.computeSapronakReports(c.Context(), &validation.CountSapronakQuery{ ProjectFlockID: projectFlockID, Status: "all", Flag: flag, }) if err != nil { return nil, nil, err } if len(reports) <= 1 { flags, err := s.collectProductFlags(c.Context(), reports) if err != nil { return nil, nil, err } return reports, flags, nil } combined := s.combineSapronakReports(reports, projectFlockID) flags, err := s.collectProductFlags(c.Context(), []dto.SapronakReportDTO{combined}) if err != nil { return nil, nil, err } return []dto.SapronakReportDTO{combined}, flags, nil } func (s sapronakService) GetSapronakByKandang(c *fiber.Ctx, projectFlockID uint, pfkID uint, flag string) (*dto.SapronakReportDTO, map[uint][]string, error) { if projectFlockID == 0 || pfkID == 0 { return nil, nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_id and project_flock_kandang_id are required") } results, err := s.computeSapronakReports(c.Context(), &validation.CountSapronakQuery{ ProjectFlockID: projectFlockID, ProjectFlockKandangID: pfkID, Status: "all", Flag: flag, }) if err != nil { return nil, nil, err } for _, res := range results { if res.ProjectFlockID == projectFlockID && res.ProjectFlockKandangID == pfkID { flags, err := s.collectProductFlags(c.Context(), []dto.SapronakReportDTO{res}) if err != nil { return nil, nil, err } return &res, flags, nil } } return nil, nil, fiber.NewError(fiber.StatusNotFound, "Sapronak for kandang not found") } func (s sapronakService) computeSapronakReports(ctx context.Context, params *validation.CountSapronakQuery) ([]dto.SapronakReportDTO, error) { pfks, err := s.loadProjectFlockKandangs(ctx, params) if err != nil { return nil, err } if len(pfks) == 0 { return []dto.SapronakReportDTO{}, nil } filterStatus := strings.ToLower(strings.TrimSpace(params.Status)) if filterStatus == "" { filterStatus = "all" } results := make([]dto.SapronakReportDTO, 0, len(pfks)) for _, pfk := range pfks { status := "closing" if pfk.ClosedAt == nil { status = "active" } if (filterStatus == "active" && status != "active") || (filterStatus == "closing" && status != "closing") { continue } // We no longer filter by date for closing sapronak report; pass nil pointers. items, groups, totalIncoming, totalUsage, err := s.buildSapronakItems(ctx, pfk, params.Flag) if err != nil { s.Log.Errorf("Failed to build sapronak items for pfk %d: %+v", pfk.Id, err) 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, TotalIncomingValue: totalIncoming, TotalUsageValue: totalUsage, Items: items, Groups: groups, }) } return results, nil } func (s sapronakService) collectProductFlags(ctx context.Context, reports []dto.SapronakReportDTO) (map[uint][]string, error) { productIDs := make(map[uint]struct{}) for _, report := range reports { for _, group := range report.Groups { for _, item := range group.Items { if item.ProductID > 0 { productIDs[item.ProductID] = struct{}{} } } } } if len(productIDs) == 0 { return map[uint][]string{}, nil } ids := make([]uint, 0, len(productIDs)) for id := range productIDs { ids = append(ids, id) } products, err := s.Repository.GetProductsWithFlagsByIDs(ctx, ids) if err != nil { return nil, err } result := make(map[uint][]string, len(products)) for _, product := range products { if len(product.Flags) == 0 { continue } flags := make([]string, 0, len(product.Flags)) for _, flag := range product.Flags { name := strings.TrimSpace(flag.Name) if name == "" { continue } flags = append(flags, strings.ToUpper(name)) } if len(flags) > 0 { result[product.Id] = flags } } return result, nil } func (s sapronakService) loadProjectFlockKandangs(ctx context.Context, params *validation.CountSapronakQuery) ([]entity.ProjectFlockKandang, error) { db := s.ProjectFlockKandangRepo.DB().WithContext(ctx). Preload("ProjectFlock"). Preload("Kandang"). Preload("Chickins") 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) } if params.ProjectFlockKandangID > 0 { db = db.Where("project_flock_kandangs.id = ?", params.ProjectFlockKandangID) } } 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 sapronakService) combineSapronakReports(reports []dto.SapronakReportDTO, projectID uint) dto.SapronakReportDTO { if len(reports) == 0 { return dto.SapronakReportDTO{} } var ( totalIncoming float64 totalUsage float64 projectName = reports[0].ProjectName ) itemMap := make(map[uint]dto.SapronakItemDTO) groupMap := make(map[string]*dto.SapronakGroupDTO) ensureGroup := func(flag string) *dto.SapronakGroupDTO { if g, ok := groupMap[flag]; ok { return g } groupMap[flag] = &dto.SapronakGroupDTO{Flag: flag} return groupMap[flag] } for _, r := range reports { totalIncoming += r.TotalIncomingValue totalUsage += r.TotalUsageValue for _, it := range r.Items { cur := itemMap[it.ProductID] if cur.ProductID == 0 { cur.ProductID = it.ProductID cur.ProductName = it.ProductName cur.Flag = it.Flag } cur.IncomingQty += it.IncomingQty cur.IncomingValue += it.IncomingValue cur.UsageQty += it.UsageQty cur.UsageValue += it.UsageValue if cur.IncomingQty >= cur.UsageQty { cur.RemainingQty = cur.IncomingQty - cur.UsageQty } else { cur.RemainingQty = 0 } if cur.IncomingQty > 0 { cur.AveragePrice = cur.IncomingValue / cur.IncomingQty } else { cur.AveragePrice = it.AveragePrice } itemMap[it.ProductID] = cur } for _, g := range r.Groups { agg := ensureGroup(g.Flag) agg.TotalMasuk += g.TotalMasuk agg.TotalKeluar += g.TotalKeluar agg.SaldoAkhir += g.SaldoAkhir agg.TotalNilai += g.TotalNilai agg.Items = append(agg.Items, g.Items...) } } items := make([]dto.SapronakItemDTO, 0, len(itemMap)) for _, it := range itemMap { items = append(items, it) } groups := make([]dto.SapronakGroupDTO, 0, len(groupMap)) for _, g := range groupMap { groups = append(groups, *g) } return dto.SapronakReportDTO{ ProjectFlockID: projectID, ProjectName: projectName, Status: "combined", StartDate: nil, TotalIncomingValue: totalIncoming, TotalUsageValue: totalUsage, Items: items, Groups: groups, } } func mapIncomingUsage(incomingRows []repository.SapronakIncomingRow, usageRows []repository.SapronakUsageRow) (map[uint]repository.SapronakIncomingRow, map[uint]repository.SapronakUsageRow) { incoming := make(map[uint]repository.SapronakIncomingRow, len(incomingRows)) for _, row := range incomingRows { incoming[row.ProductID] = row } usage := make(map[uint]repository.SapronakUsageRow, len(usageRows)) for _, row := range usageRows { usage[row.ProductID] = row } return incoming, usage } type sapronakDetailMaps struct { Incoming map[uint][]dto.SapronakDetailDTO Usage map[uint][]dto.SapronakDetailDTO AdjIncoming map[uint][]dto.SapronakDetailDTO AdjOutgoing map[uint][]dto.SapronakDetailDTO TransferIn map[uint][]dto.SapronakDetailDTO TransferOut map[uint][]dto.SapronakDetailDTO SalesOut map[uint][]dto.SapronakDetailDTO } func buildSapronakDetails( incomingRows map[uint][]repository.SapronakDetailRow, usageRows map[uint][]repository.SapronakDetailRow, adjIncomingRows map[uint][]repository.SapronakDetailRow, adjOutgoingRows map[uint][]repository.SapronakDetailRow, transferInRows map[uint][]repository.SapronakDetailRow, transferOutRows map[uint][]repository.SapronakDetailRow, salesOutRows map[uint][]repository.SapronakDetailRow, ) sapronakDetailMaps { result := sapronakDetailMaps{ Incoming: make(map[uint][]dto.SapronakDetailDTO), Usage: make(map[uint][]dto.SapronakDetailDTO), AdjIncoming: make(map[uint][]dto.SapronakDetailDTO), AdjOutgoing: make(map[uint][]dto.SapronakDetailDTO), TransferIn: make(map[uint][]dto.SapronakDetailDTO), TransferOut: make(map[uint][]dto.SapronakDetailDTO), SalesOut: make(map[uint][]dto.SapronakDetailDTO), } addRows := func(target map[uint][]dto.SapronakDetailDTO, src map[uint][]repository.SapronakDetailRow, jenis string, masuk bool) { for pid, rows := range src { for _, r := range rows { d := dto.SapronakDetailDTO{ ProductID: r.ProductID, ProductName: r.ProductName, Flag: r.Flag, Tanggal: r.Date, NoReferensi: r.Reference, JenisTransaksi: jenis, Harga: r.Price, } if masuk { d.QtyMasuk = r.QtyIn d.Nilai = r.QtyIn * r.Price } else { d.QtyKeluar = r.QtyOut d.Nilai = r.QtyOut * r.Price } target[pid] = append(target[pid], d) } } } addRows(result.Incoming, incomingRows, "Pembelian", true) addRows(result.Usage, usageRows, "Pemakaian", false) addRows(result.AdjIncoming, adjIncomingRows, "Adjustment Masuk", true) addRows(result.AdjOutgoing, adjOutgoingRows, "Adjustment Keluar", false) addRows(result.TransferIn, transferInRows, "Mutasi Masuk", true) addRows(result.TransferOut, transferOutRows, "Mutasi Keluar", false) addRows(result.SalesOut, salesOutRows, "Penjualan", false) return result } func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.ProjectFlockKandang, flagFilter string) ([]dto.SapronakItemDTO, []dto.SapronakGroupDTO, float64, float64, error) { // For sapronak closing report we intentionally ignore date range // and aggregate all historical transactions for the kandang/project. incomingRows, err := s.Repository.FetchSapronakIncoming(ctx, pfk.KandangId) if err != nil { return nil, nil, 0, 0, err } incomingDetailsRows, err := s.Repository.FetchSapronakIncomingDetails(ctx, pfk.KandangId) if err != nil { return nil, nil, 0, 0, err } usageRows, err := s.Repository.FetchSapronakUsage(ctx, pfk.Id) if err != nil { return nil, nil, 0, 0, err } chickinUsageRows, err := s.Repository.FetchSapronakChickinUsage(ctx, pfk.Id) if err != nil { return nil, nil, 0, 0, err } usageDetailsRows, err := s.Repository.FetchSapronakUsageDetails(ctx, pfk.Id) if err != nil { return nil, nil, 0, 0, err } chickinUsageDetailsRows, err := s.Repository.FetchSapronakChickinUsageDetails(ctx, pfk.Id) if err != nil { return nil, nil, 0, 0, err } usageAllocatedDetails, err := s.Repository.FetchSapronakUsageAllocatedDetails(ctx, pfk.Id) if err != nil { return nil, nil, 0, 0, err } if len(usageAllocatedDetails) > 0 { usageDetailsRows = usageAllocatedDetails chickinUsageDetailsRows = map[uint][]repository.SapronakDetailRow{} } adjIncomingRows, adjOutgoingRows, err := s.Repository.FetchSapronakAdjustments(ctx, pfk.KandangId) if err != nil { return nil, nil, 0, 0, err } transIncomingRows, transOutgoingRows, err := s.Repository.FetchSapronakTransfers(ctx, pfk.KandangId) if err != nil { return nil, nil, 0, 0, err } salesOutRows, err := s.Repository.FetchSapronakSalesAllocatedDetails(ctx, pfk.Id) if err != nil { return nil, nil, 0, 0, err } filterFlag := strings.ToUpper(strings.TrimSpace(flagFilter)) matchesFlag := func(f string) bool { if filterFlag == "" { return true } candidate := strings.ToUpper(f) if filterFlag == "DOC" || filterFlag == "PULLET" { return candidate == "DOC" || candidate == "PULLET" } return candidate == filterFlag } dedupTransfers := func(src map[uint][]dto.SapronakDetailDTO) map[uint][]dto.SapronakDetailDTO { result := make(map[uint][]dto.SapronakDetailDTO, len(src)) seen := make(map[string]struct{}) for pid, rows := range src { for _, d := range rows { dateKey := "" if d.Tanggal != nil { dateKey = d.Tanggal.Format("2006-01-02") } qtyKey := d.QtyMasuk if qtyKey == 0 { qtyKey = d.QtyKeluar } ref := strings.TrimSpace(d.NoReferensi) key := fmt.Sprintf("%d|%s|%s|%.3f", pid, ref, dateKey, qtyKey) if ref == "" { key = fmt.Sprintf("%d|%s|%s|%.3f|%s", pid, ref, dateKey, qtyKey, strings.ToUpper(strings.TrimSpace(d.Flag))) } if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} result[pid] = append(result[pid], d) } } return result } // For project flocks with category GROWING, pullet usage from chickin // should not be counted yet. Only when category is LAYING we allow // pullet usage to contribute to qty_used. isLaying := strings.EqualFold(string(pfk.ProjectFlock.Category), string(utils.ProjectFlockCategoryLaying)) if !isLaying { filteredUsage := make([]repository.SapronakUsageRow, 0, len(chickinUsageRows)) for _, row := range chickinUsageRows { if strings.ToUpper(row.Flag) == "DOC" { filteredUsage = append(filteredUsage, row) } } chickinUsageRows = filteredUsage filteredDetail := make(map[uint][]repository.SapronakDetailRow, len(chickinUsageDetailsRows)) for pid, rows := range chickinUsageDetailsRows { for _, d := range rows { if strings.ToUpper(d.Flag) == "DOC" { filteredDetail[pid] = append(filteredDetail[pid], d) } } } chickinUsageDetailsRows = filteredDetail } allUsageRows := append(usageRows, chickinUsageRows...) incoming, usage := mapIncomingUsage(incomingRows, allUsageRows) itemMap := make(map[uint]dto.SapronakItemDTO, len(incoming)+len(usage)) groupMap := make(map[string]*dto.SapronakGroupDTO) for pid, rows := range chickinUsageDetailsRows { if len(rows) == 0 { continue } usageDetailsRows[pid] = append(usageDetailsRows[pid], rows...) } detailMaps := buildSapronakDetails(incomingDetailsRows, usageDetailsRows, adjIncomingRows, adjOutgoingRows, transIncomingRows, transOutgoingRows, salesOutRows) incomingDetails := detailMaps.Incoming usageDetails := detailMaps.Usage adjIncoming := detailMaps.AdjIncoming adjOutgoing := detailMaps.AdjOutgoing transIncoming := detailMaps.TransferIn transOutgoing := detailMaps.TransferOut salesOutgoing := detailMaps.SalesOut transIncoming = dedupTransfers(transIncoming) transOutgoing = dedupTransfers(transOutgoing) ensureGroup := func(flag string) *dto.SapronakGroupDTO { if g, ok := groupMap[flag]; ok { return g } groupMap[flag] = &dto.SapronakGroupDTO{Flag: flag} return groupMap[flag] } resolveFlagName := func(productID uint, details []dto.SapronakDetailDTO) (string, string) { flag := "" name := "" if item, ok := itemMap[productID]; ok { flag = item.Flag name = item.ProductName } if flag == "" && len(details) > 0 { flag = details[0].Flag } if name == "" && len(details) > 0 { name = details[0].ProductName } return flag, name } for _, row := range incoming { if !matchesFlag(row.Flag) { continue } 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 { if !matchesFlag(row.Flag) { continue } 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 } for productID, details := range adjIncoming { for _, d := range details { if !matchesFlag(d.Flag) { continue } existing := itemMap[productID] if existing.Flag == "" { existing.Flag = d.Flag } if existing.ProductName == "" { existing.ProductName = d.ProductName } existing.IncomingQty += d.QtyMasuk existing.IncomingValue += d.Nilai if existing.IncomingQty > 0 { existing.AveragePrice = existing.IncomingValue / existing.IncomingQty } if existing.IncomingQty >= existing.UsageQty { existing.RemainingQty = existing.IncomingQty - existing.UsageQty } else { existing.RemainingQty = 0 } itemMap[productID] = existing } } for productID, details := range adjOutgoing { for _, d := range details { if !matchesFlag(d.Flag) { continue } existing := itemMap[productID] if existing.Flag == "" { existing.Flag = d.Flag } if existing.ProductName == "" { existing.ProductName = d.ProductName } // Adjustment keluar should reduce stock without inflating usage-based HPP. remaining := existing.IncomingQty - existing.UsageQty - d.QtyKeluar if remaining < 0 { remaining = 0 } existing.RemainingQty = remaining itemMap[productID] = existing } } for productID, details := range transIncoming { for _, d := range details { if !matchesFlag(d.Flag) { continue } existing := itemMap[productID] if existing.Flag == "" { existing.Flag = d.Flag } if existing.ProductName == "" { existing.ProductName = d.ProductName } existing.IncomingQty += d.QtyMasuk existing.IncomingValue += d.Nilai if existing.IncomingQty > 0 { existing.AveragePrice = existing.IncomingValue / existing.IncomingQty } if existing.IncomingQty >= existing.UsageQty { existing.RemainingQty = existing.IncomingQty - existing.UsageQty } else { existing.RemainingQty = 0 } itemMap[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) } for productID, details := range incomingDetails { flag, name := resolveFlagName(productID, details) if !matchesFlag(flag) { continue } group := ensureGroup(flag) for _, d := range details { if d.Flag == "" { d.Flag = flag } if d.ProductName == "" { d.ProductName = name } group.Items = append(group.Items, d) group.TotalMasuk += d.QtyMasuk group.TotalNilai += d.Nilai group.SaldoAkhir += d.QtyMasuk } } for productID, details := range adjIncoming { flag, name := resolveFlagName(productID, details) if !matchesFlag(flag) { continue } group := ensureGroup(flag) for _, d := range details { if d.Flag == "" { d.Flag = flag } if d.ProductName == "" { d.ProductName = name } group.Items = append(group.Items, d) group.TotalMasuk += d.QtyMasuk group.TotalNilai += d.Nilai group.SaldoAkhir += d.QtyMasuk } } for productID, details := range usageDetails { flag, name := resolveFlagName(productID, details) if !matchesFlag(flag) { continue } group := ensureGroup(flag) for _, d := range details { if d.Flag == "" { d.Flag = flag } if d.ProductName == "" { d.ProductName = name } group.Items = append(group.Items, d) group.TotalKeluar += d.QtyKeluar group.SaldoAkhir -= d.QtyKeluar } } for productID, details := range adjOutgoing { flag, name := resolveFlagName(productID, details) if !matchesFlag(flag) { continue } group := ensureGroup(flag) for _, d := range details { if d.Flag == "" { d.Flag = flag } if d.ProductName == "" { d.ProductName = name } group.Items = append(group.Items, d) group.TotalKeluar += d.QtyKeluar group.SaldoAkhir -= d.QtyKeluar } } for productID, details := range transIncoming { flag, name := resolveFlagName(productID, details) if !matchesFlag(flag) { continue } group := ensureGroup(flag) for _, d := range details { if d.Flag == "" { d.Flag = flag } if d.ProductName == "" { d.ProductName = name } group.Items = append(group.Items, d) group.TotalMasuk += d.QtyMasuk group.TotalNilai += d.Nilai group.SaldoAkhir += d.QtyMasuk } } for productID, details := range transOutgoing { flag, name := resolveFlagName(productID, details) if !matchesFlag(flag) { continue } group := ensureGroup(flag) for _, d := range details { if d.Flag == "" { d.Flag = flag } if d.ProductName == "" { d.ProductName = name } group.Items = append(group.Items, d) group.TotalKeluar += d.QtyKeluar group.SaldoAkhir -= d.QtyKeluar } } for productID, details := range salesOutgoing { flag, name := resolveFlagName(productID, details) if !matchesFlag(flag) { continue } group := ensureGroup(flag) for _, d := range details { if d.Flag == "" { d.Flag = flag } if d.ProductName == "" { d.ProductName = name } group.Items = append(group.Items, d) group.TotalKeluar += d.QtyKeluar group.SaldoAkhir -= d.QtyKeluar } } groups := make([]dto.SapronakGroupDTO, 0, len(groupMap)) for _, g := range groupMap { groups = append(groups, *g) } return items, groups, totalIncoming, totalUsage, nil }