From 7f623c0c1f000b2d497852789881861a69a82f08 Mon Sep 17 00:00:00 2001 From: "Hafizh A. Y" Date: Wed, 18 Feb 2026 11:34:38 +0700 Subject: [PATCH 1/5] fix(BE): fix report closing keuangan duplicate ovk, and closing keuangan devided by last recording --- .../repository/common.hpp.repository.go | 3 +- .../services/closingKeuangan.service.go | 32 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/internal/common/repository/common.hpp.repository.go b/internal/common/repository/common.hpp.repository.go index 2c8187fb..d1dc51f0 100644 --- a/internal/common/repository/common.hpp.repository.go +++ b/internal/common/repository/common.hpp.repository.go @@ -139,12 +139,11 @@ func (r *HppRepositoryImpl) GetOvkUsageCost(ctx context.Context, projectFlockKan Select("COALESCE(SUM(rs.usage_qty * COALESCE(pi.price, 0)), 0)"). Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id"). Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id"). - Joins("JOIN flags AS f ON f.flagable_id = pw.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct). Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String()). Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). Where("r.record_datetime <= ?", *date). - Where("f.name IN ?", flags). + Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flags). Scan(&total).Error if err != nil { return 0, err diff --git a/internal/modules/closings/services/closingKeuangan.service.go b/internal/modules/closings/services/closingKeuangan.service.go index 804ca023..757d553c 100644 --- a/internal/modules/closings/services/closingKeuangan.service.go +++ b/internal/modules/closings/services/closingKeuangan.service.go @@ -294,6 +294,9 @@ func (s closingKeuanganService) calculateProductionData(c *fiber.Ctx, projectFlo func (s closingKeuanganService) buildHPPSection(c *fiber.Ctx, projectFlock *entity.ProjectFlock, projectFlockKandangs []entity.ProjectFlockKandang, costs *CostData, production *ProductionData) dto.HPPSection { actualPopulation := production.TotalPopulationIn - production.TotalDepletion + if lastPopulation, ok := s.getLastPopulationFromRecordings(c, projectFlockKandangs); ok { + actualPopulation = lastPopulation + } totalWeightProduced := production.TotalWeightProduced totalEggWeightKg := production.TotalEggWeightKg @@ -529,6 +532,35 @@ func (s closingKeuanganService) buildProfitLossSection(projectFlock *entity.Proj return dto.ToProfitLossSection(plItems, plSummary) } +func (s closingKeuanganService) getLastPopulationFromRecordings(c *fiber.Ctx, projectFlockKandangs []entity.ProjectFlockKandang) (float64, bool) { + if s.RecordingRepo == nil || len(projectFlockKandangs) == 0 { + return 0, false + } + + total := 0.0 + recordedCount := 0 + for _, kandang := range projectFlockKandangs { + latest, err := s.RecordingRepo.GetLatestByProjectFlockKandangID(c.Context(), kandang.Id) + if err != nil { + s.Log.Errorf("Failed to fetch latest recording for project_flock_kandang_id=%d: %+v", kandang.Id, err) + return 0, false + } + if latest == nil || latest.TotalChickQty == nil { + continue + } + recordedCount++ + if *latest.TotalChickQty > 0 { + total += *latest.TotalChickQty + } + } + + if recordedCount != len(projectFlockKandangs) { + return 0, false + } + + return total, true +} + func containsFlag(flags []entity.Flag, name string) bool { for _, flag := range flags { if flag.Name == name { From 36ba4f34bbc79be6786fce5908559a98171bb7fd Mon Sep 17 00:00:00 2001 From: ragilap Date: Wed, 18 Feb 2026 11:49:25 +0700 Subject: [PATCH 2/5] [FEAT/BE] fixing fifo fallback recording,fixing backdate and fixing product category --- ...d_depletions_recording_total_used.down.sql | 9 + ...add_depletions_recording_total_used.up.sql | 17 + internal/entities/recording_depletion.go | 1 + .../controllers/closing.controller.go | 8 +- .../closings/dto/closingSapronak.dto.go | 58 +- .../repositories/closing.repository.go | 15 +- .../closings/services/sapronak.service.go | 82 +- .../chickins/services/chickin.service.go | 25 + .../projectflock_kandang.repository.go | 12 + .../services/projectflock.service.go | 57 +- .../controllers/recording.controller.go | 37 +- .../modules/production/recordings/module.go | 77 +- .../repositories/recording.repository.go | 342 +++++ .../recordings/services/recording.service.go | 1167 ++++++----------- .../services/recording_fifo.service.go | 582 ++++++-- .../validations/recording.validation.go | 9 + .../purchases/services/purchase.service.go | 9 + internal/utils/fifo/constants.go | 1 + internal/utils/recording/recording_helpers.go | 330 +++++ internal/utils/recording/util.recording.go | 87 ++ 20 files changed, 2036 insertions(+), 889 deletions(-) create mode 100644 internal/database/migrations/20260216125014_add_depletions_recording_total_used.down.sql create mode 100644 internal/database/migrations/20260216125014_add_depletions_recording_total_used.up.sql create mode 100644 internal/utils/recording/recording_helpers.go diff --git a/internal/database/migrations/20260216125014_add_depletions_recording_total_used.down.sql b/internal/database/migrations/20260216125014_add_depletions_recording_total_used.down.sql new file mode 100644 index 00000000..9677a9fe --- /dev/null +++ b/internal/database/migrations/20260216125014_add_depletions_recording_total_used.down.sql @@ -0,0 +1,9 @@ +BEGIN; + +ALTER TABLE recording_depletions + DROP CONSTRAINT IF EXISTS chk_recording_depletions_pending_zero; + +ALTER TABLE recording_depletions + DROP COLUMN IF EXISTS total_used_qty; + +COMMIT; diff --git a/internal/database/migrations/20260216125014_add_depletions_recording_total_used.up.sql b/internal/database/migrations/20260216125014_add_depletions_recording_total_used.up.sql new file mode 100644 index 00000000..59b2aa9a --- /dev/null +++ b/internal/database/migrations/20260216125014_add_depletions_recording_total_used.up.sql @@ -0,0 +1,17 @@ +BEGIN; + +ALTER TABLE recording_depletions + ADD COLUMN IF NOT EXISTS total_used_qty numeric(15, 3) NOT NULL DEFAULT 0; + +UPDATE recording_depletions +SET pending_qty = 0 +WHERE pending_qty IS NULL OR pending_qty <> 0; + +ALTER TABLE recording_depletions + ADD CONSTRAINT chk_recording_depletions_pending_zero + CHECK (pending_qty = 0); + +ALTER TABLE recording_depletions + DROP COLUMN IF EXISTS usage_qty; + +COMMIT; diff --git a/internal/entities/recording_depletion.go b/internal/entities/recording_depletion.go index 8e0c7afe..ae0b6746 100644 --- a/internal/entities/recording_depletion.go +++ b/internal/entities/recording_depletion.go @@ -6,6 +6,7 @@ type RecordingDepletion struct { ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` SourceProductWarehouseId *uint `gorm:"column:source_product_warehouse_id"` Qty float64 `gorm:"column:qty;not null"` + UsageQty float64 `gorm:"column:usage_qty"` PendingQty float64 `gorm:"column:pending_qty"` Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index b1b02886..76668160 100644 --- a/internal/modules/closings/controllers/closing.controller.go +++ b/internal/modules/closings/controllers/closing.controller.go @@ -347,12 +347,12 @@ func (u *ClosingController) GetSapronakByProject(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id") } - result, err := u.SapronakService.GetSapronakByProject(c, uint(projectID), flag) + result, productFlags, err := u.SapronakService.GetSapronakByProject(c, uint(projectID), flag) if err != nil { return err } - payload := dto.ToSapronakProjectAggregatedFromReports(result, flag) + payload := dto.ToSapronakProjectAggregatedFromReports(result, flag, productFlags) return c.Status(fiber.StatusOK). JSON(response.Success{ @@ -377,12 +377,12 @@ func (u *ClosingController) GetSapronakByKandang(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_kandang_id") } - result, err := u.SapronakService.GetSapronakByKandang(c, uint(projectID), uint(pfkID), flag) + result, productFlags, err := u.SapronakService.GetSapronakByKandang(c, uint(projectID), uint(pfkID), flag) if err != nil { return err } - payload := dto.ToSapronakProjectAggregatedFromReport(result, flag) + payload := dto.ToSapronakProjectAggregatedFromReport(result, flag, productFlags) return c.Status(fiber.StatusOK). JSON(response.Success{ diff --git a/internal/modules/closings/dto/closingSapronak.dto.go b/internal/modules/closings/dto/closingSapronak.dto.go index 30b4d945..d4cb0d0d 100644 --- a/internal/modules/closings/dto/closingSapronak.dto.go +++ b/internal/modules/closings/dto/closingSapronak.dto.go @@ -1,6 +1,7 @@ package dto import ( + "sort" "strings" "time" ) @@ -64,7 +65,7 @@ type SapronakCategoryRowDTO struct { QtyOut float64 `json:"qty_out"` QtyUsed float64 `json:"qty_used"` Description string `json:"description"` - ProductCategory string `json:"product_category"` + ProductCategory []string `json:"product_category"` UnitPrice float64 `json:"unit_price"` TotalAmount float64 `json:"total_amount"` Notes string `json:"notes"` @@ -127,7 +128,7 @@ type UomSummaryDTO struct { // === Mapper Functions for Aggregated Sapronak Response === -func ToSapronakProjectAggregatedFromReports(reports []SapronakReportDTO, flag string) SapronakProjectAggregatedDTO { +func ToSapronakProjectAggregatedFromReports(reports []SapronakReportDTO, flag string, productFlags map[uint][]string) SapronakProjectAggregatedDTO { result := SapronakProjectAggregatedDTO{} if len(reports) == 0 { @@ -135,10 +136,10 @@ func ToSapronakProjectAggregatedFromReports(reports []SapronakReportDTO, flag st } rep := reports[0] - return ToSapronakProjectAggregatedFromReport(&rep, flag) + return ToSapronakProjectAggregatedFromReport(&rep, flag, productFlags) } -func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag string) SapronakProjectAggregatedDTO { +func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag string, productFlags map[uint][]string) SapronakProjectAggregatedDTO { result := SapronakProjectAggregatedDTO{} if report == nil { @@ -175,6 +176,53 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin return t.Format("02-Jan-2006") } + flagOrder := map[string]int{ + "DOC": 0, + "PAKAN": 0, + "OVK": 0, + "PULLET": 0, + } + + buildFlagList := func(productID uint, fallback string) []string { + rawFlags := productFlags[productID] + if len(rawFlags) == 0 { + if fallback == "" { + return []string{} + } + return []string{fallback} + } + seen := make(map[string]struct{}, len(rawFlags)) + ordered := make([]string, 0, len(rawFlags)) + for _, f := range rawFlags { + flagName := strings.ToUpper(strings.TrimSpace(f)) + if flagName == "" { + continue + } + if _, ok := seen[flagName]; ok { + continue + } + seen[flagName] = struct{}{} + ordered = append(ordered, flagName) + } + sort.SliceStable(ordered, func(i, j int) bool { + li := ordered[i] + lj := ordered[j] + ri, iok := flagOrder[li] + rj, jok := flagOrder[lj] + if iok != jok { + if iok { + return true + } + return false + } + if iok && jok && ri != rj { + return ri < rj + } + return li < lj + }) + return ordered + } + for _, group := range report.Groups { flagKey := normalizeFlag(group.Flag) ptr := byFlag[flagKey] @@ -206,7 +254,7 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin Date: formatDate(item.Tanggal), ReferenceNumber: item.NoReferensi, Description: item.ProductName, - ProductCategory: item.ProductName, + ProductCategory: buildFlagList(item.ProductID, flagKey), UnitPrice: item.Harga, Notes: "-", } diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index 5fd6b7e9..ecd96b0a 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -991,8 +991,8 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C query := r.withCtx(ctx). Table("stock_allocations AS sa"). Select(` - pw.product_id AS product_id, - p.name AS product_name, + p_resolve.id AS product_id, + p_resolve.name AS product_name, f.name AS flag, COALESCE( pi.received_date, @@ -1013,10 +1013,9 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C ) AS reference, 0 AS qty_in, COALESCE(SUM(sa.qty), 0) AS qty_out, - COALESCE(pi.price, p.product_price, 0) AS price + COALESCE(pi.price, p_resolve.product_price, 0) AS price `). Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id"). - Joins("JOIN products p ON p.id = pw.product_id"). Joins("LEFT JOIN recording_stocks rs ON rs.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyRecordingStock.String()). Joins("LEFT JOIN recordings r ON r.id = rs.recording_id"). Joins("LEFT JOIN project_chickins pc_used ON pc_used.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyProjectChickin.String()). @@ -1026,9 +1025,11 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C Joins("LEFT JOIN stock_transfers st ON st.id = std.stock_transfer_id"). Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()). Joins("LEFT JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id"). + Joins("LEFT JOIN product_warehouses pw_ltt ON pw_ltt.id = ltt.product_warehouse_id"). Joins("LEFT JOIN adjustment_stocks ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyAdjustmentIn.String()). Joins("LEFT JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()). Joins("LEFT JOIN project_chickins pc ON pc.id = pfp.project_chickin_id"). + Joins("LEFT JOIN products p_resolve ON p_resolve.id = COALESCE(pi.product_id, pw_ltt.product_id, pw.product_id)"). Where("sa.status = ?", entity.StockAllocationStatusActive). Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()). Where("f.name IN ?", sapronakFlagsAll). @@ -1040,12 +1041,12 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C fifo.UsableKeyRecordingStock.String(), projectFlockKandangID, fifo.UsableKeyProjectChickin.String(), projectFlockKandangID, ) - query = r.joinSapronakProductFlag(query, "p"). + query = r.joinSapronakProductFlag(query, "p_resolve"). Group(` - pw.product_id, p.name, f.name, + p_resolve.id, p_resolve.name, f.name, pi.received_date, st.transfer_date, lt.transfer_date, ast.created_at, pc.chick_in_date, r.record_datetime, po.po_number, st.movement_number, lt.transfer_number, ast.id, pc.id, r.id, - pi.price, p.product_price + pi.price, p_resolve.product_price `) return scanAndGroupDetails(query) diff --git a/internal/modules/closings/services/sapronak.service.go b/internal/modules/closings/services/sapronak.service.go index 4dff148b..ba79db1d 100644 --- a/internal/modules/closings/services/sapronak.service.go +++ b/internal/modules/closings/services/sapronak.service.go @@ -18,8 +18,8 @@ import ( ) type SapronakService interface { - GetSapronakByProject(ctx *fiber.Ctx, projectFlockID uint, flag string) ([]dto.SapronakReportDTO, error) - GetSapronakByKandang(ctx *fiber.Ctx, projectFlockID uint, pfkID uint, flag string) (*dto.SapronakReportDTO, error) + GetSapronakByProject(ctx *fiber.Ctx, projectFlockID uint, flag string) ([]dto.SapronakReportDTO, map[uint][]string, error) + GetSapronakByKandang(ctx *fiber.Ctx, projectFlockID uint, pfkID uint, flag string) (*dto.SapronakReportDTO, map[uint][]string, error) } type sapronakService struct { @@ -42,9 +42,9 @@ func NewSapronakService( } } -func (s sapronakService) GetSapronakByProject(c *fiber.Ctx, projectFlockID uint, flag string) ([]dto.SapronakReportDTO, error) { +func (s sapronakService) GetSapronakByProject(c *fiber.Ctx, projectFlockID uint, flag string) ([]dto.SapronakReportDTO, map[uint][]string, error) { if projectFlockID == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_id is required") + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_id is required") } reports, err := s.computeSapronakReports(c.Context(), &validation.CountSapronakQuery{ ProjectFlockID: projectFlockID, @@ -52,19 +52,27 @@ func (s sapronakService) GetSapronakByProject(c *fiber.Ctx, projectFlockID uint, Flag: flag, }) if err != nil { - return nil, err + return nil, nil, err } if len(reports) <= 1 { - return reports, nil + flags, err := s.collectProductFlags(c.Context(), reports) + if err != nil { + return nil, nil, err + } + return reports, flags, nil } combined := s.combineSapronakReports(reports, projectFlockID) - return []dto.SapronakReportDTO{combined}, nil + flags, err := s.collectProductFlags(c.Context(), []dto.SapronakReportDTO{combined}) + if err != nil { + return nil, nil, err + } + return []dto.SapronakReportDTO{combined}, flags, nil } -func (s sapronakService) GetSapronakByKandang(c *fiber.Ctx, projectFlockID uint, pfkID uint, flag string) (*dto.SapronakReportDTO, error) { +func (s sapronakService) GetSapronakByKandang(c *fiber.Ctx, projectFlockID uint, pfkID uint, flag string) (*dto.SapronakReportDTO, map[uint][]string, error) { if projectFlockID == 0 || pfkID == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_id and project_flock_kandang_id are required") + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_id and project_flock_kandang_id are required") } results, err := s.computeSapronakReports(c.Context(), &validation.CountSapronakQuery{ @@ -74,16 +82,20 @@ func (s sapronakService) GetSapronakByKandang(c *fiber.Ctx, projectFlockID uint, Flag: flag, }) if err != nil { - return nil, err + return nil, nil, err } for _, res := range results { if res.ProjectFlockID == projectFlockID && res.ProjectFlockKandangID == pfkID { - return &res, nil + flags, err := s.collectProductFlags(c.Context(), []dto.SapronakReportDTO{res}) + if err != nil { + return nil, nil, err + } + return &res, flags, nil } } - return nil, fiber.NewError(fiber.StatusNotFound, "Sapronak for kandang not found") + return nil, nil, fiber.NewError(fiber.StatusNotFound, "Sapronak for kandang not found") } func (s sapronakService) computeSapronakReports(ctx context.Context, params *validation.CountSapronakQuery) ([]dto.SapronakReportDTO, error) { @@ -136,6 +148,52 @@ func (s sapronakService) computeSapronakReports(ctx context.Context, params *val return results, nil } +func (s sapronakService) collectProductFlags(ctx context.Context, reports []dto.SapronakReportDTO) (map[uint][]string, error) { + productIDs := make(map[uint]struct{}) + for _, report := range reports { + for _, group := range report.Groups { + for _, item := range group.Items { + if item.ProductID > 0 { + productIDs[item.ProductID] = struct{}{} + } + } + } + } + if len(productIDs) == 0 { + return map[uint][]string{}, nil + } + + ids := make([]uint, 0, len(productIDs)) + for id := range productIDs { + ids = append(ids, id) + } + + products, err := s.Repository.GetProductsWithFlagsByIDs(ctx, ids) + if err != nil { + return nil, err + } + + result := make(map[uint][]string, len(products)) + for _, product := range products { + if len(product.Flags) == 0 { + continue + } + flags := make([]string, 0, len(product.Flags)) + for _, flag := range product.Flags { + name := strings.TrimSpace(flag.Name) + if name == "" { + continue + } + flags = append(flags, strings.ToUpper(name)) + } + if len(flags) > 0 { + result[product.Id] = flags + } + } + + return result, nil +} + func (s sapronakService) loadProjectFlockKandangs(ctx context.Context, params *validation.CountSapronakQuery) ([]entity.ProjectFlockKandang, error) { db := s.ProjectFlockKandangRepo.DB().WithContext(ctx). Preload("ProjectFlock"). diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index a011c579..7d2e7a7f 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -36,6 +36,7 @@ type ChickinService interface { UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.ProjectChickin, error) DeleteOne(ctx *fiber.Ctx, id uint) error Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectChickin, error) + EnsureChickInExists(ctx context.Context, projectFlockKandangID uint) error } type chickinService struct { @@ -731,6 +732,30 @@ func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, return nil } +func (s chickinService) EnsureChickInExists(ctx context.Context, projectFlockKandangID uint) error { + if projectFlockKandangID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid") + } + + populations, err := s.ProjectflockPopulationRepo.GetByProjectFlockKandangID(ctx, projectFlockKandangID) + if err != nil { + s.Log.Errorf("Failed to check project flock population for project_flock_kandang_id=%d: %+v", projectFlockKandangID, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal memeriksa data chick in") + } + + if len(populations) == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Project flock belum memiliki chick in yang disetujui sehingga belum dapat membuat recording") + } + + for _, population := range populations { + if population.TotalQty > 0 { + return nil + } + } + + return fiber.NewError(fiber.StatusBadRequest, "Chick in project flock belum disetujui sehingga belum dapat membuat recording") +} + func (s *chickinService) adjustProductWarehouseQuantities(ctx context.Context, tx *gorm.DB, deltas map[uint]float64) error { if len(deltas) == 0 { return nil diff --git a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go index 002c2c58..4383ee4a 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go @@ -13,6 +13,7 @@ import ( type ProjectFlockKandangRepository interface { GetByID(ctx context.Context, id uint) (*entity.ProjectFlockKandang, error) + GetByIDLight(ctx context.Context, id uint) (*entity.ProjectFlockKandang, error) GetByProjectFlockAndKandang(ctx context.Context, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, error) GetActiveByKandangID(ctx context.Context, kandangID uint) (*entity.ProjectFlockKandang, error) UpdateClosedAt(ctx context.Context, id uint, t *time.Time) error @@ -342,6 +343,17 @@ func (r *projectFlockKandangRepositoryImpl) GetByID(ctx context.Context, id uint return record, nil } +// GetByIDLight loads only the minimal relations needed for recording flows. +func (r *projectFlockKandangRepositoryImpl) GetByIDLight(ctx context.Context, id uint) (*entity.ProjectFlockKandang, error) { + record := new(entity.ProjectFlockKandang) + if err := r.db.WithContext(ctx). + Preload("ProjectFlock"). + First(record, id).Error; err != nil { + return nil, err + } + return record, nil +} + func (r *projectFlockKandangRepositoryImpl) GetByProjectFlockAndKandang(ctx context.Context, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, error) { record := new(entity.ProjectFlockKandang) if err := r.db.WithContext(ctx). diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index 224f43bf..4caf7540 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -48,6 +48,7 @@ type ProjectflockService interface { GetProjectPeriods(ctx *fiber.Ctx, projectIDs []uint) (map[uint]int, error) Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlock, error) Resubmit(ctx *fiber.Ctx, req *validation.Resubmit, id uint) (*entity.ProjectFlock, error) + EnsureProjectFlockApproved(ctx context.Context, projectFlockID uint) error } type projectflockService struct { @@ -112,6 +113,32 @@ func (s projectflockService) approvalQueryModifier() func(*gorm.DB) *gorm.DB { } } +func (s projectflockService) EnsureProjectFlockApproved(ctx context.Context, projectFlockID uint) error { + if projectFlockID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid") + } + + approvalSvc := s.ApprovalSvc + if approvalSvc == nil { + approvalSvc = commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(s.Repository.DB())) + } + + latest, err := approvalSvc.LatestByTarget(ctx, s.approvalWorkflow, projectFlockID, nil) + if err != nil { + s.Log.Errorf("Failed to check project flock %d approval status: %+v", projectFlockID, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal memeriksa status project flock") + } + + if latest == nil { + return fiber.NewError(fiber.StatusBadRequest, "Project flock masih dalam status pengajuan sehingga belum dapat membuat recording") + } + if latest.StepNumber != uint16(utils.ProjectFlockStepAktif) || latest.Action == nil || *latest.Action != entity.ApprovalActionApproved { + return fiber.NewError(fiber.StatusBadRequest, "Project flock masih dalam status pengajuan sehingga belum dapat membuat recording") + } + + return nil +} + func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, map[uint]*flockDTO.FlockRelationDTO, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, nil, err @@ -459,6 +486,15 @@ func (s projectflockService) GetProjectFlockKandangPopulation(ctx *fiber.Ctx, pr return 0, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required") } + total, err := s.PopulationRepo.GetAvailableQtyByProjectFlockKandangID(ctx.Context(), projectFlockKandangID) + if err != nil { + s.Log.Errorf("Failed to fetch project flock kandang population %d: %+v", projectFlockKandangID, err) + return 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang population") + } + if total > 0 { + return total, nil + } + if s.RecordingRepo != nil { latest, err := s.RecordingRepo.GetLatestByProjectFlockKandangID(ctx.Context(), projectFlockKandangID) if err != nil { @@ -470,12 +506,6 @@ func (s projectflockService) GetProjectFlockKandangPopulation(ctx *fiber.Ctx, pr } } - total, err := s.PopulationRepo.GetAvailableQtyByProjectFlockKandangID(ctx.Context(), projectFlockKandangID) - if err != nil { - s.Log.Errorf("Failed to fetch project flock kandang population %d: %+v", projectFlockKandangID, err) - return 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang population") - } - return total, nil } @@ -550,21 +580,22 @@ func (s projectflockService) GetProjectFlockKandangByParams(ctx *fiber.Ctx, idSt } func (s projectflockService) GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID uint) (float64, error) { + if s.PopulationRepo == nil { + return 0, fiber.NewError(fiber.StatusInternalServerError, "Project flock population repository is not configured") + } - wh, err := s.WarehouseRepo.GetByKandangID(ctx.Context(), kandangID) + pfk, err := s.PivotRepo.GetActiveByKandangID(ctx.Context(), kandangID) if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found") + } return 0, err } - productWarehouses, err := s.ProductWarehouseRepo.GetByCategoryCodeAndWarehouseID(ctx.Context(), "DOC", wh.Id) + total, err := s.PopulationRepo.GetAvailableQtyByProjectFlockKandangID(ctx.Context(), pfk.Id) if err != nil { return 0, err } - - total := 0.0 - for _, pw := range productWarehouses { - total += pw.Quantity - } return total, nil } diff --git a/internal/modules/production/recordings/controllers/recording.controller.go b/internal/modules/production/recordings/controllers/recording.controller.go index 51e3100d..7801b16f 100644 --- a/internal/modules/production/recordings/controllers/recording.controller.go +++ b/internal/modules/production/recordings/controllers/recording.controller.go @@ -27,9 +27,14 @@ func NewRecordingController(recordingService service.RecordingService) *Recordin func (u *RecordingController) GetAll(c *fiber.Ctx) error { projectFlockID := c.QueryInt("project_flock_kandang_id", 0) + page := c.QueryInt("page", 1) + limit := c.QueryInt("limit", 10) + offset := (page - 1) * limit + query := &validation.Query{ - Page: c.QueryInt("page", 1), - Limit: c.QueryInt("limit", 10), + Page: page, + Limit: limit, + Offset: offset, Search: c.Query("search"), } if projectFlockID > 0 { @@ -79,25 +84,27 @@ func (u *RecordingController) GetOne(c *fiber.Ctx) error { } func (u *RecordingController) GetNextDay(c *fiber.Ctx) error { - projectFlockID := c.QueryInt("project_flock_kandang_id", 0) - if projectFlockID <= 0 { + req := new(validation.GetRecordingNextDay) + + if err := c.QueryParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid query params") + } + if req.ProjectFlockKandangId == 0 { return fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required") } - - recordTime := time.Now().UTC() - if recordDate := strings.TrimSpace(c.Query("record_date")); recordDate != "" { - parsed, err := time.Parse("2006-01-02", recordDate) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, "record_date must be in YYYY-MM-DD format") - } - recordTime = parsed.UTC() + if req.RecordTime == nil || strings.TrimSpace(*req.RecordTime) == "" { + return fiber.NewError(fiber.StatusBadRequest, "record_date is required") } - - nextDay, err := u.RecordingService.GetNextDay(c, uint(projectFlockID), recordTime) + recordTime, err := time.Parse("2006-01-02", strings.TrimSpace(*req.RecordTime)) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "record_time must be in YYYY-MM-DD format") + } + req.RecordTimeValue = &recordTime + nextDay, err := u.RecordingService.GetNextDay(c, req) if err != nil { return err } - + projectFlockID := req.ProjectFlockKandangId return c.Status(fiber.StatusOK). JSON(response.Success{ Code: fiber.StatusOK, diff --git a/internal/modules/production/recordings/module.go b/internal/modules/production/recordings/module.go index 23c97788..6dd74a1b 100644 --- a/internal/modules/production/recordings/module.go +++ b/internal/modules/production/recordings/module.go @@ -11,9 +11,17 @@ import ( commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + rFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/repositories" + rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" + rNonstock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/repositories" rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" sProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/services" + rProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" + rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + rChickin "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" + sChickin "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/services" rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + sProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" sRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services" rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" @@ -28,9 +36,18 @@ type RecordingModule struct{} func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { recordingRepo := rRecording.NewRecordingRepository(db) + projectFlockRepo := rProjectFlock.NewProjectflockRepository(db) projectFlockKandangRepo := rProjectFlock.NewProjectFlockKandangRepository(db) projectFlockPopulationRepo := rProjectFlock.NewProjectFlockPopulationRepository(db) + projectBudgetRepo := rProjectFlock.NewProjectBudgetRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + flockRepo := rFlock.NewFlockRepository(db) + kandangRepo := rKandang.NewKandangRepository(db) + warehouseRepo := rWarehouse.NewWarehouseRepository(db) + nonstockRepo := rNonstock.NewNonstockRepository(db) + productRepo := rProduct.NewProductRepository(db) + chickinRepo := rChickin.NewChickinRepository(db) + chickinDetailRepo := rChickin.NewChickinDetailRepository(db) stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) stockLogRepo := rStockLogs.NewStockLogRepository(db) productionStandardRepo := rProductionStandard.NewProductionStandardRepository(db) @@ -53,14 +70,30 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate ProductWarehouseID: "product_warehouse_id", TotalQuantity: "total_qty", TotalUsedQuantity: "total_used", - CreatedAt: "created_at", + CreatedAt: "(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_eggs.recording_id)", }, - OrderBy: []string{"created_at ASC", "id ASC"}, + OrderBy: []string{"(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_eggs.recording_id) ASC", "id ASC"}, }); err != nil { if !strings.Contains(strings.ToLower(err.Error()), "already registered") { panic(fmt.Sprintf("failed to register recording egg stockable workflow: %v", err)) } } + if err := fifoService.RegisterStockable(fifo.StockableConfig{ + Key: fifo.StockableKeyRecordingDepletion, + Table: "recording_depletions", + Columns: fifo.StockableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + TotalQuantity: "qty", + TotalUsedQuantity: "total_used_qty", + CreatedAt: "(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_depletions.recording_id)", + }, + OrderBy: []string{"(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_depletions.recording_id) ASC", "id ASC"}, + }); err != nil { + if !strings.Contains(strings.ToLower(err.Error()), "already registered") { + panic(fmt.Sprintf("failed to register recording depletion stockable workflow: %v", err)) + } + } if err := fifoService.RegisterUsable(fifo.UsableConfig{ Key: fifo.UsableKeyRecordingStock, Table: "recording_stocks", @@ -69,7 +102,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate ProductWarehouseID: "product_warehouse_id", UsageQuantity: "usage_qty", PendingQuantity: "pending_qty", - CreatedAt: "id", + CreatedAt: "(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_stocks.recording_id)", }, }); err != nil { if !strings.Contains(strings.ToLower(err.Error()), "already registered") { @@ -82,9 +115,9 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate Columns: fifo.UsableColumns{ ID: "id", ProductWarehouseID: "source_product_warehouse_id", - UsageQuantity: "qty", + UsageQuantity: "usage_qty", PendingQuantity: "pending_qty", - CreatedAt: "id", + CreatedAt: "(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_depletions.recording_id)", }, ExcludedStockables: []fifo.StockableKey{ fifo.StockableKeyTransferToLayingIn, @@ -104,9 +137,41 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowRecording, utils.RecordingApprovalSteps); err != nil { panic(fmt.Sprintf("failed to register recording approval workflow: %v", err)) } + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowProjectFlock, utils.ProjectFlockApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register project flock approval workflow: %v", err)) + } userRepo := rUser.NewUserRepository(db) + projectFlockService := sProjectFlock.NewProjectflockService( + projectFlockRepo, + flockRepo, + kandangRepo, + projectFlockKandangRepo, + warehouseRepo, + productWarehouseRepo, + projectBudgetRepo, + nonstockRepo, + projectFlockPopulationRepo, + recordingRepo, + approvalService, + validate, + ) + + chickinService := sChickin.NewChickinService( + chickinRepo, + kandangRepo, + warehouseRepo, + productWarehouseRepo, + productRepo, + projectFlockRepo, + projectFlockKandangRepo, + projectFlockPopulationRepo, + chickinDetailRepo, + validate, + fifoService, + ) + recordingService := sRecording.NewRecordingService( recordingRepo, projectFlockKandangRepo, @@ -117,6 +182,8 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate fifoService, stockLogRepo, productionStandardService, + projectFlockService, + chickinService, validate, ) userService := sUser.NewUserService(userRepo, validate) diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index 3ed66f87..a7978c16 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -17,8 +17,13 @@ type RecordingRepository interface { repository.BaseRepository[entity.Recording] WithRelations(db *gorm.DB) *gorm.DB + WithRelationsList(db *gorm.DB) *gorm.DB + ApplyListFilters(db *gorm.DB, search string, projectFlockKandangId uint) *gorm.DB + ApplyListCountFilters(db *gorm.DB, search string, projectFlockKandangId uint) *gorm.DB ApplySearchFilters(db *gorm.DB, rawSearch string) *gorm.DB + GetAllWithFilters(ctx context.Context, offset, limit int, search string, projectFlockKandangId uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.Recording, int64, error) GetLatestByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (*entity.Recording, error) + ListByProjectFlockKandangID(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, from *time.Time) ([]entity.Recording, error) GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error) CreateStocks(tx *gorm.DB, stocks []entity.RecordingStock) error @@ -40,9 +45,13 @@ type RecordingRepository interface { SumRecordingDepletions(tx *gorm.DB, recordingID uint) (float64, error) GetCumulativeDepletionByProjectFlockKandangUntil(tx *gorm.DB, projectFlockKandangId uint, recordTime time.Time) (float64, error) + GetCumulativeDepletionByRecordingIDs(tx *gorm.DB, recordingIDs []uint) (map[uint]float64, error) GetUniformityMeanBwByWeek(tx *gorm.DB, projectFlockKandangId uint, week int) (float64, bool, error) FindPreviousRecording(tx *gorm.DB, projectFlockKandangId uint, currentDay int) (*entity.Recording, error) + GetPreviousTotalChickByRecordingIDs(tx *gorm.DB, recordingIDs []uint) (map[uint]*float64, error) GetTotalChick(tx *gorm.DB, projectFlockKandangId uint) (int64, error) + GetRemainingPopulationByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint) (float64, error) + GetTotalChickByProjectFlockKandangIDs(tx *gorm.DB, projectFlockKandangIds []uint) (map[uint]int64, error) GetTotalChickinByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint) (float64, error) GetFeedUsageInGrams(tx *gorm.DB, recordingID uint) (float64, error) GetEggSummaryByRecording(tx *gorm.DB, recordingID uint) (totalQty float64, totalWeightGrams float64, err error) @@ -55,6 +64,10 @@ type RecordingRepository interface { GetLatestAvgWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (avgWeight float64, err error) GetTotalEggProductionWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeightKg float64, err error) GetAverageTargetMetricsByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint, includeTargets bool) (RecordingTargetAverages, error) + ResyncProjectFlockPopulationUsage(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error + ValidateFeedProductWarehouses(ctx context.Context, ids []uint) (uint, error) + ValidateEggProductWarehouses(ctx context.Context, ids []uint) (uint, error) + ValidateDepletionProductWarehouses(ctx context.Context, ids []uint) (uint, error) } type RecordingRepositoryImpl struct { @@ -113,6 +126,64 @@ func (r *RecordingRepositoryImpl) WithRelations(db *gorm.DB) *gorm.DB { Preload("Eggs.ProductWarehouse.Warehouse.Location") } +func (r *RecordingRepositoryImpl) WithRelationsList(db *gorm.DB) *gorm.DB { + return db. + Preload("CreatedUser"). + Preload("ProjectFlockKandang"). + Preload("ProjectFlockKandang.Kandang"). + Preload("ProjectFlockKandang.Kandang.Location"). + Preload("ProjectFlockKandang.ProjectFlock"). + Preload("ProjectFlockKandang.ProjectFlock.ProductionStandard") +} + +func (r *RecordingRepositoryImpl) ApplyListFilters(db *gorm.DB, search string, projectFlockKandangId uint) *gorm.DB { + db = r.WithRelationsList(db) + db = db. + Joins("JOIN project_flock_kandangs pfk ON pfk.id = recordings.project_flock_kandangs_id"). + Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id") + if projectFlockKandangId != 0 { + db = db.Where("recordings.project_flock_kandangs_id = ?", projectFlockKandangId) + } + db = r.ApplySearchFilters(db, search) + return db.Order("recordings.record_datetime DESC").Order("recordings.created_at DESC") +} + +func (r *RecordingRepositoryImpl) ApplyListCountFilters(db *gorm.DB, search string, projectFlockKandangId uint) *gorm.DB { + db = db. + Joins("JOIN project_flock_kandangs pfk ON pfk.id = recordings.project_flock_kandangs_id"). + Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id") + if projectFlockKandangId != 0 { + db = db.Where("recordings.project_flock_kandangs_id = ?", projectFlockKandangId) + } + db = r.ApplySearchFilters(db, search) + return db +} + +func (r *RecordingRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, search string, projectFlockKandangId uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.Recording, int64, error) { + var ( + records []entity.Recording + total int64 + ) + + countQ := r.ApplyListCountFilters(r.DB().WithContext(ctx).Model(&entity.Recording{}), search, projectFlockKandangId) + if modifier != nil { + countQ = modifier(countQ) + } + if err := countQ.Count(&total).Error; err != nil { + return nil, 0, err + } + + listQ := r.ApplyListFilters(r.DB().WithContext(ctx).Model(&entity.Recording{}), search, projectFlockKandangId) + if modifier != nil { + listQ = modifier(listQ) + } + if err := listQ.Offset(offset).Limit(limit).Find(&records).Error; err != nil { + return nil, 0, err + } + + return records, total, nil +} + func (r *RecordingRepositoryImpl) ApplySearchFilters(db *gorm.DB, rawSearch string) *gorm.DB { normalized := strings.ToLower(strings.TrimSpace(rawSearch)) if normalized == "" { @@ -170,6 +241,27 @@ func (r *RecordingRepositoryImpl) GetLatestByProjectFlockKandangID(ctx context.C return &record, nil } +func (r *RecordingRepositoryImpl) ListByProjectFlockKandangID(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, from *time.Time) ([]entity.Recording, error) { + if projectFlockKandangId == 0 { + return nil, errors.New("project_flock_kandang_id is required") + } + + db := tx.WithContext(ctx). + Model(&entity.Recording{}). + Where("project_flock_kandangs_id = ?", projectFlockKandangId). + Where("deleted_at IS NULL") + + if from != nil { + db = db.Where("record_datetime >= ?", *from) + } + + var records []entity.Recording + if err := db.Order("record_datetime ASC").Order("created_at ASC").Find(&records).Error; err != nil { + return nil, err + } + return records, nil +} + func (r *RecordingRepositoryImpl) GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error) { var days []int if err := tx.Model(&entity.Recording{}). @@ -332,6 +424,41 @@ func (r *RecordingRepositoryImpl) GetCumulativeDepletionByProjectFlockKandangUnt return total, err } +func (r *RecordingRepositoryImpl) GetCumulativeDepletionByRecordingIDs(tx *gorm.DB, recordingIDs []uint) (map[uint]float64, error) { + result := make(map[uint]float64) + if len(recordingIDs) == 0 { + return result, nil + } + + type row struct { + RecordingID uint `gorm:"column:recording_id"` + Total float64 `gorm:"column:total_qty"` + } + var rows []row + + err := tx. + Table("recordings r"). + Select("r.id AS recording_id, COALESCE(SUM(rd.qty), 0) AS total_qty"). + Joins(` + LEFT JOIN recordings r2 + ON r2.project_flock_kandangs_id = r.project_flock_kandangs_id + AND r2.record_datetime <= r.record_datetime + AND r2.deleted_at IS NULL`). + Joins("LEFT JOIN recording_depletions rd ON rd.recording_id = r2.id"). + Where("r.id IN ?", recordingIDs). + Where("r.deleted_at IS NULL"). + Group("r.id"). + Scan(&rows).Error + if err != nil { + return nil, err + } + + for _, row := range rows { + result[row.RecordingID] = row.Total + } + return result, nil +} + func (r *RecordingRepositoryImpl) GetUniformityMeanBwByWeek(tx *gorm.DB, projectFlockKandangId uint, week int) (float64, bool, error) { if projectFlockKandangId == 0 || week <= 0 { return 0, false, nil @@ -381,6 +508,46 @@ func (r *RecordingRepositoryImpl) FindPreviousRecording(tx *gorm.DB, projectFloc return &prev, nil } +func (r *RecordingRepositoryImpl) GetPreviousTotalChickByRecordingIDs(tx *gorm.DB, recordingIDs []uint) (map[uint]*float64, error) { + result := make(map[uint]*float64) + if len(recordingIDs) == 0 { + return result, nil + } + + type row struct { + RecordingID uint `gorm:"column:recording_id"` + PrevTotalChickQty *float64 `gorm:"column:prev_total_chick_qty"` + } + var rows []row + + err := tx. + Table("recordings r"). + Select(` + r.id AS recording_id, + ( + SELECT r2.total_chick_qty + FROM recordings r2 + WHERE r2.project_flock_kandangs_id = r.project_flock_kandangs_id + AND r2.day IS NOT NULL + AND r.day IS NOT NULL + AND r2.day < r.day + AND r2.deleted_at IS NULL + ORDER BY r2.day DESC + LIMIT 1 + ) AS prev_total_chick_qty`). + Where("r.id IN ?", recordingIDs). + Where("r.deleted_at IS NULL"). + Scan(&rows).Error + if err != nil { + return nil, err + } + + for _, row := range rows { + result[row.RecordingID] = row.PrevTotalChickQty + } + return result, nil +} + func (r *RecordingRepositoryImpl) GetTotalChick(tx *gorm.DB, projectFlockKandangId uint) (int64, error) { var total float64 err := tx. @@ -400,6 +567,57 @@ func (r *RecordingRepositoryImpl) GetTotalChick(tx *gorm.DB, projectFlockKandang return int64(math.Round(total)), nil } +func (r *RecordingRepositoryImpl) GetRemainingPopulationByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint) (float64, error) { + var total float64 + err := tx. + Table("project_flock_populations"). + Select("COALESCE(SUM(project_flock_populations.total_qty - project_flock_populations.total_used_qty), 0) AS total_qty"). + Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id"). + Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangId). + Scan(&total).Error + if err != nil { + return 0, err + } + if total < 0 { + total = 0 + } + return total, nil +} + +func (r *RecordingRepositoryImpl) GetTotalChickByProjectFlockKandangIDs(tx *gorm.DB, projectFlockKandangIds []uint) (map[uint]int64, error) { + result := make(map[uint]int64) + if len(projectFlockKandangIds) == 0 { + return result, nil + } + + type row struct { + ProjectFlockKandangId uint `gorm:"column:project_flock_kandang_id"` + Total float64 `gorm:"column:total_qty"` + } + var rows []row + + err := tx. + Table("project_flock_populations pfp"). + Select("project_chickins.project_flock_kandang_id, COALESCE(SUM(pfp.total_qty - pfp.total_used_qty), 0) AS total_qty"). + Joins("JOIN project_chickins ON project_chickins.id = pfp.project_chickin_id"). + Where("project_chickins.project_flock_kandang_id IN ?", projectFlockKandangIds). + Group("project_chickins.project_flock_kandang_id"). + Scan(&rows).Error + if err != nil { + return nil, err + } + + for _, row := range rows { + total := math.Round(row.Total) + if total < 0 { + total = 0 + } + result[row.ProjectFlockKandangId] = int64(total) + } + + return result, nil +} + func (r *RecordingRepositoryImpl) GetTotalChickinByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint) (float64, error) { if projectFlockKandangId == 0 { return 0, nil @@ -585,6 +803,130 @@ func (r *RecordingRepositoryImpl) GetAverageTargetMetricsByProjectFlockKandangID return result, nil } +func (r *RecordingRepositoryImpl) ResyncProjectFlockPopulationUsage(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error { + if projectFlockKandangID == 0 { + return nil + } + + idsSubquery := ` + SELECT pfp.id + FROM project_flock_populations pfp + JOIN project_chickins pc ON pc.id = pfp.project_chickin_id + WHERE pc.project_flock_kandang_id = ? + ` + + updateWithAlloc := ` + UPDATE project_flock_populations p + SET total_used_qty = COALESCE(a.used, 0) + FROM ( + SELECT stockable_id, SUM(qty) AS used + FROM stock_allocations + WHERE stockable_type = 'PROJECT_FLOCK_POPULATION' + AND status = 'ACTIVE' + GROUP BY stockable_id + ) a + WHERE p.id = a.stockable_id + AND p.id IN (` + idsSubquery + `) + ` + + resetMissing := ` + UPDATE project_flock_populations p + SET total_used_qty = 0 + WHERE p.id IN (` + idsSubquery + `) + AND NOT EXISTS ( + SELECT 1 + FROM stock_allocations sa + WHERE sa.stockable_type = 'PROJECT_FLOCK_POPULATION' + AND sa.status = 'ACTIVE' + AND sa.stockable_id = p.id + ) + ` + + db := r.DB().WithContext(ctx) + if tx != nil { + db = tx.WithContext(ctx) + } + + if err := db.Exec(updateWithAlloc, projectFlockKandangID).Error; err != nil { + return err + } + if err := db.Exec(resetMissing, projectFlockKandangID).Error; err != nil { + return err + } + return nil +} + +func (r *RecordingRepositoryImpl) ValidateFeedProductWarehouses(ctx context.Context, ids []uint) (uint, error) { + if len(ids) == 0 { + return 0, nil + } + var invalidIDs []uint + if err := r.DB().WithContext(ctx). + Table("product_warehouses pw"). + Where("pw.id IN ?", ids). + Where(`NOT EXISTS ( + SELECT 1 FROM flags f + WHERE f.flagable_type = 'products' + AND f.flagable_id = pw.product_id + AND UPPER(f.name) = 'PAKAN' + )`). + Pluck("pw.id", &invalidIDs).Error; err != nil { + return 0, err + } + if len(invalidIDs) > 0 { + return invalidIDs[0], nil + } + return 0, nil +} + +func (r *RecordingRepositoryImpl) ValidateEggProductWarehouses(ctx context.Context, ids []uint) (uint, error) { + if len(ids) == 0 { + return 0, nil + } + eggFlags := []string{"TELUR-UTUH", "TELUR-PECAH", "TELUR-PUTIH", "TELUR-RETAK", "TELUR"} + var invalidIDs []uint + if err := r.DB().WithContext(ctx). + Table("product_warehouses pw"). + Where("pw.id IN ?", ids). + Where(`NOT EXISTS ( + SELECT 1 FROM flags f + WHERE f.flagable_type = 'products' + AND f.flagable_id = pw.product_id + AND UPPER(f.name) IN ? + )`, eggFlags). + Pluck("pw.id", &invalidIDs).Error; err != nil { + return 0, err + } + if len(invalidIDs) > 0 { + return invalidIDs[0], nil + } + return 0, nil +} + +func (r *RecordingRepositoryImpl) ValidateDepletionProductWarehouses(ctx context.Context, ids []uint) (uint, error) { + if len(ids) == 0 { + return 0, nil + } + ayamFlags := []string{"AYAM-AFKIR", "AYAM-CULLING", "AYAM-MATI"} + var invalidIDs []uint + if err := r.DB().WithContext(ctx). + Table("product_warehouses pw"). + Where("pw.id IN ?", ids). + Where(`NOT EXISTS ( + SELECT 1 FROM flags f + WHERE f.flagable_type = 'products' + AND f.flagable_id = pw.product_id + AND UPPER(f.name) IN ? + )`, ayamFlags). + Pluck("pw.id", &invalidIDs).Error; err != nil { + return 0, err + } + if len(invalidIDs) > 0 { + return invalidIDs[0], nil + } + return 0, nil +} + func nextRecordingDay(days []int) int { if len(days) == 0 { return 1 diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index f789144c..6ef692b9 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -13,9 +13,10 @@ import ( entity "gitlab.com/mbugroup/lti-api.git/internal/entities" m "gitlab.com/mbugroup/lti-api.git/internal/middleware" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" - rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" sProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/services" + sChickin "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/services" rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + sProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" @@ -32,7 +33,7 @@ import ( type RecordingService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Recording, int64, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.Recording, error) - GetNextDay(ctx *fiber.Ctx, projectFlockKandangId uint, recordTime time.Time) (int, error) + GetNextDay(ctx *fiber.Ctx, req *validation.GetRecordingNextDay) (int, error) CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Recording, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Recording, error) DeleteOne(ctx *fiber.Ctx, id uint) error @@ -49,6 +50,8 @@ type recordingService struct { ApprovalRepo commonRepo.ApprovalRepository ApprovalSvc commonSvc.ApprovalService ProductionStandardSvc sProductionStandard.ProductionStandardService + ProjectFlockSvc sProjectFlock.ProjectflockService + ChickinSvc sChickin.ChickinService FifoSvc commonSvc.FifoService StockLogRepo rStockLogs.StockLogRepository } @@ -63,6 +66,8 @@ func NewRecordingService( fifoSvc commonSvc.FifoService, stockLogRepo rStockLogs.StockLogRepository, productionStandardSvc sProductionStandard.ProductionStandardService, + projectFlockSvc sProjectFlock.ProjectflockService, + chickinSvc sChickin.ChickinService, validate *validator.Validate, ) RecordingService { return &recordingService{ @@ -75,6 +80,8 @@ func NewRecordingService( ApprovalRepo: approvalRepo, ApprovalSvc: approvalSvc, ProductionStandardSvc: productionStandardSvc, + ProjectFlockSvc: projectFlockSvc, + ChickinSvc: chickinSvc, FifoSvc: fifoSvc, StockLogRepo: stockLogRepo, } @@ -92,28 +99,17 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti } } - limit := params.Limit - if limit == 0 { - limit = 10 - } - page := params.Page - if page == 0 { - page = 1 - } - offset := (page - 1) * limit - - recordings, total, err := s.Repository.GetAll(c.Context(), offset, limit, func(db *gorm.DB) *gorm.DB { - db = s.Repository.WithRelations(db) - db = db. - Joins("JOIN project_flock_kandangs pfk ON pfk.id = recordings.project_flock_kandangs_id"). - Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id") - db, scopeErr = m.ApplyLocationScope(c, db, "pf.location_id") - if params.ProjectFlockKandangId != 0 { - db = db.Where("project_flock_kandangs_id = ?", params.ProjectFlockKandangId) - } - db = s.Repository.ApplySearchFilters(db, params.Search) - return db.Order("recordings.record_datetime DESC").Order("recordings.created_at DESC") - }) + recordings, total, err := s.Repository.GetAllWithFilters( + c.Context(), + params.Offset, + params.Limit, + params.Search, + params.ProjectFlockKandangId, + func(db *gorm.DB) *gorm.DB { + db, scopeErr = m.ApplyLocationScope(c, db, "pf.location_id") + return db + }, + ) if scopeErr != nil { return nil, 0, scopeErr @@ -122,18 +118,63 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti s.Log.Errorf("Failed to get recordings: %+v", err) return nil, 0, err } - if err := s.attachLatestApprovals(c.Context(), recordings); err != nil { + if err := recordingutil.AttachLatestApprovals(c.Context(), recordings, s.ApprovalSvc, s.Log); err != nil { return nil, 0, err } - if err := s.attachProductionStandards(c.Context(), recordings); err != nil { + recordingPtrs := make([]*entity.Recording, 0, len(recordings)) + for i := range recordings { + recordingPtrs = append(recordingPtrs, &recordings[i]) + } + if err := recordingutil.AttachProductionStandards(c.Context(), s.Repository.DB(), true, s.Log, recordingPtrs...); err != nil { return nil, 0, err } - if err := s.attachCumulativeDepletions(c.Context(), recordings); err != nil { + dbCtx := s.Repository.DB().WithContext(c.Context()) + recordingIDs := make([]uint, 0, len(recordings)) + projectFlockIDs := make(map[uint]struct{}) + for i := range recordings { + if recordings[i].Id != 0 { + recordingIDs = append(recordingIDs, recordings[i].Id) + } + if recordings[i].ProjectFlockKandangId != 0 { + projectFlockIDs[recordings[i].ProjectFlockKandangId] = struct{}{} + } + } + + cumulativeMap, err := s.Repository.GetCumulativeDepletionByRecordingIDs(dbCtx, recordingIDs) + if err != nil { return nil, 0, err } - if err := s.attachDepletionRates(c.Context(), recordings); err != nil { + prevChickMap, err := s.Repository.GetPreviousTotalChickByRecordingIDs(dbCtx, recordingIDs) + if err != nil { return nil, 0, err } + pfkIDs := make([]uint, 0, len(projectFlockIDs)) + for id := range projectFlockIDs { + pfkIDs = append(pfkIDs, id) + } + totalChickMap, err := s.Repository.GetTotalChickByProjectFlockKandangIDs(dbCtx, pfkIDs) + if err != nil { + return nil, 0, err + } + + for i := range recordings { + if recordings[i].ProjectFlockKandangId != 0 && !recordings[i].RecordDatetime.IsZero() { + total := cumulativeMap[recordings[i].Id] + recordings[i].TotalDepletionCumQty = &total + } + + current := 0.0 + if recordings[i].TotalDepletionQty != nil { + current = *recordings[i].TotalDepletionQty + } + var prev *entity.Recording + if prevTotal, ok := prevChickMap[recordings[i].Id]; ok && prevTotal != nil { + prev = &entity.Recording{TotalChickQty: prevTotal} + } + totalChick := totalChickMap[recordings[i].ProjectFlockKandangId] + rate := recordingutil.ComputeDepletionRate(prev, current, totalChick) + recordings[i].DepletionRate = &rate + } return recordings, total, nil } @@ -152,31 +193,57 @@ func (s recordingService) GetOne(c *fiber.Ctx, id uint) (*entity.Recording, erro s.Log.Errorf("Failed get recording by id: %+v", err) return nil, err } - if err := s.attachLatestApproval(c.Context(), recording); err != nil { + if err := recordingutil.AttachLatestApproval(c.Context(), recording, s.ApprovalSvc, s.Log); err != nil { return nil, err } - if err := s.attachProductionStandard(c.Context(), recording); err != nil { + if err := recordingutil.AttachProductionStandards(c.Context(), s.Repository.DB(), false, s.Log, recording); err != nil { return nil, err } - if err := s.attachCumulativeDepletion(c.Context(), recording); err != nil { - return nil, err - } - if err := s.attachDepletionRate(c.Context(), recording); err != nil { - return nil, err + if recording.ProjectFlockKandangId != 0 { + dbCtx := s.Repository.DB().WithContext(c.Context()) + ids := []uint{recording.Id} + + cumulativeMap, err := s.Repository.GetCumulativeDepletionByRecordingIDs(dbCtx, ids) + if err != nil { + return nil, err + } + prevChickMap, err := s.Repository.GetPreviousTotalChickByRecordingIDs(dbCtx, ids) + if err != nil { + return nil, err + } + totalChickMap, err := s.Repository.GetTotalChickByProjectFlockKandangIDs(dbCtx, []uint{recording.ProjectFlockKandangId}) + if err != nil { + return nil, err + } + + if !recording.RecordDatetime.IsZero() { + total := cumulativeMap[recording.Id] + recording.TotalDepletionCumQty = &total + } + + current := 0.0 + if recording.TotalDepletionQty != nil { + current = *recording.TotalDepletionQty + } + var prev *entity.Recording + if prevTotal, ok := prevChickMap[recording.Id]; ok && prevTotal != nil { + prev = &entity.Recording{TotalChickQty: prevTotal} + } + totalChick := totalChickMap[recording.ProjectFlockKandangId] + rate := recordingutil.ComputeDepletionRate(prev, current, totalChick) + recording.DepletionRate = &rate } return recording, nil } -func (s recordingService) GetNextDay(c *fiber.Ctx, projectFlockKandangId uint, recordTime time.Time) (int, error) { - if projectFlockKandangId == 0 { - return 0, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required") - } - if err := m.EnsureProjectFlockKandangAccess(c, s.Repository.DB(), 0, projectFlockKandangId); err != nil { +func (s recordingService) GetNextDay(c *fiber.Ctx, req *validation.GetRecordingNextDay) (int, error) { + if err := s.Validate.Struct(req); err != nil { return 0, err } - - if recordTime.IsZero() { - recordTime = time.Now().UTC() + projectFlockKandangId := req.ProjectFlockKandangId + recordTime := req.RecordTimeValue.UTC() + if err := m.EnsureProjectFlockKandangAccess(c, s.Repository.DB(), 0, projectFlockKandangId); err != nil { + return 0, err } day, err := s.computeRecordingDay(c.Context(), projectFlockKandangId, recordTime) if err != nil { @@ -188,6 +255,9 @@ func (s recordingService) GetNextDay(c *fiber.Ctx, projectFlockKandangId uint, r } func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Recording, error) { + if err := s.requireFIFO(); err != nil { + return nil, err + } if err := s.Validate.Struct(req); err != nil { return nil, err } @@ -205,7 +275,7 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent recordTime = parsed.UTC() } - pfk, err := s.ProjectFlockKandangRepo.GetByID(ctx, req.ProjectFlockKandangId) + pfk, err := s.ProjectFlockKandangRepo.GetByIDLight(ctx, req.ProjectFlockKandangId) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang not found") @@ -217,10 +287,10 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent category := strings.ToUpper(pfk.ProjectFlock.Category) isLaying := category == strings.ToUpper(string(utils.ProjectFlockCategoryLaying)) - if err := s.ensureProjectFlockApproved(ctx, pfk.ProjectFlockId); err != nil { + if err := s.ProjectFlockSvc.EnsureProjectFlockApproved(ctx, pfk.ProjectFlockId); err != nil { return nil, err } - if err := s.ensureChickInExists(ctx, pfk.Id); err != nil { + if err := s.ChickinSvc.EnsureChickInExists(ctx, pfk.Id); err != nil { return nil, err } if s.ProductionStandardSvc != nil { @@ -241,6 +311,15 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent if err := s.ensureProductWarehousesExist(c, req.Stocks, req.Depletions, req.Eggs); err != nil { return nil, err } + if err := s.ensureFeedProductWarehouses(ctx, req.Stocks); err != nil { + return nil, err + } + if err := s.ensureDepletionProductWarehouses(ctx, req.Depletions); err != nil { + return nil, err + } + if err := s.ensureEggProductWarehouses(ctx, req.Eggs); err != nil { + return nil, err + } actorID, err := m.ActorIDFromContext(c) if err != nil { return nil, err @@ -287,21 +366,31 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent } mappedStocks := recordingutil.MapStocks(createdRecording.Id, req.Stocks) - stockDesired := resetStockQuantitiesForFIFO(mappedStocks, s.FifoSvc != nil) + stockDesired := resetStockQuantitiesForFIFO(mappedStocks) if err := s.Repository.CreateStocks(tx, mappedStocks); err != nil { s.Log.Errorf("Failed to persist stocks: %+v", err) return err } - applyStockDesiredQuantities(mappedStocks, stockDesired, s.FifoSvc != nil) - note := fmt.Sprintf("Recording-Create#%d", createdRecording.Id) + for i := range mappedStocks { + if i >= len(stockDesired) { + break + } + usage := stockDesired[i].Usage + pending := stockDesired[i].Pending + mappedStocks[i].UsageQty = &usage + mappedStocks[i].PendingQty = &pending + } + note := recordingutil.RecordingNote("Create", createdRecording.Id) if err := s.consumeRecordingStocks(ctx, tx, mappedStocks, note, actorID); err != nil { return err } mappedDepletions := recordingutil.MapDepletions(createdRecording.Id, req.Depletions) - depletionDesired := resetDepletionQuantitiesForFIFO(mappedDepletions, s.FifoSvc != nil) - if s.FifoSvc != nil && len(mappedDepletions) > 0 { + if len(mappedDepletions) > 0 { + if err := s.ensureDepletionWithinPopulation(ctx, tx, req.ProjectFlockKandangId, sumDepletionQty(mappedDepletions), 0); err != nil { + return err + } sourceWarehouseID, err := s.resolvePopulationWarehouseID(ctx, req.ProjectFlockKandangId) if err != nil { return err @@ -310,16 +399,17 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent mappedDepletions[i].SourceProductWarehouseId = &sourceWarehouseID } } + depletionDesired := resetDepletionQuantitiesForFIFO(mappedDepletions) if err := s.Repository.CreateDepletions(tx, mappedDepletions); err != nil { s.Log.Errorf("Failed to persist depletions: %+v", err) return err } - if s.FifoSvc != nil { - applyDepletionDesiredQuantities(mappedDepletions, depletionDesired, true) - note := fmt.Sprintf("Recording-Create#%d", createdRecording.Id) - if err := s.consumeRecordingDepletions(ctx, tx, mappedDepletions, note, actorID); err != nil { - return err - } + applyDepletionDesiredQuantities(mappedDepletions, depletionDesired) + if err := s.replenishRecordingDepletions(ctx, tx, mappedDepletions); err != nil { + return err + } + if err := s.consumeRecordingDepletions(ctx, tx, mappedDepletions, note, actorID); err != nil { + return err } mappedEggs := recordingutil.MapEggs(createdRecording.Id, createdRecording.CreatedBy, req.Eggs) @@ -327,22 +417,7 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent s.Log.Errorf("Failed to persist eggs: %+v", err) return err } - if s.FifoSvc != nil { - note := fmt.Sprintf("Recording-Create#%d", createdRecording.Id) - if err := s.replenishRecordingEggs(ctx, tx, mappedEggs, note, actorID); err != nil { - return err - } - } - - var warehouseDeltas map[uint]float64 - if s.FifoSvc != nil { - // FIFO replenish already adjusts egg warehouse quantities. - warehouseDeltas = buildWarehouseDeltas(nil, mappedDepletions, nil, nil) - } else { - warehouseDeltas = buildWarehouseDeltas(nil, mappedDepletions, nil, mappedEggs) - } - if err := s.adjustProductWarehouseQuantities(ctx, tx, warehouseDeltas); err != nil { - s.Log.Errorf("Failed to adjust product warehouses: %+v", err) + if err := s.replenishRecordingEggs(ctx, tx, mappedEggs, note, actorID); err != nil { return err } @@ -350,6 +425,10 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent s.Log.Errorf("Failed to compute recording metrics: %+v", err) return err } + if err := s.recalculateFrom(ctx, tx, createdRecording.ProjectFlockKandangId, createdRecording.RecordDatetime); err != nil { + s.Log.Errorf("Failed to recalculate recordings after create: %+v", err) + return err + } action := entity.ApprovalActionCreated if err := s.createRecordingApproval(ctx, tx, createdRecording.Id, utils.RecordingStepPengajuan, action, createdRecording.CreatedBy, nil); err != nil { @@ -367,6 +446,9 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent } func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Recording, error) { + if err := s.requireFIFO(); err != nil { + return nil, err + } if err := s.Validate.Struct(req); err != nil { return nil, err } @@ -391,6 +473,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin recording, err := repoTx.GetByID(ctx, id, func(db *gorm.DB) *gorm.DB { return s.Repository.WithRelations(db) }) + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, "Recording not found") @@ -398,45 +481,159 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin s.Log.Errorf("Failed to find recording: %+v", err) return err } - recordingEntity = recording + recordingEntity = recording hasStockChanges := req.Stocks != nil hasDepletionChanges := req.Depletions != nil hasEggChanges := req.Eggs != nil var existingStocks []entity.RecordingStock + var existingDepletions []entity.RecordingDepletion + var existingEggs []entity.RecordingEgg + + note := recordingutil.RecordingNote("Edit", recordingEntity.Id) + if hasStockChanges { existingStocks, err = s.Repository.ListStocks(tx, recordingEntity.Id) if err != nil { s.Log.Errorf("Failed to list existing stocks: %+v", err) return err } - if stocksMatch(existingStocks, req.Stocks) { + existingUsage := recordingutil.StockUsageByWarehouse(existingStocks) + incomingUsage := recordingutil.StockUsageByWarehouseReq(req.Stocks) + match := recordingutil.FloatMapsEqual(existingUsage, incomingUsage) + if match { hasStockChanges = false + } else { + if err := s.ensureProductWarehousesExist(c, req.Stocks, nil, nil); err != nil { + return err + } + if err := s.ensureFeedProductWarehouses(ctx, req.Stocks); err != nil { + return err + } + if err := s.syncRecordingStocks(ctx, tx, recordingEntity.Id, existingStocks, req.Stocks, note, actorID); err != nil { + return err + } } } - var existingDepletions []entity.RecordingDepletion if hasDepletionChanges { existingDepletions, err = s.Repository.ListDepletions(tx, recordingEntity.Id) if err != nil { s.Log.Errorf("Failed to list existing depletions: %+v", err) return err } - if depletionsMatch(existingDepletions, req.Depletions) { + existingTotals := recordingutil.TotalsByWarehouse(existingDepletions, func(dep entity.RecordingDepletion) (uint, float64) { + return dep.ProductWarehouseId, dep.Qty + }) + incomingTotals := recordingutil.TotalsByWarehouse(req.Depletions, func(dep validation.Depletion) (uint, float64) { + return dep.ProductWarehouseId, dep.Qty + }) + match := recordingutil.FloatMapsEqual(existingTotals, incomingTotals) + if match { hasDepletionChanges = false + } else { + if err := s.ensureProductWarehousesExist(c, nil, req.Depletions, nil); err != nil { + return err + } + if err := s.ensureDepletionProductWarehouses(ctx, req.Depletions); err != nil { + return err + } + if err := s.releaseRecordingDepletions(ctx, tx, existingDepletions, note, actorID); err != nil { + return err + } + if err := s.reduceRecordingDepletions(ctx, tx, existingDepletions); err != nil { + return err + } + + if err := s.Repository.DeleteDepletions(tx, recordingEntity.Id); err != nil { + s.Log.Errorf("Failed to clear depletions: %+v", err) + return err + } + + mappedDepletions := recordingutil.MapDepletions(recordingEntity.Id, req.Depletions) + if len(mappedDepletions) > 0 { + if err := s.ensureDepletionWithinPopulation(ctx, tx, recordingEntity.ProjectFlockKandangId, sumDepletionQty(mappedDepletions), sumDepletionQty(existingDepletions)); err != nil { + return err + } + sourceWarehouseID, err := s.resolvePopulationWarehouseID(ctx, recordingEntity.ProjectFlockKandangId) + if err != nil { + return err + } + for i := range mappedDepletions { + mappedDepletions[i].SourceProductWarehouseId = &sourceWarehouseID + } + } + depletionDesired := resetDepletionQuantitiesForFIFO(mappedDepletions) + if err := s.Repository.CreateDepletions(tx, mappedDepletions); err != nil { + s.Log.Errorf("Failed to update depletions: %+v", err) + return err + } + applyDepletionDesiredQuantities(mappedDepletions, depletionDesired) + if err := s.replenishRecordingDepletions(ctx, tx, mappedDepletions); err != nil { + return err + } + if err := s.consumeRecordingDepletions(ctx, tx, mappedDepletions, note, actorID); err != nil { + return err + } } } - var existingEggs []entity.RecordingEgg if hasEggChanges { existingEggs, err = s.Repository.ListEggs(tx, recordingEntity.Id) if err != nil { s.Log.Errorf("Failed to list existing eggs: %+v", err) return err } - if eggsMatch(existingEggs, req.Eggs) { + existingTotals := recordingutil.EggTotalsByWarehouse(existingEggs, func(egg entity.RecordingEgg) (uint, int, *float64) { + return egg.ProductWarehouseId, egg.Qty, egg.Weight + }) + incomingTotals := recordingutil.EggTotalsByWarehouse(req.Eggs, func(egg validation.Egg) (uint, int, *float64) { + return egg.ProductWarehouseId, egg.Qty, egg.Weight + }) + match := recordingutil.EggTotalsEqual(existingTotals, incomingTotals) + if match { hasEggChanges = false + } else { + category := "" + if recordingEntity.ProjectFlockKandang != nil && recordingEntity.ProjectFlockKandang.ProjectFlock.Id != 0 { + category = strings.ToUpper(recordingEntity.ProjectFlockKandang.ProjectFlock.Category) + } + isLaying := category == strings.ToUpper(string(utils.ProjectFlockCategoryLaying)) + if !isLaying && len(req.Eggs) > 0 { + return fiber.NewError(fiber.StatusBadRequest, "Egg details permitted only for laying project flocks") + } + + if err := s.ensureProductWarehousesExist(c, nil, nil, req.Eggs); err != nil { + return err + } + if err := s.ensureEggProductWarehouses(ctx, req.Eggs); err != nil { + return err + } + if err := ensureRecordingEggsUnused(existingEggs); err != nil { + return err + } + if err := s.logRecordingEggUsage(ctx, tx, existingEggs, note, actorID); err != nil { + return err + } + if err := s.reduceRecordingEggs(ctx, tx, existingEggs); err != nil { + return err + } + + if err := s.Repository.DeleteEggs(tx, recordingEntity.Id); err != nil { + s.Log.Errorf("Failed to clear eggs: %+v", err) + return err + } + + mappedEggs := recordingutil.MapEggs(recordingEntity.Id, recordingEntity.CreatedBy, req.Eggs) + if err := s.Repository.CreateEggs(tx, mappedEggs); err != nil { + s.Log.Errorf("Failed to update eggs: %+v", err) + return err + } + + if err := s.replenishRecordingEggs(ctx, tx, mappedEggs, note, actorID); err != nil { + return err + } } } @@ -444,116 +641,9 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin return nil } - var category string - if recordingEntity.ProjectFlockKandang != nil && recordingEntity.ProjectFlockKandang.ProjectFlock.Id != 0 { - category = strings.ToUpper(recordingEntity.ProjectFlockKandang.ProjectFlock.Category) - } - isLaying := category == strings.ToUpper(string(utils.ProjectFlockCategoryLaying)) - if hasEggChanges { - if !isLaying && len(req.Eggs) > 0 { - return fiber.NewError(fiber.StatusBadRequest, "Egg details permitted only for laying project flocks") - } - } - - if hasStockChanges { - if err := s.ensureProductWarehousesExist(c, req.Stocks, nil, nil); err != nil { - return err - } - } - - if hasDepletionChanges || hasEggChanges { - if err := s.ensureProductWarehousesExist(c, nil, req.Depletions, req.Eggs); err != nil { - return err - } - } - - if hasStockChanges { - note := fmt.Sprintf("Recording-Edit#%d", recordingEntity.Id) - if err := s.syncRecordingStocks(ctx, tx, recordingEntity.Id, existingStocks, req.Stocks, note, actorID); err != nil { - return err - } - } - - if hasDepletionChanges { - if s.FifoSvc != nil { - note := fmt.Sprintf("Recording-Edit#%d", recordingEntity.Id) - if err := s.releaseRecordingDepletions(ctx, tx, existingDepletions, note, actorID); err != nil { - return err - } - } - - if err := s.Repository.DeleteDepletions(tx, recordingEntity.Id); err != nil { - s.Log.Errorf("Failed to clear depletions: %+v", err) - return err - } - - mappedDepletions := recordingutil.MapDepletions(recordingEntity.Id, req.Depletions) - depletionDesired := resetDepletionQuantitiesForFIFO(mappedDepletions, s.FifoSvc != nil) - if s.FifoSvc != nil && len(mappedDepletions) > 0 { - sourceWarehouseID, err := s.resolvePopulationWarehouseID(ctx, recordingEntity.ProjectFlockKandangId) - if err != nil { - return err - } - for i := range mappedDepletions { - mappedDepletions[i].SourceProductWarehouseId = &sourceWarehouseID - } - } - if err := s.Repository.CreateDepletions(tx, mappedDepletions); err != nil { - s.Log.Errorf("Failed to update depletions: %+v", err) - return err - } - - if s.FifoSvc != nil { - applyDepletionDesiredQuantities(mappedDepletions, depletionDesired, true) - note := fmt.Sprintf("Recording-Edit#%d", recordingEntity.Id) - if err := s.consumeRecordingDepletions(ctx, tx, mappedDepletions, note, actorID); err != nil { - return err - } - } - - if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(existingDepletions, mappedDepletions, nil, nil)); err != nil { - s.Log.Errorf("Failed to adjust product warehouses for depletions: %+v", err) - return err - } - } - - if hasEggChanges { - if s.FifoSvc != nil { - if err := ensureRecordingEggsUnused(existingEggs); err != nil { - return err - } - note := fmt.Sprintf("Recording-Edit#%d", recordingEntity.Id) - if err := s.logRecordingEggUsage(ctx, tx, existingEggs, note, actorID); err != nil { - return err - } - if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, nil, existingEggs, nil)); err != nil { - s.Log.Errorf("Failed to adjust product warehouses for eggs: %+v", err) - return err - } - } - - if err := s.Repository.DeleteEggs(tx, recordingEntity.Id); err != nil { - s.Log.Errorf("Failed to clear eggs: %+v", err) - return err - } - - mappedEggs := recordingutil.MapEggs(recordingEntity.Id, recordingEntity.CreatedBy, req.Eggs) - if err := s.Repository.CreateEggs(tx, mappedEggs); err != nil { - s.Log.Errorf("Failed to update eggs: %+v", err) - return err - } - - if s.FifoSvc != nil { - note := fmt.Sprintf("Recording-Edit#%d", recordingEntity.Id) - if err := s.replenishRecordingEggs(ctx, tx, mappedEggs, note, actorID); err != nil { - return err - } - } else { - if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, nil, existingEggs, mappedEggs)); err != nil { - s.Log.Errorf("Failed to adjust product warehouses for eggs: %+v", err) - return err - } - } + if err := s.Repository.ResyncProjectFlockPopulationUsage(ctx, tx, recordingEntity.ProjectFlockKandangId); err != nil { + s.Log.Errorf("Failed to resync project flock population usage: %+v", err) + return err } if hasStockChanges || hasDepletionChanges || hasEggChanges { @@ -561,6 +651,10 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin s.Log.Errorf("Failed to recompute recording metrics: %+v", err) return err } + if err := s.recalculateFrom(ctx, tx, recordingEntity.ProjectFlockKandangId, recordingEntity.RecordDatetime); err != nil { + s.Log.Errorf("Failed to recalculate recordings after update: %+v", err) + return err + } } action := entity.ApprovalActionUpdated @@ -623,10 +717,10 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin if updatedRecording == nil { return s.GetOne(c, id) } - if err := s.attachLatestApproval(ctx, updatedRecording); err != nil { + if err := recordingutil.AttachLatestApproval(ctx, updatedRecording, s.ApprovalSvc, s.Log); err != nil { return nil, err } - if err := s.attachProductionStandard(ctx, updatedRecording); err != nil { + if err := recordingutil.AttachProductionStandards(ctx, s.Repository.DB(), false, s.Log, updatedRecording); err != nil { return nil, err } return updatedRecording, nil @@ -652,8 +746,13 @@ func (s recordingService) Approval(c *fiber.Ctx, req *validation.Approve) ([]ent default: return nil, fiber.NewError(fiber.StatusBadRequest, "action must be APPROVED or REJECTED") } + if action == entity.ApprovalActionRejected { + if err := s.requireFIFO(); err != nil { + return nil, err + } + } - ids := uniqueUintSlice(req.ApprovableIds) + ids := utils.UniqueUintSlice(req.ApprovableIds) if len(ids) == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "approvable_ids must contain at least one id") } @@ -694,10 +793,28 @@ func (s recordingService) Approval(c *fiber.Ctx, req *validation.Approve) ([]ent } if action == entity.ApprovalActionRejected { - note := fmt.Sprintf("Recording-Reject#%d", id) + note := recordingutil.RecordingNote("Reject", id) if err := s.rollbackRecordingInventory(ctx, tx, id, note, actorID); err != nil { return err } + recording, err := repoTx.GetByID(ctx, id, func(db *gorm.DB) *gorm.DB { + return s.Repository.WithRelations(db) + }) + if err != nil { + return err + } + if err := s.computeAndUpdateMetrics(ctx, tx, recording); err != nil { + s.Log.Errorf("Failed to recompute recording metrics after reject %d: %+v", id, err) + return err + } + if err := s.Repository.ResyncProjectFlockPopulationUsage(ctx, tx, recording.ProjectFlockKandangId); err != nil { + s.Log.Errorf("Failed to resync project flock population usage after reject %d: %+v", id, err) + return err + } + if err := s.recalculateFrom(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime); err != nil { + s.Log.Errorf("Failed to recalculate recordings after reject %d: %+v", id, err) + return err + } } } @@ -725,6 +842,9 @@ func (s recordingService) Approval(c *fiber.Ctx, req *validation.Approve) ([]ent } func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { + if err := s.requireFIFO(); err != nil { + return err + } if err := m.EnsureRecordingAccess(c, s.Repository.DB(), id); err != nil { return err } @@ -733,9 +853,18 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { if err != nil { return err } - note := fmt.Sprintf("Recording-Delete#%d", id) + note := recordingutil.RecordingNote("Delete", id) return s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + recording, err := s.Repository.WithTx(tx).GetByID(ctx, id, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Recording not found") + } + s.Log.Errorf("Failed to find recording: %+v", err) + return err + } + if err := s.rollbackRecordingInventory(ctx, tx, id, note, actorID); err != nil { return err } @@ -748,57 +877,20 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { return err } + if err := s.Repository.ResyncProjectFlockPopulationUsage(ctx, tx, recording.ProjectFlockKandangId); err != nil { + s.Log.Errorf("Failed to resync project flock population usage: %+v", err) + return err + } + + if err := s.recalculateFrom(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime); err != nil { + s.Log.Errorf("Failed to recalculate recordings after delete: %+v", err) + return err + } + return nil }) } -func (s *recordingService) rollbackRecordingInventory(ctx context.Context, tx *gorm.DB, recordingID uint, note string, actorID uint) error { - if recordingID == 0 || tx == nil { - return nil - } - - oldDepletions, err := s.Repository.ListDepletions(tx, recordingID) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - s.Log.Errorf("Failed to list depletions: %+v", err) - return err - } - - oldEggs, err := s.Repository.ListEggs(tx, recordingID) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - s.Log.Errorf("Failed to list eggs: %+v", err) - return err - } - if s.FifoSvc != nil { - if err := ensureRecordingEggsUnused(oldEggs); err != nil { - return err - } - if err := s.releaseRecordingDepletions(ctx, tx, oldDepletions, note, actorID); err != nil { - return err - } - } - - oldStocks, err := s.Repository.ListStocks(tx, recordingID) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - s.Log.Errorf("Failed to list stocks: %+v", err) - return err - } - if err := s.releaseRecordingStocks(ctx, tx, oldStocks, note, actorID); err != nil { - return err - } - - if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(oldDepletions, nil, oldEggs, nil)); err != nil { - return err - } - - if err := s.logRecordingEggRollback(ctx, tx, oldEggs, note, actorID); err != nil { - return err - } - - return nil -} - -// === Persistence Helpers === - func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []validation.Stock, depletions []validation.Depletion, eggs []validation.Egg) error { idSet := make(map[uint]struct{}) @@ -821,41 +913,72 @@ func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []v if len(idSet) == 0 { return nil } - + ids := make([]uint, 0, len(idSet)) for id := range idSet { - ok, err := s.ProductWarehouseRepo.ExistsByID(c.Context(), id) - if err != nil { - s.Log.Errorf("Failed to validate product warehouse %d: %+v", id, err) - return err - } - if !ok { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Product warehouse %d not found", id)) - } + ids = append(ids, id) + } + if err := recordingutil.EnsureProductWarehousesExist(c.Context(), s.ProductWarehouseRepo, ids); err != nil { + s.Log.Errorf("Failed to validate product warehouses: %+v", err) + return fiber.NewError(fiber.StatusBadRequest, err.Error()) } - return nil } -func (s *recordingService) resolvePopulationWarehouseID(ctx context.Context, projectFlockKandangID uint) (uint, error) { - if projectFlockKandangID == 0 { - return 0, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid") +func (s *recordingService) ensureEggProductWarehouses(ctx context.Context, eggs []validation.Egg) error { + if len(eggs) == 0 { + return nil } - populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(ctx, projectFlockKandangID) - if err != nil { - s.Log.Errorf("Failed to fetch populations for project_flock_kandang_id=%d: %+v", projectFlockKandangID, err) - return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data populasi") + ids := recordingutil.CollectWarehouseIDs(eggs, func(e validation.Egg) uint { return e.ProductWarehouseId }) + if err := s.validateWarehouseIDs(ctx, ids, func(ctx context.Context, ids []uint) error { + return recordingutil.EnsureEggProductWarehouses(ctx, s.Repository, ids) + }, "egg"); err != nil { + s.Log.Errorf("Failed to validate egg product warehouses: %+v", err) + return err } - for _, pop := range populations { - if pop.ProductWarehouseId > 0 && pop.TotalQty > 0 { - return pop.ProductWarehouseId, nil - } + return nil +} + +func (s *recordingService) ensureDepletionProductWarehouses(ctx context.Context, depletions []validation.Depletion) error { + if len(depletions) == 0 { + return nil } - for _, pop := range populations { - if pop.ProductWarehouseId > 0 { - return pop.ProductWarehouseId, nil - } + ids := recordingutil.CollectWarehouseIDs(depletions, func(d validation.Depletion) uint { return d.ProductWarehouseId }) + if err := s.validateWarehouseIDs(ctx, ids, func(ctx context.Context, ids []uint) error { + return recordingutil.EnsureDepletionProductWarehouses(ctx, s.Repository, ids) + }, "depletion"); err != nil { + s.Log.Errorf("Failed to validate depletion product warehouses: %+v", err) + return err } - return 0, fiber.NewError(fiber.StatusBadRequest, "Source product warehouse populasi tidak ditemukan") + return nil +} + +func (s *recordingService) ensureFeedProductWarehouses(ctx context.Context, stocks []validation.Stock) error { + if len(stocks) == 0 { + return nil + } + ids := recordingutil.CollectWarehouseIDs(stocks, func(st validation.Stock) uint { return st.ProductWarehouseId }) + if err := s.validateWarehouseIDs(ctx, ids, func(ctx context.Context, ids []uint) error { + return recordingutil.EnsureFeedProductWarehouses(ctx, s.Repository, ids) + }, "feed"); err != nil { + s.Log.Errorf("Failed to validate feed product warehouses: %+v", err) + return err + } + return nil +} + +func (s *recordingService) validateWarehouseIDs( + ctx context.Context, + ids []uint, + validator func(ctx context.Context, ids []uint) error, + label string, +) error { + if len(ids) == 0 || validator == nil { + return nil + } + if err := validator(ctx, ids); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + return nil } func (s *recordingService) computeRecordingDay(ctx context.Context, projectFlockKandangID uint, recordTime time.Time) (int, error) { @@ -892,152 +1015,6 @@ func (s *recordingService) computeRecordingDay(ctx context.Context, projectFlock return diff + 1, nil } -func buildWarehouseDeltas( - oldDepletions, newDepletions []entity.RecordingDepletion, - oldEggs, newEggs []entity.RecordingEgg, -) map[uint]float64 { - deltas := make(map[uint]float64) - for _, item := range oldDepletions { - accumulateWarehouseDelta(deltas, item.ProductWarehouseId, -item.Qty) - } - for _, item := range newDepletions { - accumulateWarehouseDelta(deltas, item.ProductWarehouseId, item.Qty) - } - for _, item := range oldEggs { - accumulateWarehouseDelta(deltas, item.ProductWarehouseId, -float64(item.Qty)) - } - for _, item := range newEggs { - accumulateWarehouseDelta(deltas, item.ProductWarehouseId, float64(item.Qty)) - } - return deltas -} - -func accumulateWarehouseDelta(deltas map[uint]float64, id uint, value float64) { - if id == 0 || value == 0 { - return - } - deltas[id] += value -} - -func (s *recordingService) adjustProductWarehouseQuantities(ctx context.Context, tx *gorm.DB, deltas map[uint]float64) error { - if len(deltas) == 0 { - return nil - } - return s.ProductWarehouseRepo.AdjustQuantities(ctx, deltas, func(*gorm.DB) *gorm.DB { return tx }) -} - -type eggTotals struct { - Qty int - Weight float64 -} - -func ensureRecordingEggsUnused(eggs []entity.RecordingEgg) error { - for _, egg := range eggs { - if egg.TotalUsed > 0 { - return fiber.NewError(fiber.StatusBadRequest, "Recording egg sudah digunakan sehingga tidak dapat diubah") - } - } - return nil -} - -func stocksMatch(existing []entity.RecordingStock, incoming []validation.Stock) bool { - - existingUsage := make(map[uint]float64) - for _, stock := range existing { - var usage float64 - if stock.UsageQty != nil { - usage = *stock.UsageQty - } - existingUsage[stock.ProductWarehouseId] += usage - } - - incomingUsage := make(map[uint]float64) - for _, item := range incoming { - incomingUsage[item.ProductWarehouseId] += item.Qty - } - - return floatMapsMatch(existingUsage, incomingUsage) -} - -func depletionsMatch(existing []entity.RecordingDepletion, incoming []validation.Depletion) bool { - existingTotals := make(map[uint]float64) - for _, dep := range existing { - existingTotals[dep.ProductWarehouseId] += dep.Qty - } - - incomingTotals := make(map[uint]float64) - for _, dep := range incoming { - incomingTotals[dep.ProductWarehouseId] += dep.Qty - } - - return floatMapsMatch(existingTotals, incomingTotals) -} - -func eggsMatch(existing []entity.RecordingEgg, incoming []validation.Egg) bool { - existingTotals := make(map[uint]eggTotals) - for _, egg := range existing { - weight := 0.0 - if egg.Weight != nil { - weight = *egg.Weight - } - current := existingTotals[egg.ProductWarehouseId] - current.Qty += egg.Qty - current.Weight += weight - existingTotals[egg.ProductWarehouseId] = current - } - - incomingTotals := make(map[uint]eggTotals) - for _, egg := range incoming { - weight := 0.0 - if egg.Weight != nil { - weight = *egg.Weight - } - current := incomingTotals[egg.ProductWarehouseId] - current.Qty += egg.Qty - current.Weight += weight - incomingTotals[egg.ProductWarehouseId] = current - } - - if len(existingTotals) != len(incomingTotals) { - return false - } - - for key, existingTotal := range existingTotals { - incomingTotal, ok := incomingTotals[key] - if !ok { - return false - } - if existingTotal.Qty != incomingTotal.Qty { - return false - } - if !floatNearlyEqual(existingTotal.Weight, incomingTotal.Weight) { - return false - } - } - - return true -} - -func floatMapsMatch(a, b map[uint]float64) bool { - if len(a) != len(b) { - return false - } - for key, value := range a { - other, ok := b[key] - if !ok { - return false - } - if !floatNearlyEqual(value, other) { - return false - } - } - return true -} - -func floatNearlyEqual(a, b float64) bool { - return math.Abs(a-b) <= 0.000001 -} - func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm.DB, recording *entity.Recording) error { day := 0 if recording.Day != nil { @@ -1099,39 +1076,30 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm recording.TotalDepletionCumQty = &cumDepletionQty var remainingChick float64 - if totalChick > 0 { - totalChickFloat := float64(totalChick) - if s.FifoSvc != nil { - // totalChick already represents available qty (total_qty - total_used_qty). - remainingChick = totalChickFloat - updates["total_chick_qty"] = remainingChick - recording.TotalChickQty = &remainingChick - - baseChick := initialChickin - if baseChick <= 0 { - baseChick = totalChickFloat + cumDepletionQty - } - cumRate := 0.0 - if baseChick > 0 { - cumRate = (cumDepletionQty / baseChick) * 100 - } - updates["cum_depletion_rate"] = cumRate - recording.CumDepletionRate = &cumRate - } else { - remainingChick = totalChickFloat - cumDepletionQty - if remainingChick < 0 { - remainingChick = 0 - } - updates["total_chick_qty"] = remainingChick - recording.TotalChickQty = &remainingChick - - cumRate := 0.0 - if totalChickFloat > 0 { - cumRate = (cumDepletionQty / totalChickFloat) * 100 - } - updates["cum_depletion_rate"] = cumRate - recording.CumDepletionRate = &cumRate + totalChickFloat := float64(totalChick) + if totalChick > 0 || initialChickin > 0 { + baseChick := initialChickin + if baseChick <= 0 { + baseChick = totalChickFloat + cumDepletionQty } + // Use remaining population at this record date (chickin - cumulative depletion). + if baseChick > 0 { + remainingChick = baseChick - cumDepletionQty + } else { + remainingChick = totalChickFloat + } + if remainingChick < 0 { + remainingChick = 0 + } + updates["total_chick_qty"] = remainingChick + recording.TotalChickQty = &remainingChick + + cumRate := 0.0 + if baseChick > 0 { + cumRate = (cumDepletionQty / baseChick) * 100 + } + updates["cum_depletion_rate"] = cumRate + recording.CumDepletionRate = &cumRate } else { updates["total_chick_qty"] = gorm.Expr("NULL") updates["cum_depletion_rate"] = gorm.Expr("NULL") @@ -1139,7 +1107,7 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm recording.CumDepletionRate = nil } - depletionRate := computeDepletionRate(prevRecording, currentDepletion, totalChick) + depletionRate := recordingutil.ComputeDepletionRate(prevRecording, currentDepletion, int64(remainingChick)) recording.DepletionRate = &depletionRate var feedIntake float64 @@ -1196,7 +1164,7 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm var fcrValue float64 isGrowing := false if s.ProjectFlockKandangRepo != nil { - if pfk, err := s.ProjectFlockKandangRepo.GetByID(ctx, recording.ProjectFlockKandangId); err == nil { + if pfk, err := s.ProjectFlockKandangRepo.GetByIDLight(ctx, recording.ProjectFlockKandangId); err == nil { if strings.EqualFold(pfk.ProjectFlock.Category, string(utils.ProjectFlockCategoryGrowing)) { isGrowing = true } @@ -1259,81 +1227,6 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm return nil } -func computeDepletionRate(prevRecording *entity.Recording, currentDepletion float64, totalChick int64) float64 { - base := 0.0 - if prevRecording != nil && prevRecording.TotalChickQty != nil && *prevRecording.TotalChickQty > 0 { - base = *prevRecording.TotalChickQty - } else if totalChick > 0 { - // totalChick is already remaining after today's depletion; add back current to approximate previous population. - base = float64(totalChick) + currentDepletion - } - if base <= 0 { - return 0 - } - return (currentDepletion / base) * 100 -} - -func (s *recordingService) attachCumulativeDepletion(ctx context.Context, recording *entity.Recording) error { - if recording == nil || recording.ProjectFlockKandangId == 0 || recording.RecordDatetime.IsZero() { - return nil - } - total, err := s.Repository.GetCumulativeDepletionByProjectFlockKandangUntil(s.Repository.DB().WithContext(ctx), recording.ProjectFlockKandangId, recording.RecordDatetime) - if err != nil { - return err - } - recording.TotalDepletionCumQty = &total - return nil -} - -func (s *recordingService) attachCumulativeDepletions(ctx context.Context, recordings []entity.Recording) error { - if len(recordings) == 0 { - return nil - } - for i := range recordings { - if err := s.attachCumulativeDepletion(ctx, &recordings[i]); err != nil { - return err - } - } - return nil -} - -func (s *recordingService) attachDepletionRate(ctx context.Context, recording *entity.Recording) error { - if recording == nil { - return nil - } - current := 0.0 - if recording.TotalDepletionQty != nil { - current = *recording.TotalDepletionQty - } - day := 0 - if recording.Day != nil { - day = *recording.Day - } - prev, err := s.Repository.FindPreviousRecording(s.Repository.DB().WithContext(ctx), recording.ProjectFlockKandangId, day) - if err != nil { - return err - } - totalChick, err := s.Repository.GetTotalChick(s.Repository.DB().WithContext(ctx), recording.ProjectFlockKandangId) - if err != nil { - return err - } - rate := computeDepletionRate(prev, current, totalChick) - recording.DepletionRate = &rate - return nil -} - -func (s *recordingService) attachDepletionRates(ctx context.Context, recordings []entity.Recording) error { - if len(recordings) == 0 { - return nil - } - for i := range recordings { - if err := s.attachDepletionRate(ctx, &recordings[i]); err != nil { - return err - } - } - return nil -} - func (s *recordingService) createRecordingApproval( ctx context.Context, db *gorm.DB, @@ -1362,233 +1255,3 @@ func (s *recordingService) createRecordingApproval( _, err := svc.CreateApproval(ctx, utils.ApprovalWorkflowRecording, recordingID, step, &action, actorID, notes) return err } - -func (s *recordingService) attachLatestApprovals(ctx context.Context, items []entity.Recording) error { - if len(items) == 0 || s.ApprovalSvc == nil { - return nil - } - - ids := make([]uint, 0, len(items)) - visited := make(map[uint]struct{}, len(items)) - for _, item := range items { - if item.Id == 0 { - continue - } - if _, ok := visited[item.Id]; ok { - continue - } - visited[item.Id] = struct{}{} - ids = append(ids, item.Id) - } - - if len(ids) == 0 { - return nil - } - - latestMap, err := s.ApprovalSvc.LatestByTargets(ctx, utils.ApprovalWorkflowRecording, ids, func(db *gorm.DB) *gorm.DB { - return db.Preload("ActionUser") - }) - if err != nil { - s.Log.Warnf("Unable to load latest approvals for recordings: %+v", err) - return nil - } - - if len(latestMap) == 0 { - return nil - } - - for i := range items { - if items[i].Id == 0 { - continue - } - if approval, ok := latestMap[items[i].Id]; ok { - items[i].LatestApproval = approval - } - } - - return nil -} - -func (s *recordingService) attachLatestApproval(ctx context.Context, item *entity.Recording) error { - if item == nil || item.Id == 0 || s.ApprovalSvc == nil { - return nil - } - - approvals, err := s.ApprovalSvc.ListByTarget(ctx, utils.ApprovalWorkflowRecording, item.Id, func(db *gorm.DB) *gorm.DB { - return db.Preload("ActionUser") - }) - if err != nil { - s.Log.Warnf("Unable to load approvals for recording %d: %+v", item.Id, err) - return nil - } - - if len(approvals) == 0 { - item.LatestApproval = nil - return nil - } - - latest := approvals[len(approvals)-1] - item.LatestApproval = &latest - return nil -} - -type productionStandardValues struct { - HenDay *float64 - HenHouse *float64 - FeedIntake *float64 - MaxDepletion *float64 - EggMass *float64 - EggWeight *float64 -} - -func (s *recordingService) attachProductionStandards(ctx context.Context, items []entity.Recording) error { - if len(items) == 0 { - return nil - } - - for i := range items { - if err := s.attachProductionStandard(ctx, &items[i]); err != nil { - s.Log.Warnf("Unable to load production standard for recording %d: %+v", items[i].Id, err) - } - } - return nil -} - -func (s *recordingService) attachProductionStandard(ctx context.Context, item *entity.Recording) error { - if item == nil || item.Id == 0 { - return nil - } - if item.Day == nil || *item.Day <= 0 { - return nil - } - if item.ProjectFlockKandang == nil || item.ProjectFlockKandang.ProjectFlock.Id == 0 { - return nil - } - - standardID := item.ProjectFlockKandang.ProjectFlock.ProductionStandardId - if standardID == 0 { - return nil - } - - category := strings.ToUpper(item.ProjectFlockKandang.ProjectFlock.Category) - weekBase := 1 - if category == string(utils.ProjectFlockCategoryLaying) { - weekBase = 18 - } - week := ((int(*item.Day) - 1) / 7) + weekBase - if week <= 0 { - return nil - } - - db := s.Repository.DB() - standardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db) - growthDetailRepo := rProductionStandard.NewStandardGrowthDetailRepository(db) - - var standard productionStandardValues - var standardFcr *float64 - detail, err := standardDetailRepo.GetByStandardIDAndWeek(ctx, standardID, week) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - if detail != nil { - standard.HenDay = detail.TargetHenDayProduction - standard.HenHouse = detail.TargetHenHouseProduction - standard.EggWeight = detail.TargetEggWeight - standard.EggMass = detail.TargetEggMass - if detail.StandardFCR != nil { - standardFcr = detail.StandardFCR - } - } - - growthDetail, err := growthDetailRepo.GetByStandardIDAndWeek(ctx, standardID, week) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - if growthDetail != nil { - standard.FeedIntake = growthDetail.FeedIntake - standard.MaxDepletion = growthDetail.MaxDepletion - } - - item.StandardHenDay = standard.HenDay - item.StandardHenHouse = standard.HenHouse - item.StandardFeedIntake = standard.FeedIntake - item.StandardMaxDepletion = standard.MaxDepletion - item.StandardEggMass = standard.EggMass - item.StandardEggWeight = standard.EggWeight - item.StandardFcr = standardFcr - - return nil -} - -func uniqueUintSlice(values []uint) []uint { - if len(values) == 0 { - return nil - } - - seen := make(map[uint]struct{}, len(values)) - result := make([]uint, 0, len(values)) - for _, v := range values { - if v == 0 { - continue - } - if _, ok := seen[v]; ok { - continue - } - seen[v] = struct{}{} - result = append(result, v) - } - return result -} - -func (s *recordingService) ensureProjectFlockApproved(ctx context.Context, projectFlockID uint) error { - if projectFlockID == 0 { - return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid") - } - - var ( - latest *entity.Approval - err error - ) - if s.ApprovalSvc != nil { - latest, err = s.ApprovalSvc.LatestByTarget(ctx, utils.ApprovalWorkflowProjectFlock, projectFlockID, nil) - } else { - latest, err = s.ApprovalRepo.LatestByTarget(ctx, utils.ApprovalWorkflowProjectFlock.String(), projectFlockID, nil) - } - if err != nil { - s.Log.Errorf("Failed to check project flock %d approval status: %+v", projectFlockID, err) - return fiber.NewError(fiber.StatusInternalServerError, "Gagal memeriksa status project flock") - } - - if latest == nil { - return fiber.NewError(fiber.StatusBadRequest, "Project flock masih dalam status pengajuan sehingga belum dapat membuat recording") - } - if latest.StepNumber != uint16(utils.ProjectFlockStepAktif) || latest.Action == nil || *latest.Action != entity.ApprovalActionApproved { - return fiber.NewError(fiber.StatusBadRequest, "Project flock masih dalam status pengajuan sehingga belum dapat membuat recording") - } - - return nil -} - -func (s *recordingService) ensureChickInExists(ctx context.Context, projectFlockKandangID uint) error { - if projectFlockKandangID == 0 { - return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid") - } - - populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(ctx, projectFlockKandangID) - if err != nil { - s.Log.Errorf("Failed to check project flock population for project_flock_kandang_id=%d: %+v", projectFlockKandangID, err) - return fiber.NewError(fiber.StatusInternalServerError, "Gagal memeriksa data chick in") - } - - if len(populations) == 0 { - return fiber.NewError(fiber.StatusBadRequest, "Project flock belum memiliki chick in yang disetujui sehingga belum dapat membuat recording") - } - - for _, population := range populations { - if population.TotalQty > 0 { - return nil - } - } - - return fiber.NewError(fiber.StatusBadRequest, "Chick in project flock belum disetujui sehingga belum dapat membuat recording") -} diff --git a/internal/modules/production/recordings/services/recording_fifo.service.go b/internal/modules/production/recordings/services/recording_fifo.service.go index 375b75ce..eb9e5094 100644 --- a/internal/modules/production/recordings/services/recording_fifo.service.go +++ b/internal/modules/production/recordings/services/recording_fifo.service.go @@ -3,43 +3,92 @@ package service import ( "context" "errors" + "fmt" + "math" "strings" + "time" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" - rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" - repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" - rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" - recordingutil "gitlab.com/mbugroup/lti-api.git/internal/utils/recording" "github.com/gofiber/fiber/v2" "gorm.io/gorm" ) -type RecordingFIFOIntegrationService interface { - ConsumeRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock, note string, actorID uint) error - ReleaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock, note string, actorID uint) error -} - var recordingStockUsableKey = fifo.UsableKeyRecordingStock var recordingDepletionUsableKey = fifo.UsableKeyRecordingDepletion -func NewRecordingFIFOIntegrationService( - repo repository.RecordingRepository, - productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, - fifoSvc commonSvc.FifoService, - stockLogRepo rStockLogs.StockLogRepository, -) RecordingFIFOIntegrationService { - return &recordingService{ - Log: utils.Log, - Repository: repo, - ProductWarehouseRepo: productWarehouseRepo, - FifoSvc: fifoSvc, - StockLogRepo: stockLogRepo, +const depletionUsageTolerance = 0.000001 + +func (s *recordingService) logStockTrace(action string, stock entity.RecordingStock, extra string) { + if s == nil || s.Log == nil { + return } + usage := 0.0 + if stock.UsageQty != nil { + usage = *stock.UsageQty + } + pending := 0.0 + if stock.PendingQty != nil { + pending = *stock.PendingQty + } + s.Log.Infof( + "[recording-stock] action=%s recording_id=%d stock_id=%d pw=%d usage=%.3f pending=%.3f %s", + action, + stock.RecordingId, + stock.Id, + stock.ProductWarehouseId, + usage, + pending, + extra, + ) +} + +func (s *recordingService) logEggTrace(action string, egg entity.RecordingEgg, extra string) { + if s == nil || s.Log == nil { + return + } + weight := 0.0 + if egg.Weight != nil { + weight = *egg.Weight + } + s.Log.Infof( + "[recording-egg] action=%s recording_id=%d egg_id=%d pw=%d qty=%d weight=%.3f total_qty=%.3f total_used=%.3f %s", + action, + egg.RecordingId, + egg.Id, + egg.ProductWarehouseId, + egg.Qty, + weight, + egg.TotalQty, + egg.TotalUsed, + extra, + ) +} + +func (s *recordingService) logDepletionTrace(action string, dep entity.RecordingDepletion, extra string) { + if s == nil || s.Log == nil { + return + } + sourceWarehouseID := uint(0) + if dep.SourceProductWarehouseId != nil { + sourceWarehouseID = *dep.SourceProductWarehouseId + } + s.Log.Infof( + "[recording-depletion] action=%s recording_id=%d depletion_id=%d source_pw=%d dest_pw=%d qty=%.3f usage=%.3f pending=%.3f %s", + action, + dep.RecordingId, + dep.Id, + sourceWarehouseID, + dep.ProductWarehouseId, + dep.Qty, + dep.UsageQty, + dep.PendingQty, + extra, + ) } func (s *recordingService) consumeRecordingStocks( @@ -49,9 +98,13 @@ func (s *recordingService) consumeRecordingStocks( note string, actorID uint, ) error { - if len(stocks) == 0 || s.FifoSvc == nil { + if len(stocks) == 0 { return nil } + if s.FifoSvc == nil { + s.Log.Errorf("FIFO service is not available for consuming recording stocks") + return errors.New("fifo service is not available") + } if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { return errors.New("stock log repository is not available") } @@ -60,6 +113,7 @@ func (s *recordingService) consumeRecordingStocks( if stock.Id == 0 { continue } + s.logStockTrace("consume:start", stock, "") var desired float64 if stock.UsageQty != nil { @@ -87,6 +141,7 @@ func (s *recordingService) consumeRecordingStocks( if err := s.Repository.UpdateStockUsage(tx, stock.Id, result.UsageQuantity, result.PendingQuantity); err != nil { return err } + s.logStockTrace("consume:done", stock, fmt.Sprintf("desired=%.3f used=%.3f pending=%.3f", desiredTotal, result.UsageQuantity, result.PendingQuantity)) logDecrease := result.UsageQuantity if result.PendingQuantity > 0 { @@ -129,9 +184,13 @@ func (s *recordingService) consumeRecordingDepletions( note string, actorID uint, ) error { - if len(depletions) == 0 || s.FifoSvc == nil { + if len(depletions) == 0 { return nil } + if s.FifoSvc == nil { + s.Log.Errorf("FIFO service is not available for consuming recording depletions") + return errors.New("fifo service is not available") + } if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { return errors.New("stock log repository is not available") } @@ -140,6 +199,7 @@ func (s *recordingService) consumeRecordingDepletions( if depletion.Id == 0 { continue } + s.logDepletionTrace("consume:start", depletion, "") sourceWarehouseID := uint(0) if depletion.SourceProductWarehouseId != nil { @@ -166,6 +226,7 @@ func (s *recordingService) consumeRecordingDepletions( if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, result.PendingQuantity); err != nil { return err } + s.logDepletionTrace("consume:done", depletion, fmt.Sprintf("desired=%.3f used=%.3f pending=%.3f", desired, result.UsageQuantity, result.PendingQuantity)) logDecrease := result.UsageQuantity if result.PendingQuantity > 0 { @@ -231,16 +292,6 @@ func (s *recordingService) consumeRecordingDepletions( return nil } -func (s *recordingService) ConsumeRecordingStocks( - ctx context.Context, - tx *gorm.DB, - stocks []entity.RecordingStock, - note string, - actorID uint, -) error { - return s.consumeRecordingStocks(ctx, tx, stocks, note, actorID) -} - func (s *recordingService) releaseRecordingStocks( ctx context.Context, tx *gorm.DB, @@ -248,9 +299,13 @@ func (s *recordingService) releaseRecordingStocks( note string, actorID uint, ) error { - if len(stocks) == 0 || s.FifoSvc == nil { + if len(stocks) == 0 { return nil } + if s.FifoSvc == nil { + s.Log.Errorf("FIFO service is not available for releasing recording stocks") + return errors.New("fifo service is not available") + } if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { return errors.New("stock log repository is not available") } @@ -259,6 +314,26 @@ func (s *recordingService) releaseRecordingStocks( if stock.Id == 0 { continue } + if stock.UsageQty != nil && *stock.UsageQty > 0 { + activeCount, err := s.countActiveAllocations(ctx, tx, fifo.UsableKeyRecordingStock, stock.Id) + if err != nil { + return err + } + if activeCount == 0 { + s.Log.Warnf("recording-stock release: no active allocations, forcing usage/pending to 0 (stock_id=%d)", stock.Id) + if err := s.Repository.UpdateStockUsage(tx, stock.Id, 0, 0); err != nil { + return err + } + continue + } + if err := s.resyncStockableUsageFromAllocations(ctx, tx, fifo.UsableKeyRecordingStock, stock.Id); err != nil { + return err + } + if err := s.ensureActiveAllocations(ctx, tx, fifo.UsableKeyRecordingStock, stock.Id); err != nil { + return err + } + } + s.logStockTrace("release:start", stock, "") if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ UsableKey: recordingStockUsableKey, UsableID: stock.Id, @@ -271,6 +346,7 @@ func (s *recordingService) releaseRecordingStocks( if err := s.Repository.UpdateStockUsage(tx, stock.Id, 0, 0); err != nil { return err } + s.logStockTrace("release:done", stock, "") if stock.UsageQty != nil && *stock.UsageQty > 0 && strings.TrimSpace(note) != "" && actorID != 0 { log := &entity.StockLog{ @@ -309,9 +385,13 @@ func (s *recordingService) releaseRecordingDepletions( note string, actorID uint, ) error { - if len(depletions) == 0 || s.FifoSvc == nil { + if len(depletions) == 0 { return nil } + if s.FifoSvc == nil { + s.Log.Errorf("FIFO service is not available for releasing recording depletions") + return errors.New("fifo service is not available") + } if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { return errors.New("stock log repository is not available") } @@ -320,6 +400,36 @@ func (s *recordingService) releaseRecordingDepletions( if depletion.Id == 0 { continue } + if depletion.UsageQty > 0 { + activeCount, err := s.countActiveAllocations(ctx, tx, fifo.UsableKeyRecordingDepletion, depletion.Id) + if err != nil { + return err + } + if activeCount == 0 { + s.Log.Warnf("recording-depletion release: no active allocations, forcing usage/pending to 0 (depletion_id=%d)", depletion.Id) + if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, 0); err != nil { + return err + } + if err := tx.WithContext(ctx). + Table("recording_depletions"). + Where("id = ?", depletion.Id). + Update("usage_qty", 0).Error; err != nil { + return err + } + continue + } + if err := s.resyncStockableUsageFromAllocations(ctx, tx, fifo.UsableKeyRecordingDepletion, depletion.Id); err != nil { + return err + } + if err := s.ensureActiveAllocations(ctx, tx, fifo.UsableKeyRecordingDepletion, depletion.Id); err != nil { + return err + } + } + s.logDepletionTrace("release:start", depletion, "") + if err := validateDepletionUsage(depletion); err != nil { + s.Log.Errorf("FIFO depletion mismatch for recording %d (depletion %d): qty=%.3f usage=%.3f pending=%.3f", depletion.RecordingId, depletion.Id, depletion.Qty, depletion.UsageQty, depletion.PendingQty) + return err + } sourceWarehouseID := uint(0) if depletion.SourceProductWarehouseId != nil { @@ -340,6 +450,7 @@ func (s *recordingService) releaseRecordingDepletions( if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, 0); err != nil { return err } + s.logDepletionTrace("release:done", depletion, "") logIncrease := depletion.Qty if depletion.PendingQty > 0 { @@ -405,14 +516,15 @@ func (s *recordingService) releaseRecordingDepletions( return nil } -func (s *recordingService) ReleaseRecordingStocks( - ctx context.Context, - tx *gorm.DB, - stocks []entity.RecordingStock, - note string, - actorID uint, -) error { - return s.releaseRecordingStocks(ctx, tx, stocks, note, actorID) +func validateDepletionUsage(depletion entity.RecordingDepletion) error { + desired := depletion.Qty + depletion.PendingQty + if math.Abs(depletion.UsageQty-desired) <= depletionUsageTolerance { + return nil + } + return fiber.NewError( + fiber.StatusConflict, + fmt.Sprintf("FIFO depletion mismatch (id=%d): qty=%.3f usage=%.3f pending=%.3f", depletion.Id, depletion.Qty, depletion.UsageQty, depletion.PendingQty), + ) } func (s *recordingService) logRecordingEggUsage( @@ -503,9 +615,13 @@ func (s *recordingService) replenishRecordingEggs( note string, actorID uint, ) error { - if len(eggs) == 0 || s.FifoSvc == nil { + if len(eggs) == 0 { return nil } + if s.FifoSvc == nil { + s.Log.Errorf("FIFO service is not available for replenishing recording eggs") + return errors.New("fifo service is not available") + } if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { return errors.New("stock log repository is not available") } @@ -514,6 +630,7 @@ func (s *recordingService) replenishRecordingEggs( if egg.Id == 0 || egg.ProductWarehouseId == 0 || egg.Qty <= 0 { continue } + s.logEggTrace("replenish:start", egg, "") if _, err := s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ StockableKey: fifo.StockableKeyRecordingEgg, StockableID: egg.Id, @@ -524,6 +641,7 @@ func (s *recordingService) replenishRecordingEggs( s.Log.Errorf("Failed to replenish FIFO stock for recording egg %d: %+v", egg.Id, err) return err } + s.logEggTrace("replenish:done", egg, "") if strings.TrimSpace(note) != "" && actorID != 0 { log := &entity.StockLog{ @@ -555,6 +673,210 @@ func (s *recordingService) replenishRecordingEggs( return nil } +func (s *recordingService) replenishRecordingDepletions( + ctx context.Context, + tx *gorm.DB, + depletions []entity.RecordingDepletion, +) error { + if len(depletions) == 0 { + return nil + } + if s.FifoSvc == nil { + s.Log.Errorf("FIFO service is not available for replenishing recording depletions") + return errors.New("fifo service is not available") + } + + for _, depletion := range depletions { + if depletion.Id == 0 || depletion.ProductWarehouseId == 0 || depletion.Qty <= 0 { + continue + } + s.logDepletionTrace("replenish:start", depletion, "") + if _, err := s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ + StockableKey: fifo.StockableKeyRecordingDepletion, + StockableID: depletion.Id, + ProductWarehouseID: depletion.ProductWarehouseId, + Quantity: depletion.Qty, + Tx: tx, + }); err != nil { + s.Log.Errorf("Failed to replenish FIFO stock for recording depletion %d: %+v", depletion.Id, err) + return err + } + s.logDepletionTrace("replenish:done", depletion, "") + } + + return nil +} + +func (s *recordingService) reduceRecordingDepletions( + ctx context.Context, + tx *gorm.DB, + depletions []entity.RecordingDepletion, +) error { + if len(depletions) == 0 { + return nil + } + if s.FifoSvc == nil { + s.Log.Errorf("FIFO service is not available for reducing recording depletions") + return errors.New("fifo service is not available") + } + + for _, depletion := range depletions { + if depletion.Id == 0 || depletion.ProductWarehouseId == 0 || depletion.Qty <= 0 { + continue + } + s.logDepletionTrace("reduce:start", depletion, "") + if err := s.FifoSvc.AdjustStockableQuantity(ctx, commonSvc.StockAdjustRequest{ + StockableKey: fifo.StockableKeyRecordingDepletion, + StockableID: depletion.Id, + ProductWarehouseID: depletion.ProductWarehouseId, + Quantity: -depletion.Qty, + Tx: tx, + }); err != nil { + s.Log.Errorf("Failed to reduce FIFO stock for recording depletion %d: %+v", depletion.Id, err) + return err + } + s.logDepletionTrace("reduce:done", depletion, "") + } + + return nil +} + +func (s *recordingService) reduceRecordingEggs( + ctx context.Context, + tx *gorm.DB, + eggs []entity.RecordingEgg, +) error { + if len(eggs) == 0 { + return nil + } + if s.FifoSvc == nil { + s.Log.Errorf("FIFO service is not available for reducing recording eggs") + return errors.New("fifo service is not available") + } + + for _, egg := range eggs { + if egg.Id == 0 || egg.ProductWarehouseId == 0 || egg.Qty <= 0 { + continue + } + s.logEggTrace("reduce:start", egg, "") + if err := s.FifoSvc.AdjustStockableQuantity(ctx, commonSvc.StockAdjustRequest{ + StockableKey: fifo.StockableKeyRecordingEgg, + StockableID: egg.Id, + ProductWarehouseID: egg.ProductWarehouseId, + Quantity: -float64(egg.Qty), + Tx: tx, + }); err != nil { + s.Log.Errorf("Failed to reduce FIFO stock for recording egg %d: %+v", egg.Id, err) + return err + } + s.logEggTrace("reduce:done", egg, "") + } + + return nil +} + +func (s *recordingService) ensureActiveAllocations( + ctx context.Context, + tx *gorm.DB, + usableKey fifo.UsableKey, + usableID uint, +) error { + if usableID == 0 { + return nil + } + var count int64 + if err := tx.WithContext(ctx). + Model(&entity.StockAllocation{}). + Where("usable_type = ? AND usable_id = ? AND status = ?", usableKey, usableID, entity.StockAllocationStatusActive). + Count(&count).Error; err != nil { + return err + } + if count == 0 { + return fiber.NewError(fiber.StatusConflict, fmt.Sprintf("no active allocations for usable %s id=%d", usableKey, usableID)) + } + return nil +} + +func (s *recordingService) countActiveAllocations( + ctx context.Context, + tx *gorm.DB, + usableKey fifo.UsableKey, + usableID uint, +) (int64, error) { + if usableID == 0 { + return 0, nil + } + var count int64 + if err := tx.WithContext(ctx). + Model(&entity.StockAllocation{}). + Where("usable_type = ? AND usable_id = ? AND status = ?", usableKey, usableID, entity.StockAllocationStatusActive). + Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + +func (s *recordingService) resyncStockableUsageFromAllocations( + ctx context.Context, + tx *gorm.DB, + usableKey fifo.UsableKey, + usableID uint, +) error { + if usableID == 0 { + return nil + } + + type stockableRef struct { + StockableType string + StockableID uint + } + + var refs []stockableRef + if err := tx.WithContext(ctx). + Model(&entity.StockAllocation{}). + Select("stockable_type, stockable_id"). + Where("usable_type = ? AND usable_id = ? AND status = ?", usableKey, usableID, entity.StockAllocationStatusActive). + Group("stockable_type, stockable_id"). + Scan(&refs).Error; err != nil { + return err + } + if len(refs) == 0 { + return nil + } + + for _, ref := range refs { + var total float64 + if err := tx.WithContext(ctx). + Model(&entity.StockAllocation{}). + Select("COALESCE(SUM(qty),0)"). + Where("stockable_type = ? AND stockable_id = ? AND status = ?", ref.StockableType, ref.StockableID, entity.StockAllocationStatusActive). + Scan(&total).Error; err != nil { + return err + } + + switch ref.StockableType { + case string(fifo.StockableKeyProjectFlockPopulation): + if err := tx.WithContext(ctx). + Table("project_flock_populations"). + Where("id = ?", ref.StockableID). + Update("total_used_qty", total).Error; err != nil { + return err + } + case string(fifo.StockableKeyPurchaseItems): + if err := tx.WithContext(ctx). + Table("purchase_items"). + Where("id = ?", ref.StockableID). + Update("total_used", total).Error; err != nil { + return err + } + default: + // no-op for other stockables + } + } + + return nil +} + type desiredStock struct { Usage float64 Pending float64 @@ -565,7 +887,7 @@ type desiredDepletion struct { Pending float64 } -func resetStockQuantitiesForFIFO(stocks []entity.RecordingStock, enabled bool) []desiredStock { +func resetStockQuantitiesForFIFO(stocks []entity.RecordingStock) []desiredStock { desired := make([]desiredStock, len(stocks)) for i := range stocks { if stocks[i].UsageQty != nil { @@ -574,9 +896,6 @@ func resetStockQuantitiesForFIFO(stocks []entity.RecordingStock, enabled bool) [ if stocks[i].PendingQty != nil { desired[i].Pending = *stocks[i].PendingQty } - if !enabled { - continue - } zero := 0.0 stocks[i].UsageQty = &zero stocks[i].PendingQty = &zero @@ -584,39 +903,19 @@ func resetStockQuantitiesForFIFO(stocks []entity.RecordingStock, enabled bool) [ return desired } -func applyStockDesiredQuantities(stocks []entity.RecordingStock, desired []desiredStock, enabled bool) { - if !enabled { - return - } - for i := range stocks { - if i >= len(desired) { - break - } - usage := desired[i].Usage - pending := desired[i].Pending - stocks[i].UsageQty = &usage - stocks[i].PendingQty = &pending - } -} - -func resetDepletionQuantitiesForFIFO(depletions []entity.RecordingDepletion, enabled bool) []desiredDepletion { +func resetDepletionQuantitiesForFIFO(depletions []entity.RecordingDepletion) []desiredDepletion { desired := make([]desiredDepletion, len(depletions)) for i := range depletions { desired[i].Qty = depletions[i].Qty desired[i].Pending = depletions[i].PendingQty - if !enabled { - continue - } depletions[i].Qty = 0 + depletions[i].UsageQty = 0 depletions[i].PendingQty = 0 } return desired } -func applyDepletionDesiredQuantities(depletions []entity.RecordingDepletion, desired []desiredDepletion, enabled bool) { - if !enabled { - return - } +func applyDepletionDesiredQuantities(depletions []entity.RecordingDepletion, desired []desiredDepletion) { for i := range depletions { if i >= len(desired) { break @@ -636,11 +935,8 @@ func (s *recordingService) syncRecordingStocks( actorID uint, ) error { if s.FifoSvc == nil { - if err := s.Repository.DeleteStocks(tx, recordingID); err != nil { - return err - } - mapped := recordingutil.MapStocks(recordingID, incoming) - return s.Repository.CreateStocks(tx, mapped) + s.Log.Errorf("FIFO service is not available for syncing recording stocks") + return errors.New("fifo service is not available") } existingByWarehouse := make(map[uint][]entity.RecordingStock) @@ -701,3 +997,137 @@ func (s *recordingService) syncRecordingStocks( } return s.consumeRecordingStocks(ctx, tx, stocksToConsume, note, actorID) } + +func sumDepletionQty(items []entity.RecordingDepletion) float64 { + var total float64 + for _, item := range items { + if item.Qty > 0 { + total += item.Qty + } + } + return total +} + +func (s *recordingService) ensureDepletionWithinPopulation(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, newTotal float64, existingTotal float64) error { + if projectFlockKandangId == 0 || newTotal <= 0 { + return nil + } + totalChick, err := s.Repository.GetTotalChick(tx, projectFlockKandangId) + if err != nil { + return err + } + // totalChick already reflects existing depletions; add them back to compare the delta. + available := float64(totalChick) + existingTotal + if newTotal > available { + return fiber.NewError(fiber.StatusBadRequest, "Depletion melebihi populasi yang tersedia") + } + return nil +} + +func ensureRecordingEggsUnused(eggs []entity.RecordingEgg) error { + for _, egg := range eggs { + if egg.TotalUsed > 0 { + return fiber.NewError(fiber.StatusBadRequest, "Recording egg sudah digunakan sehingga tidak dapat diubah") + } + } + return nil +} + +func (s *recordingService) resolvePopulationWarehouseID(ctx context.Context, projectFlockKandangID uint) (uint, error) { + if projectFlockKandangID == 0 { + return 0, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid") + } + populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(ctx, projectFlockKandangID) + if err != nil { + s.Log.Errorf("Failed to fetch populations for project_flock_kandang_id=%d: %+v", projectFlockKandangID, err) + return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data populasi") + } + for _, pop := range populations { + if pop.ProductWarehouseId > 0 && pop.TotalQty > 0 { + return pop.ProductWarehouseId, nil + } + } + for _, pop := range populations { + if pop.ProductWarehouseId > 0 { + return pop.ProductWarehouseId, nil + } + } + return 0, fiber.NewError(fiber.StatusBadRequest, "Source product warehouse populasi tidak ditemukan") +} + +func (s *recordingService) recalculateFrom(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, from time.Time) error { + if tx == nil || projectFlockKandangId == 0 || from.IsZero() { + return nil + } + + fromUTC := from.UTC() + records, err := s.Repository.ListByProjectFlockKandangID(ctx, tx, projectFlockKandangId, &fromUTC) + if err != nil { + return err + } + + for i := range records { + if err := s.computeAndUpdateMetrics(ctx, tx, &records[i]); err != nil { + return err + } + } + + return nil +} + +func (s *recordingService) rollbackRecordingInventory(ctx context.Context, tx *gorm.DB, recordingID uint, note string, actorID uint) error { + if recordingID == 0 || tx == nil { + return nil + } + if err := s.requireFIFO(); err != nil { + return err + } + + oldDepletions, err := s.Repository.ListDepletions(tx, recordingID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to list depletions: %+v", err) + return err + } + + oldEggs, err := s.Repository.ListEggs(tx, recordingID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to list eggs: %+v", err) + return err + } + if err := ensureRecordingEggsUnused(oldEggs); err != nil { + return err + } + if err := s.releaseRecordingDepletions(ctx, tx, oldDepletions, note, actorID); err != nil { + return err + } + + oldStocks, err := s.Repository.ListStocks(tx, recordingID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to list stocks: %+v", err) + return err + } + if err := s.releaseRecordingStocks(ctx, tx, oldStocks, note, actorID); err != nil { + return err + } + + if err := s.reduceRecordingDepletions(ctx, tx, oldDepletions); err != nil { + return err + } + if err := s.reduceRecordingEggs(ctx, tx, oldEggs); err != nil { + return err + } + + if err := s.logRecordingEggRollback(ctx, tx, oldEggs, note, actorID); err != nil { + return err + } + + return nil +} + +func (s *recordingService) requireFIFO() error { + if s.FifoSvc == nil { + s.Log.Errorf("FIFO service is not available for recording operations") + return fiber.NewError(fiber.StatusInternalServerError, "FIFO service is required for recording operations") + } + return nil +} diff --git a/internal/modules/production/recordings/validations/recording.validation.go b/internal/modules/production/recordings/validations/recording.validation.go index dbbd4f30..647ea37c 100644 --- a/internal/modules/production/recordings/validations/recording.validation.go +++ b/internal/modules/production/recordings/validations/recording.validation.go @@ -1,5 +1,7 @@ package validation +import "time" + type ( Stock struct { ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"` @@ -35,6 +37,7 @@ type Update struct { type Query struct { Page int `query:"page" validate:"omitempty,number,min=1"` Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + Offset int `query:"-" validate:"omitempty,number,min=0"` ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"` Search string `query:"search" validate:"omitempty,max=50"` } @@ -44,3 +47,9 @@ type Approve struct { ApprovableIds []uint `json:"approvable_ids" validate:"required_strict,min=1,dive,gt=0"` Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` } + +type GetRecordingNextDay struct { + ProjectFlockKandangId uint `json:"project_flock_kandang_id" query:"project_flock_kandang_id" validate:"required,number,min=1"` + RecordTime *string `json:"record_date" query:"record_date" validate:"required,datetime=2006-01-02"` + RecordTimeValue *time.Time `query:"-" validate:"-"` +} diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index 6ecc6e1f..50e891f5 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -1064,6 +1064,15 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation if err != nil { return err } + // Safety: ensure the PW we got matches the purchase item product. + if pwDetail, err := pwRepoTx.GetDetailByID(c.Context(), pwID); err != nil { + return err + } else if pwDetail.ProductId != uint(item.ProductId) { + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Product warehouse %d belongs to product %d, not purchase item product %d", pwID, pwDetail.ProductId, item.ProductId), + ) + } newPWID = &pwID deltaQty := prep.receivedQty - item.TotalQty diff --git a/internal/utils/fifo/constants.go b/internal/utils/fifo/constants.go index 840ba8e1..6296eedb 100644 --- a/internal/utils/fifo/constants.go +++ b/internal/utils/fifo/constants.go @@ -17,4 +17,5 @@ const ( StockableKeyPurchaseItems StockableKey = "PURCHASE_ITEMS" StockableKeyProjectFlockPopulation StockableKey = "PROJECT_FLOCK_POPULATION" StockableKeyRecordingEgg StockableKey = "RECORDING_EGG" + StockableKeyRecordingDepletion StockableKey = "RECORDING_DEPLETION_IN" ) diff --git a/internal/utils/recording/recording_helpers.go b/internal/utils/recording/recording_helpers.go new file mode 100644 index 00000000..5e79ed40 --- /dev/null +++ b/internal/utils/recording/recording_helpers.go @@ -0,0 +1,330 @@ +package recording + +import ( + "context" + "fmt" + "strings" + + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gorm.io/gorm" +) + +type warnLogger interface { + Warnf(format string, args ...any) +} + +type productWarehouseExistsRepo interface { + ExistsByID(ctx context.Context, id uint) (bool, error) +} + +type recordingValidationRepo interface { + ValidateFeedProductWarehouses(ctx context.Context, ids []uint) (uint, error) + ValidateEggProductWarehouses(ctx context.Context, ids []uint) (uint, error) + ValidateDepletionProductWarehouses(ctx context.Context, ids []uint) (uint, error) +} + +func EnsureProductWarehousesExist(ctx context.Context, repo productWarehouseExistsRepo, ids []uint) error { + if repo == nil || len(ids) == 0 { + return nil + } + for _, id := range ids { + ok, err := repo.ExistsByID(ctx, id) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("product warehouse %d not found", id) + } + } + return nil +} + +type pwValidatorFunc func(ctx context.Context, ids []uint) (uint, error) + +func ensureProductWarehouses(ctx context.Context, ids []uint, label string, validator pwValidatorFunc) error { + if len(ids) == 0 || validator == nil { + return nil + } + invalidID, err := validator(ctx, ids) + if err != nil { + return err + } + if invalidID != 0 { + return fmt.Errorf("product warehouse %d is not a %s warehouse", invalidID, label) + } + return nil +} + +type idGetter[T any] func(T) uint + +func CollectWarehouseIDs[T any](items []T, getID idGetter[T]) []uint { + if len(items) == 0 { + return nil + } + ids := make([]uint, 0, len(items)) + for _, item := range items { + if id := getID(item); id != 0 { + ids = append(ids, id) + } + } + return ids +} + +func EnsureFeedProductWarehouses(ctx context.Context, repo recordingValidationRepo, ids []uint) error { + if repo == nil { + return nil + } + return ensureProductWarehouses(ctx, ids, "feed", repo.ValidateFeedProductWarehouses) +} + +func EnsureEggProductWarehouses(ctx context.Context, repo recordingValidationRepo, ids []uint) error { + if repo == nil { + return nil + } + return ensureProductWarehouses(ctx, ids, "egg", repo.ValidateEggProductWarehouses) +} + +func EnsureDepletionProductWarehouses(ctx context.Context, repo recordingValidationRepo, ids []uint) error { + if repo == nil { + return nil + } + return ensureProductWarehouses(ctx, ids, "depletion", repo.ValidateDepletionProductWarehouses) +} + +func ComputeDepletionRate(prevRecording *entity.Recording, currentDepletion float64, totalChick int64) float64 { + base := 0.0 + if prevRecording != nil && prevRecording.TotalChickQty != nil && *prevRecording.TotalChickQty > 0 { + base = *prevRecording.TotalChickQty + } else if totalChick > 0 { + base = float64(totalChick) + currentDepletion + } + if base <= 0 { + return 0 + } + return (currentDepletion / base) * 100 +} + +func AttachLatestApprovals(ctx context.Context, items []entity.Recording, approvalSvc commonSvc.ApprovalService, logger warnLogger) error { + if len(items) == 0 || approvalSvc == nil { + return nil + } + + ids := make([]uint, 0, len(items)) + visited := make(map[uint]struct{}, len(items)) + for _, item := range items { + if item.Id == 0 { + continue + } + if _, ok := visited[item.Id]; ok { + continue + } + visited[item.Id] = struct{}{} + ids = append(ids, item.Id) + } + + if len(ids) == 0 { + return nil + } + + latestMap, err := approvalSvc.LatestByTargets(ctx, utils.ApprovalWorkflowRecording, ids, func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + }) + if err != nil { + if logger != nil { + logger.Warnf("Unable to load latest approvals for recordings: %+v", err) + } + return nil + } + + if len(latestMap) == 0 { + return nil + } + + for i := range items { + if items[i].Id == 0 { + continue + } + if approval, ok := latestMap[items[i].Id]; ok { + items[i].LatestApproval = approval + } + } + + return nil +} + +func AttachLatestApproval(ctx context.Context, item *entity.Recording, approvalSvc commonSvc.ApprovalService, logger warnLogger) error { + if item == nil || item.Id == 0 || approvalSvc == nil { + return nil + } + + latest, err := approvalSvc.LatestByTarget(ctx, utils.ApprovalWorkflowRecording, item.Id, func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + }) + if err != nil { + if logger != nil { + logger.Warnf("Unable to load approvals for recording %d: %+v", item.Id, err) + } + return nil + } + item.LatestApproval = latest + return nil +} + +type productionStandardValues struct { + HenDay *float64 + HenHouse *float64 + FeedIntake *float64 + MaxDepletion *float64 + EggMass *float64 + EggWeight *float64 +} + +func AttachProductionStandards(ctx context.Context, db *gorm.DB, warnOnly bool, logger warnLogger, items ...*entity.Recording) error { + if len(items) == 0 { + return nil + } + + type standardKey struct { + standardID uint + week int + } + type standardCacheEntry struct { + values productionStandardValues + fcr *float64 + } + + if db == nil { + return nil + } + + standardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db) + growthDetailRepo := rProductionStandard.NewStandardGrowthDetailRepository(db) + cache := make(map[standardKey]standardCacheEntry, len(items)) + + standardIDs := make(map[uint]struct{}, len(items)) + for _, item := range items { + if item == nil || item.ProjectFlockKandang == nil || item.ProjectFlockKandang.ProjectFlock.Id == 0 { + continue + } + if item.ProjectFlockKandang.ProjectFlock.ProductionStandardId > 0 { + standardIDs[item.ProjectFlockKandang.ProjectFlock.ProductionStandardId] = struct{}{} + } + } + + standardDetailByStd := make(map[uint]map[int]*entity.ProductionStandardDetail, len(standardIDs)) + growthDetailByStd := make(map[uint]map[int]*entity.StandardGrowthDetail, len(standardIDs)) + + for standardID := range standardIDs { + details, err := standardDetailRepo.GetByProductionStandardID(ctx, standardID) + if err != nil { + if warnOnly { + if logger != nil { + logger.Warnf("Unable to preload production standard detail for standard %d: %+v", standardID, err) + } + } else { + return err + } + continue + } + detailMap := make(map[int]*entity.ProductionStandardDetail, len(details)) + for i := range details { + detail := details[i] + detailMap[detail.Week] = &detail + } + standardDetailByStd[standardID] = detailMap + + growths, err := growthDetailRepo.GetByProductionStandardID(ctx, standardID) + if err != nil { + if warnOnly { + if logger != nil { + logger.Warnf("Unable to preload standard growth detail for standard %d: %+v", standardID, err) + } + } else { + return err + } + continue + } + growthMap := make(map[int]*entity.StandardGrowthDetail, len(growths)) + for i := range growths { + growth := growths[i] + growthMap[growth.Week] = &growth + } + growthDetailByStd[standardID] = growthMap + } + + for _, item := range items { + if item == nil || item.ProjectFlockKandang == nil || item.ProjectFlockKandang.ProjectFlock.Id == 0 { + continue + } + standardID := item.ProjectFlockKandang.ProjectFlock.ProductionStandardId + if standardID == 0 { + continue + } + week := RecordingWeekValue(*item) + cacheKey := standardKey{standardID: standardID, week: week} + if cached, ok := cache[cacheKey]; ok { + applyProductionStandardValues(item, cached.values, cached.fcr) + continue + } + values := productionStandardValues{} + var fcr *float64 + if detailMap, ok := standardDetailByStd[standardID]; ok { + if detail, ok := detailMap[week]; ok { + values.HenDay = detail.TargetHenDayProduction + values.HenHouse = detail.TargetHenHouseProduction + values.EggMass = detail.TargetEggMass + values.EggWeight = detail.TargetEggWeight + fcr = detail.StandardFCR + } + } + if growthMap, ok := growthDetailByStd[standardID]; ok { + if growth, ok := growthMap[week]; ok { + values.FeedIntake = growth.FeedIntake + values.MaxDepletion = growth.MaxDepletion + } + } + cache[cacheKey] = standardCacheEntry{values: values, fcr: fcr} + applyProductionStandardValues(item, values, fcr) + } + + return nil +} + +func applyProductionStandardValues(item *entity.Recording, values productionStandardValues, fcr *float64) { + item.StandardHenDay = values.HenDay + item.StandardHenHouse = values.HenHouse + item.StandardFeedIntake = values.FeedIntake + item.StandardMaxDepletion = values.MaxDepletion + item.StandardEggMass = values.EggMass + item.StandardEggWeight = values.EggWeight + item.StandardFcr = fcr +} + +func RecordingWeekValue(e entity.Recording) int { + day := intValue(e.Day) + if day <= 0 { + return 0 + } + weekBase := 1 + if IsLayingRecording(e) { + weekBase = 18 + } + return ((day - 1) / 7) + weekBase +} + +func IsLayingRecording(e entity.Recording) bool { + if e.ProjectFlockKandang == nil { + return false + } + return strings.EqualFold(e.ProjectFlockKandang.ProjectFlock.Category, string(utils.ProjectFlockCategoryLaying)) +} + +func intValue(value *int) int { + if value == nil { + return 0 + } + return *value +} diff --git a/internal/utils/recording/util.recording.go b/internal/utils/recording/util.recording.go index 2b146f5f..d03ea800 100644 --- a/internal/utils/recording/util.recording.go +++ b/internal/utils/recording/util.recording.go @@ -1,6 +1,9 @@ package recording import ( + "fmt" + "strings" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" ) @@ -70,3 +73,87 @@ func MapEggs(recordingID uint, createdBy uint, items []validation.Egg) []entity. } return result } + +type EggTotals struct { + Qty int + Weight float64 +} + +func StockUsageByWarehouse(items []entity.RecordingStock) map[uint]float64 { + return TotalsByWarehouse(items, func(stock entity.RecordingStock) (uint, float64) { + var usage float64 + if stock.UsageQty != nil { + usage = *stock.UsageQty + } + return stock.ProductWarehouseId, usage + }) +} + +func StockUsageByWarehouseReq(items []validation.Stock) map[uint]float64 { + return TotalsByWarehouse(items, func(item validation.Stock) (uint, float64) { + return item.ProductWarehouseId, item.Qty + }) +} + +func FloatMapsEqual(a, b map[uint]float64) bool { + if len(a) != len(b) { + return false + } + for key, value := range a { + other, ok := b[key] + if !ok || !floatNearlyEqual(value, other) { + return false + } + } + return true +} + +func EggTotalsEqual(a, b map[uint]EggTotals) bool { + if len(a) != len(b) { + return false + } + for key, value := range a { + other, ok := b[key] + if !ok || value.Qty != other.Qty || !floatNearlyEqual(value.Weight, other.Weight) { + return false + } + } + return true +} + +func floatNearlyEqual(a, b float64) bool { + return a-b <= 0.000001 && b-a <= 0.000001 +} + +func TotalsByWarehouse[T any](items []T, get func(T) (uint, float64)) map[uint]float64 { + result := make(map[uint]float64) + for _, item := range items { + warehouseID, qty := get(item) + result[warehouseID] += qty + } + return result +} + +func EggTotalsByWarehouse[T any](items []T, get func(T) (uint, int, *float64)) map[uint]EggTotals { + result := make(map[uint]EggTotals) + for _, item := range items { + warehouseID, qty, weightPtr := get(item) + weight := 0.0 + if weightPtr != nil { + weight = *weightPtr + } + current := result[warehouseID] + current.Qty += qty + current.Weight += weight + result[warehouseID] = current + } + return result +} + +func RecordingNote(action string, id uint) string { + action = strings.TrimSpace(action) + if action == "" { + return fmt.Sprintf("Recording#%d", id) + } + return fmt.Sprintf("Recording-%s#%d", action, id) +} From e0d42fe6d35e54a77da1422e6d72fd34c73296d7 Mon Sep 17 00:00:00 2001 From: ragilap Date: Wed, 18 Feb 2026 15:30:59 +0700 Subject: [PATCH 3/5] [FEAT/BE] add product flags in stock --- .../recordings/repositories/recording.repository.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index a7978c16..c2a55708 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -861,6 +861,7 @@ func (r *RecordingRepositoryImpl) ValidateFeedProductWarehouses(ctx context.Cont return 0, nil } var invalidIDs []uint + feedFlags := []string{"PAKAN", "OVK"} if err := r.DB().WithContext(ctx). Table("product_warehouses pw"). Where("pw.id IN ?", ids). @@ -868,8 +869,8 @@ func (r *RecordingRepositoryImpl) ValidateFeedProductWarehouses(ctx context.Cont SELECT 1 FROM flags f WHERE f.flagable_type = 'products' AND f.flagable_id = pw.product_id - AND UPPER(f.name) = 'PAKAN' - )`). + AND UPPER(f.name) IN ? + )`, feedFlags). Pluck("pw.id", &invalidIDs).Error; err != nil { return 0, err } From 3da05eea02cffc841f2292b99f1631273fef98c0 Mon Sep 17 00:00:00 2001 From: ragilap Date: Wed, 18 Feb 2026 16:01:20 +0700 Subject: [PATCH 4/5] [FEAT/BE] add coloumn usage_qty and change standart ensure product --- ...d_depletions_recording_total_used.down.sql | 3 + ...add_depletions_recording_total_used.up.sql | 6 +- .../repositories/recording.repository.go | 57 +------------------ .../recordings/services/recording.service.go | 57 ++++++------------- internal/utils/recording/recording_helpers.go | 41 +++++-------- 5 files changed, 39 insertions(+), 125 deletions(-) diff --git a/internal/database/migrations/20260216125014_add_depletions_recording_total_used.down.sql b/internal/database/migrations/20260216125014_add_depletions_recording_total_used.down.sql index 9677a9fe..eb1e8d24 100644 --- a/internal/database/migrations/20260216125014_add_depletions_recording_total_used.down.sql +++ b/internal/database/migrations/20260216125014_add_depletions_recording_total_used.down.sql @@ -6,4 +6,7 @@ ALTER TABLE recording_depletions ALTER TABLE recording_depletions DROP COLUMN IF EXISTS total_used_qty; +ALTER TABLE recording_depletions + DROP COLUMN IF EXISTS usage_qty; + COMMIT; diff --git a/internal/database/migrations/20260216125014_add_depletions_recording_total_used.up.sql b/internal/database/migrations/20260216125014_add_depletions_recording_total_used.up.sql index 59b2aa9a..18870585 100644 --- a/internal/database/migrations/20260216125014_add_depletions_recording_total_used.up.sql +++ b/internal/database/migrations/20260216125014_add_depletions_recording_total_used.up.sql @@ -1,7 +1,8 @@ BEGIN; ALTER TABLE recording_depletions - ADD COLUMN IF NOT EXISTS total_used_qty numeric(15, 3) NOT NULL DEFAULT 0; + ADD COLUMN IF NOT EXISTS total_used_qty numeric(15, 3) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS usage_qty numeric(15, 3) NOT NULL DEFAULT 0; UPDATE recording_depletions SET pending_qty = 0 @@ -11,7 +12,4 @@ ALTER TABLE recording_depletions ADD CONSTRAINT chk_recording_depletions_pending_zero CHECK (pending_qty = 0); -ALTER TABLE recording_depletions - DROP COLUMN IF EXISTS usage_qty; - COMMIT; diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index c2a55708..0f93d0a7 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -65,9 +65,7 @@ type RecordingRepository interface { GetTotalEggProductionWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeightKg float64, err error) GetAverageTargetMetricsByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint, includeTargets bool) (RecordingTargetAverages, error) ResyncProjectFlockPopulationUsage(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error - ValidateFeedProductWarehouses(ctx context.Context, ids []uint) (uint, error) - ValidateEggProductWarehouses(ctx context.Context, ids []uint) (uint, error) - ValidateDepletionProductWarehouses(ctx context.Context, ids []uint) (uint, error) + ValidateProductWarehousesByFlags(ctx context.Context, ids []uint, flags []string) (uint, error) } type RecordingRepositoryImpl struct { @@ -856,36 +854,11 @@ func (r *RecordingRepositoryImpl) ResyncProjectFlockPopulationUsage(ctx context. return nil } -func (r *RecordingRepositoryImpl) ValidateFeedProductWarehouses(ctx context.Context, ids []uint) (uint, error) { +func (r *RecordingRepositoryImpl) ValidateProductWarehousesByFlags(ctx context.Context, ids []uint, flags []string) (uint, error) { if len(ids) == 0 { return 0, nil } var invalidIDs []uint - feedFlags := []string{"PAKAN", "OVK"} - if err := r.DB().WithContext(ctx). - Table("product_warehouses pw"). - Where("pw.id IN ?", ids). - Where(`NOT EXISTS ( - SELECT 1 FROM flags f - WHERE f.flagable_type = 'products' - AND f.flagable_id = pw.product_id - AND UPPER(f.name) IN ? - )`, feedFlags). - Pluck("pw.id", &invalidIDs).Error; err != nil { - return 0, err - } - if len(invalidIDs) > 0 { - return invalidIDs[0], nil - } - return 0, nil -} - -func (r *RecordingRepositoryImpl) ValidateEggProductWarehouses(ctx context.Context, ids []uint) (uint, error) { - if len(ids) == 0 { - return 0, nil - } - eggFlags := []string{"TELUR-UTUH", "TELUR-PECAH", "TELUR-PUTIH", "TELUR-RETAK", "TELUR"} - var invalidIDs []uint if err := r.DB().WithContext(ctx). Table("product_warehouses pw"). Where("pw.id IN ?", ids). @@ -894,31 +867,7 @@ func (r *RecordingRepositoryImpl) ValidateEggProductWarehouses(ctx context.Conte WHERE f.flagable_type = 'products' AND f.flagable_id = pw.product_id AND UPPER(f.name) IN ? - )`, eggFlags). - Pluck("pw.id", &invalidIDs).Error; err != nil { - return 0, err - } - if len(invalidIDs) > 0 { - return invalidIDs[0], nil - } - return 0, nil -} - -func (r *RecordingRepositoryImpl) ValidateDepletionProductWarehouses(ctx context.Context, ids []uint) (uint, error) { - if len(ids) == 0 { - return 0, nil - } - ayamFlags := []string{"AYAM-AFKIR", "AYAM-CULLING", "AYAM-MATI"} - var invalidIDs []uint - if err := r.DB().WithContext(ctx). - Table("product_warehouses pw"). - Where("pw.id IN ?", ids). - Where(`NOT EXISTS ( - SELECT 1 FROM flags f - WHERE f.flagable_type = 'products' - AND f.flagable_id = pw.product_id - AND UPPER(f.name) IN ? - )`, ayamFlags). + )`, flags). Pluck("pw.id", &invalidIDs).Error; err != nil { return 0, err } diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 6ef692b9..5fd387bf 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -311,13 +311,16 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent if err := s.ensureProductWarehousesExist(c, req.Stocks, req.Depletions, req.Eggs); err != nil { return nil, err } - if err := s.ensureFeedProductWarehouses(ctx, req.Stocks); err != nil { + feedIDs := recordingutil.CollectWarehouseIDs(req.Stocks, func(st validation.Stock) uint { return st.ProductWarehouseId }) + if err := s.ensureProductWarehousesByFlags(ctx, feedIDs, []string{"PAKAN", "OVK"}, "feed"); err != nil { return nil, err } - if err := s.ensureDepletionProductWarehouses(ctx, req.Depletions); err != nil { + depletionIDs := recordingutil.CollectWarehouseIDs(req.Depletions, func(d validation.Depletion) uint { return d.ProductWarehouseId }) + if err := s.ensureProductWarehousesByFlags(ctx, depletionIDs, []string{"AYAM-AFKIR", "AYAM-CULLING", "AYAM-MATI"}, "depletion"); err != nil { return nil, err } - if err := s.ensureEggProductWarehouses(ctx, req.Eggs); err != nil { + eggIDs := recordingutil.CollectWarehouseIDs(req.Eggs, func(e validation.Egg) uint { return e.ProductWarehouseId }) + if err := s.ensureProductWarehousesByFlags(ctx, eggIDs, []string{"TELUR-UTUH", "TELUR-PECAH", "TELUR-PUTIH", "TELUR-RETAK", "TELUR"}, "egg"); err != nil { return nil, err } actorID, err := m.ActorIDFromContext(c) @@ -508,7 +511,8 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin if err := s.ensureProductWarehousesExist(c, req.Stocks, nil, nil); err != nil { return err } - if err := s.ensureFeedProductWarehouses(ctx, req.Stocks); err != nil { + feedIDs := recordingutil.CollectWarehouseIDs(req.Stocks, func(st validation.Stock) uint { return st.ProductWarehouseId }) + if err := s.ensureProductWarehousesByFlags(ctx, feedIDs, []string{"PAKAN", "OVK"}, "feed"); err != nil { return err } if err := s.syncRecordingStocks(ctx, tx, recordingEntity.Id, existingStocks, req.Stocks, note, actorID); err != nil { @@ -536,7 +540,8 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin if err := s.ensureProductWarehousesExist(c, nil, req.Depletions, nil); err != nil { return err } - if err := s.ensureDepletionProductWarehouses(ctx, req.Depletions); err != nil { + depletionIDs := recordingutil.CollectWarehouseIDs(req.Depletions, func(d validation.Depletion) uint { return d.ProductWarehouseId }) + if err := s.ensureProductWarehousesByFlags(ctx, depletionIDs, []string{"AYAM-AFKIR", "AYAM-CULLING", "AYAM-MATI"}, "depletion"); err != nil { return err } if err := s.releaseRecordingDepletions(ctx, tx, existingDepletions, note, actorID); err != nil { @@ -607,7 +612,8 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin if err := s.ensureProductWarehousesExist(c, nil, nil, req.Eggs); err != nil { return err } - if err := s.ensureEggProductWarehouses(ctx, req.Eggs); err != nil { + eggIDs := recordingutil.CollectWarehouseIDs(req.Eggs, func(e validation.Egg) uint { return e.ProductWarehouseId }) + if err := s.ensureProductWarehousesByFlags(ctx, eggIDs, []string{"TELUR-UTUH", "TELUR-PECAH", "TELUR-PUTIH", "TELUR-RETAK", "TELUR"}, "egg"); err != nil { return err } if err := ensureRecordingEggsUnused(existingEggs); err != nil { @@ -924,43 +930,14 @@ func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []v return nil } -func (s *recordingService) ensureEggProductWarehouses(ctx context.Context, eggs []validation.Egg) error { - if len(eggs) == 0 { +func (s *recordingService) ensureProductWarehousesByFlags(ctx context.Context, ids []uint, flags []string, label string) error { + if len(ids) == 0 { return nil } - ids := recordingutil.CollectWarehouseIDs(eggs, func(e validation.Egg) uint { return e.ProductWarehouseId }) if err := s.validateWarehouseIDs(ctx, ids, func(ctx context.Context, ids []uint) error { - return recordingutil.EnsureEggProductWarehouses(ctx, s.Repository, ids) - }, "egg"); err != nil { - s.Log.Errorf("Failed to validate egg product warehouses: %+v", err) - return err - } - return nil -} - -func (s *recordingService) ensureDepletionProductWarehouses(ctx context.Context, depletions []validation.Depletion) error { - if len(depletions) == 0 { - return nil - } - ids := recordingutil.CollectWarehouseIDs(depletions, func(d validation.Depletion) uint { return d.ProductWarehouseId }) - if err := s.validateWarehouseIDs(ctx, ids, func(ctx context.Context, ids []uint) error { - return recordingutil.EnsureDepletionProductWarehouses(ctx, s.Repository, ids) - }, "depletion"); err != nil { - s.Log.Errorf("Failed to validate depletion product warehouses: %+v", err) - return err - } - return nil -} - -func (s *recordingService) ensureFeedProductWarehouses(ctx context.Context, stocks []validation.Stock) error { - if len(stocks) == 0 { - return nil - } - ids := recordingutil.CollectWarehouseIDs(stocks, func(st validation.Stock) uint { return st.ProductWarehouseId }) - if err := s.validateWarehouseIDs(ctx, ids, func(ctx context.Context, ids []uint) error { - return recordingutil.EnsureFeedProductWarehouses(ctx, s.Repository, ids) - }, "feed"); err != nil { - s.Log.Errorf("Failed to validate feed product warehouses: %+v", err) + return recordingutil.EnsureProductWarehousesByFlags(ctx, s.Repository, ids, flags, label) + }, label); err != nil { + s.Log.Errorf("Failed to validate %s product warehouses: %+v", label, err) return err } return nil diff --git a/internal/utils/recording/recording_helpers.go b/internal/utils/recording/recording_helpers.go index 5e79ed40..644f6e8d 100644 --- a/internal/utils/recording/recording_helpers.go +++ b/internal/utils/recording/recording_helpers.go @@ -21,9 +21,7 @@ type productWarehouseExistsRepo interface { } type recordingValidationRepo interface { - ValidateFeedProductWarehouses(ctx context.Context, ids []uint) (uint, error) - ValidateEggProductWarehouses(ctx context.Context, ids []uint) (uint, error) - ValidateDepletionProductWarehouses(ctx context.Context, ids []uint) (uint, error) + ValidateProductWarehousesByFlags(ctx context.Context, ids []uint, flags []string) (uint, error) } func EnsureProductWarehousesExist(ctx context.Context, repo productWarehouseExistsRepo, ids []uint) error { @@ -42,13 +40,11 @@ func EnsureProductWarehousesExist(ctx context.Context, repo productWarehouseExis return nil } -type pwValidatorFunc func(ctx context.Context, ids []uint) (uint, error) - -func ensureProductWarehouses(ctx context.Context, ids []uint, label string, validator pwValidatorFunc) error { - if len(ids) == 0 || validator == nil { +func EnsureProductWarehousesByFlags(ctx context.Context, repo recordingValidationRepo, ids []uint, flags []string, label string) error { + if repo == nil || len(ids) == 0 { return nil } - invalidID, err := validator(ctx, ids) + invalidID, err := repo.ValidateProductWarehousesByFlags(ctx, ids, flags) if err != nil { return err } @@ -73,25 +69,16 @@ func CollectWarehouseIDs[T any](items []T, getID idGetter[T]) []uint { return ids } -func EnsureFeedProductWarehouses(ctx context.Context, repo recordingValidationRepo, ids []uint) error { - if repo == nil { - return nil - } - return ensureProductWarehouses(ctx, ids, "feed", repo.ValidateFeedProductWarehouses) -} - -func EnsureEggProductWarehouses(ctx context.Context, repo recordingValidationRepo, ids []uint) error { - if repo == nil { - return nil - } - return ensureProductWarehouses(ctx, ids, "egg", repo.ValidateEggProductWarehouses) -} - -func EnsureDepletionProductWarehouses(ctx context.Context, repo recordingValidationRepo, ids []uint) error { - if repo == nil { - return nil - } - return ensureProductWarehouses(ctx, ids, "depletion", repo.ValidateDepletionProductWarehouses) +func EnsureProductWarehousesByFlagsForItems[T any]( + ctx context.Context, + repo recordingValidationRepo, + items []T, + getID idGetter[T], + flags []string, + label string, +) error { + ids := CollectWarehouseIDs(items, getID) + return EnsureProductWarehousesByFlags(ctx, repo, ids, flags, label) } func ComputeDepletionRate(prevRecording *entity.Recording, currentDepletion float64, totalChick int64) float64 { From 95547ad7c7dbcbaf3019cdbac5e381b58acfab6e Mon Sep 17 00:00:00 2001 From: giovanni Date: Thu, 19 Feb 2026 16:00:48 +0700 Subject: [PATCH 5/5] fix insert stock to stocklog --- .../modules/inventory/transfers/services/transfer.service.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index aa5a6069..fa4cb8ca 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -483,7 +483,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } if len(stockLogs) > 0 { latestStockLog := stockLogs[0] - stockLogDecrease.Stock -= latestStockLog.Stock - stockLogDecrease.Decrease + stockLogDecrease.Stock = latestStockLog.Stock - stockLogDecrease.Decrease } else { stockLogDecrease.Stock -= stockLogDecrease.Decrease }