diff --git a/db_lti_erp-202601271102-stg.sql b/db_lti_erp-202601271102-stg.sql deleted file mode 100644 index 2a8495d8..00000000 Binary files a/db_lti_erp-202601271102-stg.sql and /dev/null differ diff --git a/internal/common/repository/common.hpp.repository.go b/internal/common/repository/common.hpp.repository.go index e0f2bcc5..2c8187fb 100644 --- a/internal/common/repository/common.hpp.repository.go +++ b/internal/common/repository/common.hpp.repository.go @@ -20,7 +20,6 @@ type HppCostRepository interface { GetTotalPopulation(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) GetPulletCost(ctx context.Context, projectFlockKandangId uint) (float64, error) GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error) - GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIdsAll(ctx context.Context, projectFlockKandangIDs []uint) (float64, float64, error) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, startDate *time.Time, endDate *time.Time) (float64, float64, error) GetProjectFlockIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (uint, error) GetTransferSourceSummary(ctx context.Context, projectFlockKandangId uint) (uint, float64, error) @@ -197,10 +196,10 @@ func (r *HppRepositoryImpl) GetPulletCost(ctx context.Context, projectFlockKanda } func (r *HppRepositoryImpl) GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error) { - // if date == nil { - // now := time.Now() - // date = &now - // } + if date == nil { + now := time.Now() + date = &now + } var totals struct { TotalPieces float64 @@ -220,24 +219,6 @@ func (r *HppRepositoryImpl) GetEggProduksiPiecesAndWeightKgByProjectFlockKandang return totals.TotalPieces, totals.TotalWeightKg, nil } -func (r *HppRepositoryImpl) GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIdsAll(ctx context.Context, projectFlockKandangIDs []uint) (float64, float64, error) { - var totals struct { - TotalPieces float64 - TotalWeightKg float64 - } - err := r.db.WithContext(ctx). - Table("recordings AS r"). - Select("COALESCE(SUM(re.qty), 0) AS total_pieces, COALESCE(SUM(re.weight), 0)AS total_weight_kg"). - Joins("JOIN recording_eggs AS re ON re.recording_id = r.id"). - Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). - Scan(&totals).Error - if err != nil { - return 0, 0, err - } - - return totals.TotalPieces, totals.TotalWeightKg, nil -} - func (r *HppRepositoryImpl) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds( ctx context.Context, projectFlockKandangIDs []uint, diff --git a/internal/common/service/common.fifo.service.go b/internal/common/service/common.fifo.service.go index 14cbb5c1..190bf819 100644 --- a/internal/common/service/common.fifo.service.go +++ b/internal/common/service/common.fifo.service.go @@ -147,6 +147,7 @@ type StockReleaseRequest struct { Reason *string Tx *gorm.DB } + func (s *fifoService) AdjustStockableQuantity(ctx context.Context, req StockAdjustRequest) error { if req.StockableID == 0 || strings.TrimSpace(req.StockableKey.String()) == "" { return errors.New("stockable key and id are required") @@ -308,7 +309,7 @@ func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*St } if reductionTarget > 0 { - released, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, reductionTarget) + released, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, reductionTarget, productWarehouseID) if err != nil { return err } @@ -355,7 +356,7 @@ func (s *fifoService) ReleaseUsage(ctx context.Context, req StockReleaseRequest) } var usageDelta, pendingDelta float64 if ctxRow.UsageQty > 0 { - if _, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, ctxRow.UsageQty); err != nil { + if _, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, ctxRow.UsageQty, ctxRow.ProductWarehouseID); err != nil { return err } usageDelta -= ctxRow.UsageQty @@ -721,6 +722,7 @@ func (s *fifoService) releaseUsagePortion( usableKey fifo.UsableKey, usableID uint, target float64, + expectedWarehouseID uint, ) (float64, error) { if target <= 0 { return 0, nil @@ -736,6 +738,20 @@ func (s *fifoService) releaseUsagePortion( if len(allocations) == 0 { return 0, nil } + for i := range allocations { + alloc := &allocations[i] + if expectedWarehouseID == 0 || alloc.ProductWarehouseId == expectedWarehouseID { + continue + } + fmt.Printf("WARN[FIFO] ALLOC WAREHOUSE MISMATCH usable_key=%s usable_id=%d alloc_id=%d expected_pw=%d actual_pw=%d\n", + usableKey.String(), usableID, alloc.Id, expectedWarehouseID, alloc.ProductWarehouseId) + if err := tx.Model(&entities.StockAllocation{}). + Where("id = ?", alloc.Id). + Update("product_warehouse_id", expectedWarehouseID).Error; err != nil { + return 0, err + } + alloc.ProductWarehouseId = expectedWarehouseID + } var ( remaining = target diff --git a/internal/entities/recording.go b/internal/entities/recording.go index 0cc5dc03..1ca3deb2 100644 --- a/internal/entities/recording.go +++ b/internal/entities/recording.go @@ -12,7 +12,9 @@ type Recording struct { RecordDatetime time.Time `gorm:"column:record_datetime;not null"` Day *int `gorm:"column:day"` TotalDepletionQty *float64 `gorm:"column:total_depletion_qty"` + TotalDepletionCumQty *float64 `gorm:"-"` CumDepletionRate *float64 `gorm:"column:cum_depletion_rate"` + DepletionRate *float64 `gorm:"-"` CumIntake *int `gorm:"column:cum_intake"` FcrValue *float64 `gorm:"column:fcr_value"` TotalChickQty *float64 `gorm:"column:total_chick_qty"` diff --git a/internal/modules/closings/dto/closingMarketing.dto.go b/internal/modules/closings/dto/closingMarketing.dto.go index 189ef7cb..421a8d3d 100644 --- a/internal/modules/closings/dto/closingMarketing.dto.go +++ b/internal/modules/closings/dto/closingMarketing.dto.go @@ -122,16 +122,23 @@ func ToSalesAgeDTO(e entity.MarketingDeliveryProduct) SalesDTO { } func ToSummaryDto(e []entity.MarketingDeliveryProduct) SummaryDTO { - var totalSalesPrice, totalActualPrice, sumSales, sumActual float64 count := len(e) + if count == 0 { + return SummaryDTO{ + TotalSalesPrice: 0, + TotalActualPrice: 0, + AvgSalesPrice: 0, + AvgActualPrice: 0, + } + } + for _, item := range e { totalSalesPrice += item.MarketingProduct.TotalPrice totalActualPrice += item.TotalPrice sumSales += item.MarketingProduct.UnitPrice sumActual += item.UnitPrice - } return SummaryDTO{ diff --git a/internal/modules/closings/dto/closingSapronak.dto.go b/internal/modules/closings/dto/closingSapronak.dto.go index 92d3b2ee..30b4d945 100644 --- a/internal/modules/closings/dto/closingSapronak.dto.go +++ b/internal/modules/closings/dto/closingSapronak.dto.go @@ -234,14 +234,14 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin row.Notes = "TRANSFER STOCK" } } - case "pemakaian", "adjustment keluar": + case "pemakaian": price := row.UnitPrice if price == 0 { price = item.Harga } row.QtyUsed += item.QtyKeluar row.TotalAmount += item.QtyKeluar * price - case "mutasi keluar", "penjualan": + case "adjustment keluar", "mutasi keluar", "penjualan": price := row.UnitPrice if price == 0 { price = item.Harga diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index 04391332..cd5ce2da 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -36,6 +36,7 @@ type ClosingRepository interface { FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) FetchSapronakSales(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error) + FetchSapronakSalesAllocatedDetails(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error) GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error) } @@ -939,6 +940,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C 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"). Where("sa.status = ?", entity.StockAllocationStatusActive). + Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()). Where("f.name IN ?", sapronakFlagsAll). Where(` (sa.usable_type = ? AND r.project_flock_kandangs_id = ?) @@ -1085,12 +1087,75 @@ func splitStockLogs(rows []stockLogSapronakRow, refFn func(stockLogSapronakRow) } func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) { - rows, err := r.fetchStockLogs(ctx, kandangID, string(utils.StockLogTypeAdjustment), false) + poByWarehouse := r.DB(). + Table("purchase_items pi"). + Select("DISTINCT ON (pi.product_warehouse_id) pi.product_warehouse_id, po.po_number, pi.received_date"). + Joins("JOIN purchases po ON po.id = pi.purchase_id"). + Where("pi.received_date IS NOT NULL"). + Order("pi.product_warehouse_id, pi.received_date ASC") + + incomingQuery := r.withCtx(ctx). + Table("adjustment_stocks AS ast"). + Select(` + pw.product_id AS product_id, + p.name AS product_name, + f.name AS flag, + ast.created_at AS date, + CONCAT('ADJ-', ast.id) AS reference, + COALESCE(ast.total_qty, 0) AS qty_in, + 0 AS qty_out, + COALESCE(p.product_price, 0) AS price + `). + Joins("JOIN product_warehouses pw ON pw.id = ast.product_warehouse_id"). + Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). + Joins("JOIN products p ON p.id = pw.product_id"). + Where("w.kandang_id = ?", kandangID). + Where("f.name IN ?", sapronakFlagsAll). + Where("COALESCE(ast.total_qty, 0) > 0") + incomingQuery = r.joinSapronakProductFlag(incomingQuery, "p") + incoming, err := scanAndGroupDetails(incomingQuery) if err != nil { return nil, nil, err } - in, out := splitStockLogs(rows, func(row stockLogSapronakRow) string { return fmt.Sprintf("ADJ-%d", row.ID) }) - return in, out, nil + + outgoingQuery := r.withCtx(ctx). + Table("stock_allocations AS sa"). + Select(` + pw.product_id AS product_id, + p.name AS product_name, + f.name AS flag, + COALESCE(pi.received_date, st.transfer_date, lt.transfer_date, pfp_po.received_date, pc.chick_in_date, ast_in.created_at, ast.created_at) AS date, + COALESCE(po.po_number, st.movement_number, lt.transfer_number, pfp_po.po_number, CONCAT('CHICKIN-', pc.id), CONCAT('ADJ-', ast_in.id), CONCAT('ADJ-', ast.id)) AS reference, + 0 AS qty_in, + COALESCE(SUM(sa.qty), 0) AS qty_out, + COALESCE(p.product_price, 0) AS price + `). + Joins("JOIN adjustment_stocks ast ON ast.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyAdjustmentOut.String()). + Joins("LEFT JOIN purchase_items pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()). + Joins("LEFT JOIN purchases po ON po.id = pi.purchase_id"). + Joins("LEFT JOIN stock_transfer_details std ON std.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyStockTransferIn.String()). + Joins("LEFT JOIN stock_transfers st ON st.id = std.stock_transfer_id"). + Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()). + Joins("LEFT JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id"). + Joins("LEFT JOIN adjustment_stocks ast_in ON ast_in.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 (?) pfp_po ON pfp_po.product_warehouse_id = pfp.product_warehouse_id", poByWarehouse). + Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id"). + Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). + Joins("JOIN products p ON p.id = pw.product_id"). + Where("sa.status = ?", entity.StockAllocationStatusActive). + Where("w.kandang_id = ?", kandangID). + Where("f.name IN ?", sapronakFlagsAll). + Where("f.name NOT IN ?", sapronakFlags(utils.FlagDOC, utils.FlagPullet)). + Group("pw.product_id, p.name, f.name, pi.received_date, st.transfer_date, lt.transfer_date, pfp_po.received_date, pc.chick_in_date, ast_in.created_at, ast.created_at, po.po_number, st.movement_number, lt.transfer_number, pfp_po.po_number, pc.id, ast_in.id, ast.id, p.product_price") + outgoingQuery = r.joinSapronakProductFlag(outgoingQuery, "p") + outgoing, err := scanAndGroupDetails(outgoingQuery) + if err != nil { + return nil, nil, err + } + + return incoming, outgoing, nil } func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) { @@ -1286,6 +1351,59 @@ func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectF return sales, nil } +func (r *ClosingRepositoryImpl) FetchSapronakSalesAllocatedDetails(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error) { + if projectFlockKandangID == 0 { + return map[uint][]SapronakDetailRow{}, nil + } + + query := r.withCtx(ctx). + Table("stock_allocations AS sa"). + Select(` + pw.product_id AS product_id, + p.name AS product_name, + f.name AS flag, + COALESCE( + pi.received_date, + st.transfer_date, + lt.transfer_date, + ast.created_at + ) AS date, + COALESCE( + po.po_number, + st.movement_number, + lt.transfer_number, + CONCAT('ADJ-', ast.id), + '' + ) AS reference, + 0 AS qty_in, + COALESCE(SUM(sa.qty), 0) AS qty_out, + COALESCE(pi.price, p.product_price, 0) AS price + `). + Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id"). + Joins("JOIN products p ON p.id = pw.product_id"). + Joins("JOIN marketing_delivery_products mdp ON mdp.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyMarketingDelivery.String()). + Joins("LEFT JOIN purchase_items pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()). + Joins("LEFT JOIN purchases po ON po.id = pi.purchase_id"). + Joins("LEFT JOIN stock_transfer_details std ON std.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyStockTransferIn.String()). + Joins("LEFT JOIN stock_transfers st ON st.id = std.stock_transfer_id"). + Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()). + Joins("LEFT JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id"). + Joins("LEFT JOIN adjustment_stocks ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyAdjustmentIn.String()). + Where("sa.status = ?", entity.StockAllocationStatusActive). + Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()). + Where("pw.project_flock_kandang_id = ?", projectFlockKandangID). + Where("f.name IN ?", sapronakFlagsAll). + Group(` + pw.product_id, p.name, f.name, + pi.received_date, st.transfer_date, lt.transfer_date, ast.created_at, + po.po_number, st.movement_number, lt.transfer_number, ast.id, + pi.price, p.product_price + `) + + query = r.joinSapronakProductFlag(query, "p") + return scanAndGroupDetails(query) +} + func (r *ClosingRepositoryImpl) GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error) { if len(productIDs) == 0 { return []entity.Product{}, nil diff --git a/internal/modules/closings/services/closingKeuangan.service.go b/internal/modules/closings/services/closingKeuangan.service.go index 44137fad..804ca023 100644 --- a/internal/modules/closings/services/closingKeuangan.service.go +++ b/internal/modules/closings/services/closingKeuangan.service.go @@ -41,6 +41,7 @@ type ProductionData struct { TotalWeightProduced float64 TotalEggWeightKg float64 TotalWeightSold float64 + TotalBirdSold float64 TotalSalesAmount float64 } @@ -262,7 +263,7 @@ func (s closingKeuanganService) calculateProductionData(c *fiber.Ctx, projectFlo } if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) { - _, data.TotalEggWeightKg, err = s.HppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIdsAll(c.Context(), projectFlockKandangIDs) + _, data.TotalEggWeightKg, err = s.HppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(c.Context(), projectFlockKandangIDs, nil) if err != nil { data.TotalEggWeightKg = 0 } @@ -283,6 +284,7 @@ func (s closingKeuanganService) calculateProductionData(c *fiber.Ctx, projectFlo continue } data.TotalWeightSold += delivery.TotalWeight + data.TotalBirdSold += delivery.UsageQty data.TotalSalesAmount += delivery.TotalPrice } @@ -383,46 +385,77 @@ func (s closingKeuanganService) buildHPPSection(c *fiber.Ctx, projectFlock *enti func (s closingKeuanganService) buildProfitLossSection(projectFlock *entity.ProjectFlock, costs *CostData, production *ProductionData) dto.ProfitLossSection { - totalPopulationIn := production.TotalPopulationIn totalWeightProduced := production.TotalWeightProduced totalEggWeightKg := production.TotalEggWeightKg totalSalesAmount := production.TotalSalesAmount totalWeightSold := production.TotalWeightSold + totalBirdSold := production.TotalBirdSold + actualPopulation := production.TotalPopulationIn - production.TotalDepletion - weightForSales := totalWeightSold - weightForCalculation := totalWeightProduced - if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) { - weightForSales = totalWeightSold - weightForCalculation = totalEggWeightKg - } + isLaying := projectFlock.Category == string(utils.ProjectFlockCategoryLaying) - calculateProfitLossMetrics := func(amount float64) (rpPerBird, rpPerKg float64) { - if totalPopulationIn > 0 { - rpPerBird = amount / totalPopulationIn - } - if weightForSales > 0 { - rpPerKg = amount / weightForSales + // Fungsi untuk sales: LAYING = populasi aktual, GROWING = ekor terjual + calculateSalesMetrics := func(amount float64) (rpPerBird, rpPerKg float64) { + if isLaying { + if actualPopulation > 0 { + rpPerBird = amount / actualPopulation + } + if totalWeightSold > 0 { + rpPerKg = amount / totalWeightSold + } + } else { + if totalBirdSold > 0 { + rpPerBird = amount / totalBirdSold + } + if totalWeightSold > 0 { + rpPerKg = amount / totalWeightSold + } } return } - actualPopulation := production.TotalPopulationIn - production.TotalDepletion - - calculateMetrics := func(amount float64) (rpPerBird, rpPerKg float64) { + // Fungsi untuk cost: per ekor = populasi aktual, per kg = LAYING telur produksi / GROWING ayam produksi + calculateCostMetrics := func(amount float64) (rpPerBird, rpPerKg float64) { if actualPopulation > 0 { rpPerBird = amount / actualPopulation } - if weightForCalculation > 0 { - rpPerKg = amount / weightForCalculation + if isLaying { + if totalEggWeightKg > 0 { + rpPerKg = amount / totalEggWeightKg + } + } else { + if totalWeightProduced > 0 { + rpPerKg = amount / totalWeightProduced + } + } + return + } + + // Fungsi untuk overhead/ekspedisi: LAYING = populasi aktual, GROWING = ekor terjual + calculateOverheadMetrics := func(amount float64) (rpPerBird, rpPerKg float64) { + if isLaying { + if actualPopulation > 0 { + rpPerBird = amount / actualPopulation + } + if totalWeightSold > 0 { + rpPerKg = amount / totalWeightSold + } + } else { + if totalBirdSold > 0 { + rpPerBird = amount / totalBirdSold + } + if totalWeightSold > 0 { + rpPerKg = amount / totalWeightSold + } } return } plItems := []dto.ProfitLossItem{} - salesRpPerBird, salesRpPerKg := calculateProfitLossMetrics(totalSalesAmount) + salesRpPerBird, salesRpPerKg := calculateSalesMetrics(totalSalesAmount) salesLabel := "Penjualan Ayam" - if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) { + if isLaying { salesLabel = "Penjualan Telur" } plItems = append(plItems, dto.ToProfitLossItem( @@ -435,23 +468,23 @@ func (s closingKeuanganService) buildProfitLossSection(projectFlock *entity.Proj )) totalSapronakAmount := costs.ChickenCost + costs.FeedCost + costs.OvkCost - _, sapronakRpPerKg := calculateMetrics(totalSapronakAmount) sapronakRpPerBird := 0.0 + sapronakRpPerKg := 0.0 for _, amount := range []float64{costs.ChickenCost, costs.FeedCost, costs.OvkCost} { - rpPerBird, _ := calculateMetrics(amount) + rpPerBird, rpPerKg := calculateCostMetrics(amount) sapronakRpPerBird += rpPerBird + sapronakRpPerKg += rpPerKg } - sapronakLabel := "Pengeluaran Sapronak" plItems = append(plItems, dto.ToProfitLossItem( string(dto.PLCodeSapronak), - sapronakLabel, + "Pengeluaran Sapronak", "purchase", sapronakRpPerBird, sapronakRpPerKg, totalSapronakAmount, )) - overheadRpPerBird, overheadRpPerKg := calculateProfitLossMetrics(costs.RealizationOperational) + overheadRpPerBird, overheadRpPerKg := calculateOverheadMetrics(costs.RealizationOperational) plItems = append(plItems, dto.ToProfitLossItem( string(dto.PLCodeOverhead), "Overhead", @@ -461,7 +494,7 @@ func (s closingKeuanganService) buildProfitLossSection(projectFlock *entity.Proj costs.RealizationOperational, )) - ekspedisiRpPerBird, ekspedisiRpPerKg := calculateProfitLossMetrics(costs.ExpeditionCost) + ekspedisiRpPerBird, ekspedisiRpPerKg := calculateOverheadMetrics(costs.ExpeditionCost) plItems = append(plItems, dto.ToProfitLossItem( string(dto.PLCodeEkspedisi), "Ekspedisi", diff --git a/internal/modules/closings/services/sapronak.service.go b/internal/modules/closings/services/sapronak.service.go index 9501cfbc..4dff148b 100644 --- a/internal/modules/closings/services/sapronak.service.go +++ b/internal/modules/closings/services/sapronak.service.go @@ -363,7 +363,7 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj if err != nil { return nil, nil, 0, 0, err } - salesOutRows, err := s.Repository.FetchSapronakSales(ctx, pfk.Id) + salesOutRows, err := s.Repository.FetchSapronakSalesAllocatedDetails(ctx, pfk.Id) if err != nil { return nil, nil, 0, 0, err } @@ -570,13 +570,12 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj if existing.ProductName == "" { existing.ProductName = d.ProductName } - existing.UsageQty += d.QtyKeluar - existing.UsageValue += d.Nilai - if existing.IncomingQty >= existing.UsageQty { - existing.RemainingQty = existing.IncomingQty - existing.UsageQty - } else { - existing.RemainingQty = 0 + // Adjustment keluar should reduce stock without inflating usage-based HPP. + remaining := existing.IncomingQty - existing.UsageQty - d.QtyKeluar + if remaining < 0 { + remaining = 0 } + existing.RemainingQty = remaining itemMap[productID] = existing } } diff --git a/internal/modules/dashboards/repositories/dashboard_stats.repository.go b/internal/modules/dashboards/repositories/dashboard_stats.repository.go index 0662a0de..3c04f9a0 100644 --- a/internal/modules/dashboards/repositories/dashboard_stats.repository.go +++ b/internal/modules/dashboards/repositories/dashboard_stats.repository.go @@ -107,16 +107,23 @@ func applyDashboardFilters(db *gorm.DB, filters *validation.DashboardFilter) *go func (r *DashboardRepositoryImpl) GetRecordingWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]RecordingWeeklyMetric, error) { var rows []RecordingWeeklyMetric + weekExpr := `CASE + WHEN r.day IS NULL OR r.day <= 0 THEN 1 + WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17 + ELSE ((r.day - 1) / 7 + 1) + END` + db := r.DB().WithContext(ctx). Table("recordings AS r"). - Select(`((r.day - 1) / 7 + 1) AS week, + Select(fmt.Sprintf(`%s AS week, COALESCE(AVG(r.hen_day), 0) AS hen_day, COALESCE(AVG(r.egg_weight), 0) AS egg_weight, COALESCE(AVG(r.feed_intake), 0) AS feed_intake, COALESCE(AVG(r.fcr_value), 0) AS fcr_value, - COALESCE(AVG(r.cum_depletion_rate), 0) AS cum_depletion_rate`). + COALESCE(AVG(r.cum_depletion_rate), 0) AS cum_depletion_rate`, weekExpr)). Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id"). Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). Where("r.deleted_at IS NULL"). Where("r.day IS NOT NULL AND r.day > 0") @@ -188,92 +195,19 @@ func (r *DashboardRepositoryImpl) GetStandardFcrWeekly(ctx context.Context, week return nil, nil } - filterClause := "" - filterArgs := make([]interface{}, 0) - if filters != nil { - if len(filters.FlockIds) > 0 { - filterClause += " AND pf.id IN ?" - filterArgs = append(filterArgs, filters.FlockIds) - } - if len(filters.KandangIds) > 0 { - filterClause += " AND k.id IN ?" - filterArgs = append(filterArgs, filters.KandangIds) - } - if len(filters.LokasiIds) > 0 { - filterClause += " AND k.location_id IN ?" - filterArgs = append(filterArgs, filters.LokasiIds) - } + standardIDs := r.standardIDSubquery(filters) + if standardIDs == nil { + return nil, nil } - query := fmt.Sprintf(` -WITH src AS ( - SELECT DISTINCT pf.production_standard_id, pf.fcr_id - FROM project_flocks pf - JOIN project_flock_kandangs pfk ON pfk.project_flock_id = pf.id - JOIN kandangs k ON k.id = pfk.kandang_id - WHERE pf.production_standard_id > 0 AND pf.fcr_id > 0 - %s -), -actual AS ( - SELECT u.week AS week, - pf.fcr_id AS fcr_id, - AVG((u.chart_data->'statistics'->>'average_weight')::numeric) AS avg_weight - FROM project_flock_kandang_uniformity u - JOIN project_flock_kandangs pfk ON pfk.id = u.project_flock_kandang_id - JOIN project_flocks pf ON pf.id = pfk.project_flock_id - JOIN kandangs k ON k.id = pfk.kandang_id - WHERE u.week IN ? AND u.uniform_date IS NOT NULL AND pf.fcr_id > 0 - %s - GROUP BY u.week, pf.fcr_id -), -target AS ( - SELECT sgd.week AS week, - src.fcr_id AS fcr_id, - AVG(sgd.target_mean_bw) AS target_mean_bw - FROM standard_growth_details sgd - JOIN src ON src.production_standard_id = sgd.production_standard_id - WHERE sgd.week IN ? - GROUP BY sgd.week, src.fcr_id -), -weights AS ( - SELECT COALESCE(a.week, t.week) AS week, - COALESCE(a.fcr_id, t.fcr_id) AS fcr_id, - COALESCE( - CASE WHEN a.avg_weight > 10 THEN a.avg_weight / 1000 ELSE a.avg_weight END, - CASE WHEN t.target_mean_bw > 10 THEN t.target_mean_bw / 1000 ELSE t.target_mean_bw END - ) AS weight - FROM actual a - FULL OUTER JOIN target t ON t.week = a.week AND t.fcr_id = a.fcr_id -) -SELECT w.week AS week, - COALESCE(AVG( - COALESCE( - (SELECT fs.fcr_number - FROM fcr_standards fs - WHERE fs.fcr_id = w.fcr_id - AND fs.weight >= w.weight - ORDER BY fs.weight ASC - LIMIT 1), - (SELECT fs.fcr_number - FROM fcr_standards fs - WHERE fs.fcr_id = w.fcr_id - ORDER BY fs.weight DESC - LIMIT 1) - ) - ), 0) AS std_fcr -FROM weights w -GROUP BY w.week -ORDER BY w.week ASC -`, filterClause, filterClause) - - args := make([]interface{}, 0, len(filterArgs)*2+2) - args = append(args, filterArgs...) - args = append(args, weeks) - args = append(args, filterArgs...) - args = append(args, weeks) - var rows []StandardWeeklyFcrMetric - if err := r.DB().WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil { + db := r.DB().WithContext(ctx). + Table("production_standard_details AS psd"). + Select("psd.week AS week, COALESCE(AVG(psd.standard_fcr), 0) AS std_fcr"). + Where("psd.week IN ?", weeks). + Where("psd.production_standard_id IN (?)", standardIDs) + + if err := db.Group("psd.week").Order("psd.week ASC").Scan(&rows).Error; err != nil { return nil, err } @@ -635,13 +569,19 @@ func (r *DashboardRepositoryImpl) GetComparisonWeeklyUniformityMetrics(ctx conte func (r *DashboardRepositoryImpl) GetEggQualityWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]EggQualityWeeklyMetric, error) { var rows []EggQualityWeeklyMetric + weekExpr := `CASE + WHEN r.day IS NULL OR r.day <= 0 THEN 1 + WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17 + ELSE ((r.day - 1) / 7 + 1) + END` + db := r.DB().WithContext(ctx). Table("recording_eggs AS re"). - Select(` - ((r.day - 1) / 7 + 1) AS week, + Select(fmt.Sprintf(` + %s AS week, COALESCE(SUM(CASE WHEN f.name = ? THEN re.qty ELSE 0 END), 0) AS normal_qty, COALESCE(SUM(CASE WHEN f.name IN (?, ?, ?) THEN re.qty ELSE 0 END), 0) AS abnormal_qty, - COALESCE(SUM(re.qty), 0) AS total_qty`, + COALESCE(SUM(re.qty), 0) AS total_qty`, weekExpr), utils.FlagTelurUtuh, utils.FlagTelurPutih, utils.FlagTelurRetak, @@ -650,6 +590,7 @@ func (r *DashboardRepositoryImpl) GetEggQualityWeeklyMetrics(ctx context.Context Joins("JOIN recordings AS r ON r.id = re.recording_id"). Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id"). Joins("JOIN product_warehouses AS pw ON pw.id = re.product_warehouse_id"). Joins("JOIN products AS p ON p.id = pw.product_id"). Joins("JOIN flags AS f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). @@ -670,14 +611,21 @@ func (r *DashboardRepositoryImpl) GetEggQualityWeeklyMetrics(ctx context.Context func (r *DashboardRepositoryImpl) GetEggWeightWeeklyGrams(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]WeeklyEggWeightMetric, error) { var rows []WeeklyEggWeightMetric + weekExpr := `CASE + WHEN r.day IS NULL OR r.day <= 0 THEN 1 + WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17 + ELSE ((r.day - 1) / 7 + 1) + END` + db := r.DB().WithContext(ctx). Table("recording_eggs AS re"). - Select(` - ((r.day - 1) / 7 + 1) AS week, - COALESCE(SUM(re.weight * 1000), 0) AS egg_weight_grams`). + Select(fmt.Sprintf(` + %s AS week, + COALESCE(SUM(re.weight * 1000), 0) AS egg_weight_grams`, weekExpr)). Joins("JOIN recordings AS r ON r.id = re.recording_id"). Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id"). Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). Where("r.deleted_at IS NULL"). Where("r.day IS NOT NULL AND r.day > 0") @@ -694,15 +642,22 @@ func (r *DashboardRepositoryImpl) GetEggWeightWeeklyGrams(ctx context.Context, s func (r *DashboardRepositoryImpl) GetFeedUsageWeeklyByUom(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]WeeklyFeedUsageMetric, error) { var rows []WeeklyFeedUsageMetric + weekExpr := `CASE + WHEN r.day IS NULL OR r.day <= 0 THEN 1 + WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17 + ELSE ((r.day - 1) / 7 + 1) + END` + db := r.DB().WithContext(ctx). Table("recording_stocks AS rs"). - Select(` - ((r.day - 1) / 7 + 1) AS week, + Select(fmt.Sprintf(` + %s AS week, COALESCE(SUM(rs.usage_qty), 0) + COALESCE(SUM(rs.pending_qty), 0) AS total_qty, - LOWER(uoms.name) AS uom_name`). + LOWER(uoms.name) AS uom_name`, weekExpr)). Joins("JOIN recordings AS r ON r.id = rs.recording_id"). Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id"). Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id"). Joins("JOIN products AS p ON p.id = pw.product_id"). Joins("JOIN uoms ON uoms.id = p.uom_id"). diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index ceefcb1e..862d6991 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -160,7 +160,6 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e return fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse") } - afterQuantity := productWarehouse.Quantity newLog := &entity.StockLog{ LoggableType: string(utils.StockLogTypeAdjustment), LoggableId: 0, @@ -183,14 +182,12 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e } if transactionType == string(utils.StockLogTransactionTypeIncrease) { - afterQuantity += req.Quantity newLog.Increase = req.Quantity newLog.Stock += newLog.Increase } else { if productWarehouse.Quantity < req.Quantity { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk pengurangan. Stok saat ini: %.2f, Jumlah yang akan dikurangi: %.2f", productWarehouse.Quantity, req.Quantity)) } - afterQuantity -= req.Quantity newLog.Decrease = req.Quantity newLog.Stock -= newLog.Decrease } @@ -243,12 +240,6 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e } } - productWarehouse.Quantity = afterQuantity - if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(ctx, productWarehouse.Id, productWarehouse, nil); err != nil { - s.Log.Errorf("Failed to update product warehouse quantity: %+v", err) - return err - } - createdAdjustmentStockId = adjustmentStock.Id return nil }) diff --git a/internal/modules/inventory/product-stocks/dto/product-stock.dto.go b/internal/modules/inventory/product-stocks/dto/product-stock.dto.go index e571d2b6..be8a5b04 100644 --- a/internal/modules/inventory/product-stocks/dto/product-stock.dto.go +++ b/internal/modules/inventory/product-stocks/dto/product-stock.dto.go @@ -62,6 +62,7 @@ type StockLogDetailDTO struct { Id uint `json:"id"` Increase float64 `json:"increase"` Decrease float64 `json:"decrease"` + Stock float64 `json:"stock"` LoggableType string `json:"loggable_type"` LoggableId uint `json:"loggable_id"` Notes *string `json:"notes"` @@ -195,6 +196,7 @@ func mapStockLogs(src []entity.StockLog) []StockLogDetailDTO { Id: log.Id, Increase: log.Increase, Decrease: log.Decrease, + Stock: log.Stock, LoggableType: log.LoggableType, LoggableId: log.LoggableId, Notes: notes, diff --git a/internal/modules/marketing/module.go b/internal/modules/marketing/module.go index 2f8ea4fb..2dde163f 100644 --- a/internal/modules/marketing/module.go +++ b/internal/modules/marketing/module.go @@ -64,7 +64,7 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate warehouseRepo := rWarehouse.NewWarehouseRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) - salesOrdersService := service.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc, warehouseRepo, projectFlockKandangRepo, validate) + salesOrdersService := service.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc, fifoService, warehouseRepo, projectFlockKandangRepo, validate) deliveryOrdersService := service.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, stockLogRepo, approvalSvc, fifoService, validate) userService := sUser.NewUserService(userRepo, validate) diff --git a/internal/modules/marketing/services/deliveryorder.service.go b/internal/modules/marketing/services/deliveryorder.service.go index 51e37465..6d9392a6 100644 --- a/internal/modules/marketing/services/deliveryorder.service.go +++ b/internal/modules/marketing/services/deliveryorder.service.go @@ -10,7 +10,6 @@ import ( commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" m "gitlab.com/mbugroup/lti-api.git/internal/middleware" - productWarehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto" marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/validations" @@ -502,78 +501,33 @@ func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gor Tx: tx, }) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock for product warehouse %d: %v", marketingProduct.ProductWarehouseId, err)) + } + deliveryProductRepo := marketingRepo.NewMarketingDeliveryProductRepository(tx) - totalConsumed := 0.0 - var fifoConsumed float64 - var directConsumed float64 - - if result != nil && result.UsageQuantity > 0 { - fifoConsumed = result.UsageQuantity - totalConsumed = result.UsageQuantity - } - - if err != nil || (totalConsumed < requestedQty) { - remainder := requestedQty - totalConsumed - - pwRepo := productWarehouseRepo.NewProductWarehouseRepository(tx) - pw, err2 := pwRepo.GetByID(ctx, marketingProduct.ProductWarehouseId, nil) - if err2 != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to check product warehouse stock") - } - - if pw == nil || pw.Quantity < remainder { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock. FIFO: %.2f, Direct Available: %.2f, Total Needed: %.2f", func() float64 { - if pw != nil { - return pw.Quantity - } else { - return 0 - } - }(), remainder, requestedQty)) - } - - if err := pwRepo.AdjustQuantities(ctx, map[uint]float64{ - marketingProduct.ProductWarehouseId: -remainder, - }, func(db *gorm.DB) *gorm.DB { - return tx - }); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to adjust product warehouse quantity") - } - - directConsumed = remainder - totalConsumed += remainder - } - - if err := deliveryProductRepo.UpdateFifoFields(ctx, deliveryProduct.Id, totalConsumed, 0); err != nil { + if err := deliveryProductRepo.UpdateFifoFields(ctx, deliveryProduct.Id, result.UsageQuantity, 0); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product") } - if actorID > 0 && totalConsumed > 0 { - notes := "" - if fifoConsumed > 0 && directConsumed > 0 { - notes = fmt.Sprintf("Partial FIFO (%.2f) + Direct (%.2f)", fifoConsumed, directConsumed) - } else if fifoConsumed > 0 { - notes = fmt.Sprintf("FIFO stock only (%.2f)", fifoConsumed) - } else if directConsumed > 0 { - notes = fmt.Sprintf("Direct stock only (%.2f)", directConsumed) - } - + if actorID > 0 && result.UsageQuantity > 0 { decreaseLog := &entity.StockLog{ - Decrease: totalConsumed, + Decrease: result.UsageQuantity, LoggableType: string(utils.StockLogTypeMarketing), LoggableId: deliveryProduct.Id, ProductWarehouseId: marketingProduct.ProductWarehouseId, CreatedBy: actorID, - Notes: notes, + Notes: fmt.Sprintf("FIFO consume (%.2f)", result.UsageQuantity), } + stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, marketingProduct.ProductWarehouseId, 1) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") } if len(stockLogs) > 0 { latestStockLog := stockLogs[0] - decreaseLog.Stock = latestStockLog.Stock - decreaseLog.Stock -= decreaseLog.Decrease + decreaseLog.Stock = latestStockLog.Stock - decreaseLog.Decrease } else { decreaseLog.Stock -= decreaseLog.Decrease } @@ -610,6 +564,10 @@ func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gor return err } + if err := deliveryProductRepo.ResetFifoFields(ctx, deliveryProduct.Id); err != nil { + return err + } + if actorID > 0 && currentUsage > 0 { increaseLog := &entity.StockLog{ Increase: currentUsage, @@ -617,7 +575,7 @@ func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gor LoggableId: deliveryProduct.Id, ProductWarehouseId: marketingProduct.ProductWarehouseId, CreatedBy: actorID, - Notes: "", + Notes: fmt.Sprintf("Release delivery stock (%.2f)", currentUsage), } stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, marketingProduct.ProductWarehouseId, 1) if err != nil { @@ -625,8 +583,7 @@ func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gor } if len(stockLogs) > 0 { latestStockLog := stockLogs[0] - increaseLog.Stock = latestStockLog.Stock - increaseLog.Stock += increaseLog.Increase + increaseLog.Stock = latestStockLog.Stock + increaseLog.Increase } else { increaseLog.Stock += increaseLog.Increase } @@ -634,9 +591,5 @@ func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gor s.StockLogRepo.WithTx(tx).CreateOne(ctx, increaseLog, nil) } - if err := deliveryProductRepo.ResetFifoFields(ctx, deliveryProduct.Id); err != nil { - return err - } - return nil } diff --git a/internal/modules/marketing/services/salesorder.service.go b/internal/modules/marketing/services/salesorder.service.go index 9d950307..df75fe82 100644 --- a/internal/modules/marketing/services/salesorder.service.go +++ b/internal/modules/marketing/services/salesorder.service.go @@ -19,6 +19,7 @@ import ( userRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -41,11 +42,12 @@ type salesOrdersService struct { ProductWarehouseRepo productWarehouseRepo.ProductWarehouseRepository UserRepo userRepo.UserRepository ApprovalSvc commonSvc.ApprovalService + FifoSvc commonSvc.FifoService WarehouseRepo warehouseRepo.WarehouseRepository ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository } -func NewSalesOrdersService(marketingRepo repository.MarketingRepository, customerRepo customerRepo.CustomerRepository, productWarehouseRepo productWarehouseRepo.ProductWarehouseRepository, userRepo userRepo.UserRepository, approvalSvc commonSvc.ApprovalService, warehouseRepo warehouseRepo.WarehouseRepository, +func NewSalesOrdersService(marketingRepo repository.MarketingRepository, customerRepo customerRepo.CustomerRepository, productWarehouseRepo productWarehouseRepo.ProductWarehouseRepository, userRepo userRepo.UserRepository, approvalSvc commonSvc.ApprovalService, fifoSvc commonSvc.FifoService, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, validate *validator.Validate) SalesOrdersService { return &salesOrdersService{ Log: utils.Log, @@ -55,6 +57,7 @@ func NewSalesOrdersService(marketingRepo repository.MarketingRepository, custome ProductWarehouseRepo: productWarehouseRepo, UserRepo: userRepo, ApprovalSvc: approvalSvc, + FifoSvc: fifoSvc, WarehouseRepo: warehouseRepo, ProjectFlockKandangRepo: projectFlockKandangRepo, } @@ -230,14 +233,14 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u } if len(req.MarketingProducts) > 0 { - for _, item := range req.MarketingProducts { - if err := m.EnsureProductWarehouseAccess(c, s.MarketingRepo.DB(), item.ProductWarehouseId); err != nil { - return nil, err - } - if err := commonSvc.EnsureRelations(c.Context(), - commonSvc.RelationCheck{Name: "ProductWarehouse", ID: &item.ProductWarehouseId, Exists: s.ProductWarehouseRepo.IdExists}, - ); err != nil { - return nil, err + for _, item := range req.MarketingProducts { + if err := m.EnsureProductWarehouseAccess(c, s.MarketingRepo.DB(), item.ProductWarehouseId); err != nil { + return nil, err + } + if err := commonSvc.EnsureRelations(c.Context(), + commonSvc.RelationCheck{Name: "ProductWarehouse", ID: &item.ProductWarehouseId, Exists: s.ProductWarehouseRepo.IdExists}, + ); err != nil { + return nil, err } } } @@ -333,6 +336,32 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u totalPrice = totalWeight * rp.UnitPrice } + deliveryProduct, err := invDeliveryRepoTx.GetByMarketingProductID(c.Context(), old.Id) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to check delivery product") + } + + if err == nil && deliveryProduct.Id != 0 { + oldQty := old.Qty + newQty := rp.Qty + qtyDiff := newQty - oldQty + + if qtyDiff < 0 { + return fiber.NewError(fiber.StatusBadRequest, "Cannot decrease quantity after stock has been allocated. Please delete and create new product.") + } else if qtyDiff > 0 { + _, err := s.FifoSvc.Consume(c.Context(), commonSvc.StockConsumeRequest{ + UsableKey: fifo.UsableKeyMarketingDelivery, + UsableID: deliveryProduct.Id, + ProductWarehouseID: rp.ProductWarehouseId, + Quantity: qtyDiff, + Tx: dbTransaction, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Insufficient stock for additional quantity: %v", err)) + } + } + } + updateBody := map[string]any{ "product_warehouse_id": rp.ProductWarehouseId, "qty": rp.Qty, @@ -345,25 +374,20 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u return fiber.NewError(fiber.StatusInternalServerError, "Failed to update marketing product") } - if _, err := invDeliveryRepoTx.GetByMarketingProductID(c.Context(), old.Id); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - - mdp := &entity.MarketingDeliveryProduct{ - MarketingProductId: old.Id, - UnitPrice: 0, - TotalWeight: 0, - AvgWeight: 0, - TotalPrice: 0, - DeliveryDate: nil, - VehicleNumber: rp.VehicleNumber, - UsageQty: 0, - PendingQty: 0, - } - if err := invDeliveryRepoTx.CreateOne(c.Context(), mdp, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to create marketing delivery product") - } - } else { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to check delivery product") + if deliveryProduct.Id == 0 { + mdp := &entity.MarketingDeliveryProduct{ + MarketingProductId: old.Id, + UnitPrice: 0, + TotalWeight: 0, + AvgWeight: 0, + TotalPrice: 0, + DeliveryDate: nil, + VehicleNumber: rp.VehicleNumber, + UsageQty: 0, + PendingQty: 0, + } + if err := invDeliveryRepoTx.CreateOne(c.Context(), mdp, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create marketing delivery product") } } } else { @@ -380,10 +404,18 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusInternalServerError, "Failed to check existing delivery product") } - if err == nil { + if err == nil && deliveryProduct.Id != 0 { - if deliveryProduct.DeliveryDate != nil || deliveryProduct.UsageQty > 0 || deliveryProduct.PendingQty > 0 { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot delete marketing product %d because it has delivery records", old.Id)) + if deliveryProduct.DeliveryDate != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot delete marketing product %d because it has been delivered", old.Id)) + } + + if err := s.FifoSvc.ReleaseUsage(c.Context(), commonSvc.StockReleaseRequest{ + UsableKey: fifo.UsableKeyMarketingDelivery, + UsableID: deliveryProduct.Id, + Tx: dbTransaction, + }); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to release stock: %v", err)) } if err := invDeliveryRepoTx.DeleteOne(c.Context(), deliveryProduct.Id); err != nil { @@ -459,6 +491,19 @@ func (s salesOrdersService) DeleteOne(c *fiber.Ctx, id uint) error { marketingRepoTx := repository.NewMarketingRepository(dbTransaction) if len(marketing.Products) > 0 { + deliveryProducts, err := marketingDeliveryProductRepoTx.GetByMarketingId(c.Context(), marketing.Id) + if err == nil && len(deliveryProducts) > 0 { + for _, dp := range deliveryProducts { + if err := s.FifoSvc.ReleaseUsage(c.Context(), commonSvc.StockReleaseRequest{ + UsableKey: fifo.UsableKeyMarketingDelivery, + UsableID: dp.Id, + Tx: dbTransaction, + }); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to release stock for delivery product %d: %v", dp.Id, err)) + } + } + } + for _, product := range marketing.Products { if err := marketingDeliveryProductRepoTx.DeleteMany(c.Context(), func(db *gorm.DB) *gorm.DB { return db.Where("marketing_product_id = ?", product.Id).Unscoped() diff --git a/internal/modules/master/production-standards/services/production-standard.service.go b/internal/modules/master/production-standards/services/production-standard.service.go index 2ea95cf3..c2841708 100644 --- a/internal/modules/master/production-standards/services/production-standard.service.go +++ b/internal/modules/master/production-standards/services/production-standard.service.go @@ -152,6 +152,23 @@ func (s *productionStandardService) CreateOne(c *fiber.Ctx, req *validation.Crea if err := productionStandardDetailRepoTx.CreateOne(c.Context(), productionStandardDetail, nil); err != nil { return fmt.Errorf("failed to create production standard detail for week %d: %w", detailReq.Week, err) } + } else if req.ProjectCategory == string(utils.ProjectFlockCategoryGrowing) { + if detailReq.ProductionStandardDetails != nil && detailReq.ProductionStandardDetails.StandardFCR != nil { + var zero float64 = 0 + productionStandardDetail := &entity.ProductionStandardDetail{ + ProductionStandardId: newStandard.Id, + Week: detailReq.Week, + TargetHenDayProduction: &zero, + TargetHenHouseProduction: &zero, + TargetEggWeight: &zero, + TargetEggMass: &zero, + StandardFCR: detailReq.ProductionStandardDetails.StandardFCR, + } + + if err := productionStandardDetailRepoTx.CreateOne(c.Context(), productionStandardDetail, nil); err != nil { + return fmt.Errorf("failed to create production standard detail for week %d: %w", detailReq.Week, err) + } + } } standardGrowthDetail := &entity.StandardGrowthDetail{ @@ -265,6 +282,23 @@ func (s productionStandardService) UpdateOne(c *fiber.Ctx, req *validation.Updat if err := productionStandardDetailRepoTx.CreateOne(c.Context(), productionStandardDetail, nil); err != nil { return fmt.Errorf("failed to create production standard detail for week %d: %w", detailReq.Week, err) } + } else if projectCategory == "GROWING" { + if detailReq.ProductionStandardDetails != nil && detailReq.ProductionStandardDetails.StandardFCR != nil { + var zero float64 = 0 + productionStandardDetail := &entity.ProductionStandardDetail{ + ProductionStandardId: id, + Week: detailReq.Week, + TargetHenDayProduction: &zero, + TargetHenHouseProduction: &zero, + TargetEggWeight: &zero, + TargetEggMass: &zero, + StandardFCR: detailReq.ProductionStandardDetails.StandardFCR, + } + + if err := productionStandardDetailRepoTx.CreateOne(c.Context(), productionStandardDetail, nil); err != nil { + return fmt.Errorf("failed to create production standard detail for week %d: %w", detailReq.Week, err) + } + } } standardGrowthDetail := &entity.StandardGrowthDetail{ diff --git a/internal/modules/production/recordings/dto/recording.dto.go b/internal/modules/production/recordings/dto/recording.dto.go index 0fa14e97..191b9676 100644 --- a/internal/modules/production/recordings/dto/recording.dto.go +++ b/internal/modules/production/recordings/dto/recording.dto.go @@ -1,6 +1,7 @@ package dto import ( + "math" "strings" "time" @@ -73,7 +74,9 @@ type RecordingRelationDTO struct { RecordDatetime time.Time `json:"record_datetime"` Day int `json:"day"` TotalDepletionQty float64 `json:"total_depletion_qty"` + TotalDepletionCumQty float64 `json:"total_depletion_cum_qty"` CumDepletionRate float64 `json:"cum_depletion_rate"` + DepletionRate float64 `json:"depletion_rate"` CumIntake int `json:"cum_intake"` FcrValue float64 `json:"fcr_value"` HenDay float64 `json:"hen_day"` @@ -230,7 +233,9 @@ func toRecordingRelationDTO(e entity.Recording) RecordingRelationDTO { RecordDatetime: e.RecordDatetime, Day: intValue(e.Day), TotalDepletionQty: floatValue(e.TotalDepletionQty), - CumDepletionRate: floatValue(e.CumDepletionRate), + TotalDepletionCumQty: floatValue(e.TotalDepletionCumQty), + CumDepletionRate: roundFloatValue(e.CumDepletionRate, 2), + DepletionRate: roundFloatValue(e.DepletionRate, 2), CumIntake: intValue(e.CumIntake), FcrValue: floatValue(e.FcrValue), HenDay: floatValue(e.HenDay), @@ -426,6 +431,17 @@ func floatValue(value *float64) float64 { return *value } +func roundFloatValue(value *float64, places int) float64 { + if value == nil { + return 0 + } + if places <= 0 { + return math.Round(*value) + } + factor := math.Pow(10, float64(places)) + return math.Round(*value*factor) / factor +} + func intValue(value *int) int { if value == nil { return 0 diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index 27c399f4..ce4dc0df 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -39,6 +39,7 @@ type RecordingRepository interface { ExistsOnDate(ctx context.Context, projectFlockKandangId uint, recordTime time.Time) (bool, error) SumRecordingDepletions(tx *gorm.DB, recordingID uint) (float64, error) + GetCumulativeDepletionByProjectFlockKandangUntil(tx *gorm.DB, projectFlockKandangId uint, recordTime time.Time) (float64, error) FindPreviousRecording(tx *gorm.DB, projectFlockKandangId uint, currentDay int) (*entity.Recording, error) GetTotalChick(tx *gorm.DB, projectFlockKandangId uint) (int64, error) GetTotalChickinByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint) (float64, error) @@ -314,6 +315,23 @@ func (r *RecordingRepositoryImpl) SumRecordingDepletions(tx *gorm.DB, recordingI return result, nil } +func (r *RecordingRepositoryImpl) GetCumulativeDepletionByProjectFlockKandangUntil(tx *gorm.DB, projectFlockKandangId uint, recordTime time.Time) (float64, error) { + if projectFlockKandangId == 0 || recordTime.IsZero() { + return 0, nil + } + + var total float64 + err := tx. + Table("recording_depletions rd"). + Select("COALESCE(SUM(rd.qty),0)"). + Joins("JOIN recordings r ON r.id = rd.recording_id"). + Where("r.project_flock_kandangs_id = ?", projectFlockKandangId). + Where("r.record_datetime <= ?", recordTime). + Where("r.deleted_at IS NULL"). + Scan(&total).Error + return total, err +} + func (r *RecordingRepositoryImpl) FindPreviousRecording(tx *gorm.DB, projectFlockKandangId uint, currentDay int) (*entity.Recording, error) { if currentDay <= 1 { return nil, nil diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 7a63d5da..28329041 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -21,7 +21,6 @@ import ( rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" - "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" recordingutil "gitlab.com/mbugroup/lti-api.git/internal/utils/recording" "github.com/go-playground/validator/v10" @@ -40,14 +39,6 @@ type RecordingService interface { Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.Recording, error) } -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 - type recordingService struct { Log *logrus.Logger Validate *validator.Validate @@ -89,21 +80,6 @@ func NewRecordingService( } } -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, - } -} - func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Recording, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err @@ -152,6 +128,12 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti if err := s.attachProductionStandards(c.Context(), recordings); err != nil { return nil, 0, err } + if err := s.attachCumulativeDepletions(c.Context(), recordings); err != nil { + return nil, 0, err + } + if err := s.attachDepletionRates(c.Context(), recordings); err != nil { + return nil, 0, err + } return recordings, total, nil } @@ -176,6 +158,12 @@ func (s recordingService) GetOne(c *fiber.Ctx, id uint) (*entity.Recording, erro if err := s.attachProductionStandard(c.Context(), 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 + } return recording, nil } @@ -347,7 +335,12 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent } var warehouseDeltas map[uint]float64 - warehouseDeltas = buildWarehouseDeltas(nil, mappedDepletions, nil, mappedEggs) + 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) return err @@ -529,39 +522,9 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin if err := ensureRecordingEggsUnused(existingEggs); err != nil { return err } - if s.StockLogRepo != nil { - note := fmt.Sprintf("Recording-Edit#%d", recordingEntity.Id) - logs := make([]*entity.StockLog, 0, len(existingEggs)) - for _, egg := range existingEggs { - if egg.ProductWarehouseId == 0 || egg.Qty <= 0 { - continue - } - stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, egg.ProductWarehouseId, 1) - if err != nil { - s.Log.Errorf("Failed to get stock logs: %+v", err) - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") - } - latestStockLog := &entity.StockLog{} - if len(stockLogs) > 0 { - latestStockLog = stockLogs[0] - } else { - latestStockLog.Stock = 0 - } - logs = append(logs, &entity.StockLog{ - ProductWarehouseId: egg.ProductWarehouseId, - CreatedBy: actorID, - Decrease: float64(egg.Qty), - LoggableType: string(utils.StockLogTypeRecording), - LoggableId: recordingEntity.Id, - Notes: note, - Stock: latestStockLog.Stock - float64(egg.Qty), - }) - } - if len(logs) > 0 { - if err := s.StockLogRepo.WithTx(tx).CreateMany(ctx, logs, nil); 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) @@ -818,40 +781,6 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { }) } -func (s *recordingService) logRecordingEggRollback( - ctx context.Context, - tx *gorm.DB, - eggs []entity.RecordingEgg, - note string, - actorID uint, -) error { - if len(eggs) == 0 || s.StockLogRepo == nil { - return nil - } - if strings.TrimSpace(note) == "" || actorID == 0 { - return nil - } - - for _, egg := range eggs { - if egg.ProductWarehouseId == 0 || egg.Qty <= 0 { - continue - } - log := &entity.StockLog{ - ProductWarehouseId: egg.ProductWarehouseId, - CreatedBy: actorID, - Decrease: float64(egg.Qty), - LoggableType: string(utils.StockLogTypeRecording), - LoggableId: egg.RecordingId, - Notes: note, - } - if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); 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 { @@ -891,381 +820,6 @@ func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []v return nil } -func (s *recordingService) consumeRecordingStocks( - ctx context.Context, - tx *gorm.DB, - stocks []entity.RecordingStock, - note string, - actorID uint, -) error { - if len(stocks) == 0 || s.FifoSvc == nil { - return nil - } - if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { - return errors.New("stock log repository is not available") - } - - for _, stock := range stocks { - if stock.Id == 0 { - continue - } - - var desired float64 - if stock.UsageQty != nil { - desired = *stock.UsageQty - } - var pending float64 - if stock.PendingQty != nil { - pending = *stock.PendingQty - } - desiredTotal := desired + pending - - result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ - UsableKey: recordingStockUsableKey, - UsableID: stock.Id, - ProductWarehouseID: stock.ProductWarehouseId, - Quantity: desiredTotal, - AllowPending: true, - Tx: tx, - }) - if err != nil { - s.Log.Errorf("Failed to consume FIFO stock for recording stock %d: %+v", stock.Id, err) - return err - } - - if err := s.Repository.UpdateStockUsage(tx, stock.Id, result.UsageQuantity, result.PendingQuantity); err != nil { - return err - } - - logDecrease := result.UsageQuantity - if result.PendingQuantity > 0 { - logDecrease += result.PendingQuantity - } - if logDecrease > 0 && strings.TrimSpace(note) != "" && actorID != 0 { - log := &entity.StockLog{ - ProductWarehouseId: stock.ProductWarehouseId, - CreatedBy: actorID, - Decrease: logDecrease, - LoggableType: string(utils.StockLogTypeRecording), - LoggableId: stock.RecordingId, - Notes: note, - } - stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, stock.ProductWarehouseId, 1) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") - } - if len(stockLogs) > 0 { - latestStockLog := stockLogs[0] - log.Stock = latestStockLog.Stock - log.Stock -= log.Decrease - } else { - log.Stock -= log.Decrease - } - - if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { - return err - } - } - } - - return nil -} - -func (s *recordingService) consumeRecordingDepletions( - ctx context.Context, - tx *gorm.DB, - depletions []entity.RecordingDepletion, - note string, - actorID uint, -) error { - if len(depletions) == 0 || s.FifoSvc == nil { - return nil - } - if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { - return errors.New("stock log repository is not available") - } - - for _, depletion := range depletions { - if depletion.Id == 0 { - continue - } - - sourceWarehouseID := uint(0) - if depletion.SourceProductWarehouseId != nil { - sourceWarehouseID = *depletion.SourceProductWarehouseId - } - if sourceWarehouseID == 0 { - return fiber.NewError(fiber.StatusBadRequest, "Source product warehouse tidak ditemukan untuk depletion") - } - - desired := depletion.Qty + depletion.PendingQty - result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ - UsableKey: recordingDepletionUsableKey, - UsableID: depletion.Id, - ProductWarehouseID: sourceWarehouseID, - Quantity: desired, - AllowPending: false, - Tx: tx, - }) - if err != nil { - s.Log.Errorf("Failed to consume FIFO stock for recording depletion %d: %+v", depletion.Id, err) - return err - } - - if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, result.PendingQuantity); err != nil { - return err - } - - logDecrease := result.UsageQuantity - if result.PendingQuantity > 0 { - logDecrease += result.PendingQuantity - } - if logDecrease > 0 && strings.TrimSpace(note) != "" && actorID != 0 { - log := &entity.StockLog{ - ProductWarehouseId: sourceWarehouseID, - CreatedBy: actorID, - Decrease: logDecrease, - LoggableType: string(utils.StockLogTypeRecording), - LoggableId: depletion.RecordingId, - Notes: note, - } - stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, sourceWarehouseID, 1) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") - } - if len(stockLogs) > 0 { - latestStockLog := stockLogs[0] - log.Stock = latestStockLog.Stock - log.Stock -= log.Decrease - } else { - log.Stock -= log.Decrease - } - - if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { - return err - } - } - - destDelta := depletion.Qty + depletion.PendingQty - if depletion.ProductWarehouseId != 0 && destDelta > 0 && strings.TrimSpace(note) != "" && actorID != 0 { - if depletion.ProductWarehouseId == sourceWarehouseID { - continue - } - log := &entity.StockLog{ - ProductWarehouseId: depletion.ProductWarehouseId, - CreatedBy: actorID, - Increase: destDelta, - LoggableType: string(utils.StockLogTypeRecording), - LoggableId: depletion.RecordingId, - Notes: note, - } - stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, depletion.ProductWarehouseId, 1) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") - } - if len(stockLogs) > 0 { - latestStockLog := stockLogs[0] - log.Stock = latestStockLog.Stock - log.Stock += log.Increase - } else { - log.Stock += log.Increase - } - - if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { - return err - } - } - } - - 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, - stocks []entity.RecordingStock, - note string, - actorID uint, -) error { - if len(stocks) == 0 || s.FifoSvc == nil { - return nil - } - if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { - return errors.New("stock log repository is not available") - } - - for _, stock := range stocks { - if stock.Id == 0 { - continue - } - - if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ - UsableKey: recordingStockUsableKey, - UsableID: stock.Id, - Tx: tx, - }); err != nil { - s.Log.Errorf("Failed to release FIFO stock for recording stock %d: %+v", stock.Id, err) - return err - } - - if err := s.Repository.UpdateStockUsage(tx, stock.Id, 0, 0); err != nil { - return err - } - - if stock.UsageQty != nil && *stock.UsageQty > 0 && strings.TrimSpace(note) != "" && actorID != 0 { - log := &entity.StockLog{ - ProductWarehouseId: stock.ProductWarehouseId, - CreatedBy: actorID, - Increase: *stock.UsageQty, - LoggableType: string(utils.StockLogTypeRecording), - LoggableId: stock.RecordingId, - Notes: note, - } - stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, stock.ProductWarehouseId, 1) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") - } - if len(stockLogs) > 0 { - latestStockLog := stockLogs[0] - log.Stock = latestStockLog.Stock - log.Stock += log.Increase - } else { - log.Stock += log.Increase - } - - if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { - return err - } - } - } - - return nil -} - -func (s *recordingService) releaseRecordingDepletions( - ctx context.Context, - tx *gorm.DB, - depletions []entity.RecordingDepletion, - note string, - actorID uint, -) error { - if len(depletions) == 0 || s.FifoSvc == nil { - return nil - } - if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { - return errors.New("stock log repository is not available") - } - - for _, depletion := range depletions { - if depletion.Id == 0 { - continue - } - - sourceWarehouseID := uint(0) - if depletion.SourceProductWarehouseId != nil { - sourceWarehouseID = *depletion.SourceProductWarehouseId - } - if sourceWarehouseID == 0 { - return fiber.NewError(fiber.StatusBadRequest, "Source product warehouse tidak ditemukan untuk depletion") - } - - if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ - UsableKey: recordingDepletionUsableKey, - UsableID: depletion.Id, - Tx: tx, - }); err != nil { - s.Log.Errorf("Failed to release FIFO stock for recording depletion %d: %+v", depletion.Id, err) - return err - } - - if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, 0); err != nil { - return err - } - - logIncrease := depletion.Qty - if depletion.PendingQty > 0 { - logIncrease += depletion.PendingQty - } - if logIncrease > 0 && strings.TrimSpace(note) != "" && actorID != 0 { - log := &entity.StockLog{ - ProductWarehouseId: sourceWarehouseID, - CreatedBy: actorID, - Increase: logIncrease, - LoggableType: string(utils.StockLogTypeRecording), - LoggableId: depletion.RecordingId, - Notes: note, - } - stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, sourceWarehouseID, 1) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") - } - if len(stockLogs) > 0 { - latestStockLog := stockLogs[0] - log.Stock = latestStockLog.Stock - log.Stock += log.Increase - } else { - log.Stock += log.Increase - } - - if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { - return err - } - } - - destDelta := depletion.Qty + depletion.PendingQty - if depletion.ProductWarehouseId != 0 && destDelta > 0 && strings.TrimSpace(note) != "" && actorID != 0 { - if depletion.ProductWarehouseId == sourceWarehouseID { - continue - } - log := &entity.StockLog{ - ProductWarehouseId: depletion.ProductWarehouseId, - CreatedBy: actorID, - Decrease: destDelta, - LoggableType: string(utils.StockLogTypeRecording), - LoggableId: depletion.RecordingId, - Notes: note, - } - stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, depletion.ProductWarehouseId, 1) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") - } - if len(stockLogs) > 0 { - latestStockLog := stockLogs[0] - log.Stock = latestStockLog.Stock - log.Stock -= log.Decrease - } else { - log.Stock -= log.Decrease - } - - if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { - return err - } - } - } - - 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 (s *recordingService) resolvePopulationWarehouseID(ctx context.Context, projectFlockKandangID uint) (uint, error) { if projectFlockKandangID == 0 { return 0, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid") @@ -1356,212 +910,6 @@ func (s *recordingService) adjustProductWarehouseQuantities(ctx context.Context, return s.ProductWarehouseRepo.AdjustQuantities(ctx, deltas, func(*gorm.DB) *gorm.DB { return tx }) } -func (s *recordingService) replenishRecordingEggs( - ctx context.Context, - tx *gorm.DB, - eggs []entity.RecordingEgg, - note string, - actorID uint, -) error { - if len(eggs) == 0 || s.FifoSvc == nil { - return nil - } - if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { - return errors.New("stock log repository is not available") - } - - for _, egg := range eggs { - if egg.Id == 0 || egg.ProductWarehouseId == 0 || egg.Qty <= 0 { - continue - } - if _, err := s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ - StockableKey: fifo.StockableKeyRecordingEgg, - StockableID: egg.Id, - ProductWarehouseID: egg.ProductWarehouseId, - Quantity: float64(egg.Qty), - Tx: tx, - }); err != nil { - s.Log.Errorf("Failed to replenish FIFO stock for recording egg %d: %+v", egg.Id, err) - return err - } - - if strings.TrimSpace(note) != "" && actorID != 0 { - log := &entity.StockLog{ - ProductWarehouseId: egg.ProductWarehouseId, - CreatedBy: actorID, - Increase: float64(egg.Qty), - LoggableType: string(utils.StockLogTypeRecording), - LoggableId: egg.RecordingId, - Notes: note, - } - stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, egg.ProductWarehouseId, 1) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") - } - if len(stockLogs) > 0 { - latestStockLog := stockLogs[0] - log.Stock = latestStockLog.Stock - log.Stock += log.Increase - } else { - log.Stock += log.Increase - } - - if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { - return err - } - } - } - - return nil -} - -type desiredStock struct { - Usage float64 - Pending float64 -} - -type desiredDepletion struct { - Qty float64 - Pending float64 -} - -func resetStockQuantitiesForFIFO(stocks []entity.RecordingStock, enabled bool) []desiredStock { - desired := make([]desiredStock, len(stocks)) - for i := range stocks { - if stocks[i].UsageQty != nil { - desired[i].Usage = *stocks[i].UsageQty - } - 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 - } - 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 { - 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].PendingQty = 0 - } - return desired -} - -func applyDepletionDesiredQuantities(depletions []entity.RecordingDepletion, desired []desiredDepletion, enabled bool) { - if !enabled { - return - } - for i := range depletions { - if i >= len(desired) { - break - } - depletions[i].Qty = desired[i].Qty - depletions[i].PendingQty = desired[i].Pending - } -} - -func (s *recordingService) syncRecordingStocks( - ctx context.Context, - tx *gorm.DB, - recordingID uint, - existing []entity.RecordingStock, - incoming []validation.Stock, - note string, - 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) - } - - existingByWarehouse := make(map[uint][]entity.RecordingStock) - for _, stock := range existing { - existingByWarehouse[stock.ProductWarehouseId] = append(existingByWarehouse[stock.ProductWarehouseId], stock) - } - - stocksToConsume := make([]entity.RecordingStock, 0, len(incoming)) - for _, item := range incoming { - list := existingByWarehouse[item.ProductWarehouseId] - var stock entity.RecordingStock - if len(list) > 0 { - stock = list[0] - existingByWarehouse[item.ProductWarehouseId] = list[1:] - } else { - zero := 0.0 - stock = entity.RecordingStock{ - RecordingId: recordingID, - ProductWarehouseId: item.ProductWarehouseId, - UsageQty: &zero, - PendingQty: &zero, - } - if err := tx.Create(&stock).Error; err != nil { - return err - } - } - - desired := item.Qty - stock.UsageQty = &desired - zero := 0.0 - stock.PendingQty = &zero - stocksToConsume = append(stocksToConsume, stock) - } - - var leftovers []entity.RecordingStock - for _, list := range existingByWarehouse { - leftovers = append(leftovers, list...) - } - if len(leftovers) > 0 { - if err := s.releaseRecordingStocks(ctx, tx, leftovers, note, actorID); err != nil { - return err - } - ids := make([]uint, 0, len(leftovers)) - for _, stock := range leftovers { - if stock.Id != 0 { - ids = append(ids, stock.Id) - } - } - if len(ids) > 0 { - if err := tx.Where("id IN ?", ids).Delete(&entity.RecordingStock{}).Error; err != nil { - return err - } - } - } - - if len(stocksToConsume) == 0 { - return nil - } - return s.consumeRecordingStocks(ctx, tx, stocksToConsume, note, actorID) -} - type eggTotals struct { Qty int Weight float64 @@ -1690,12 +1038,8 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm return fmt.Errorf("getPreviousRecording: %w", err) } - var prevCumDepletionQty float64 var prevCumIntake float64 if prevRecording != nil { - if prevRecording.TotalDepletionQty != nil { - prevCumDepletionQty = *prevRecording.TotalDepletionQty - } if prevRecording.CumIntake != nil { prevCumIntake = float64(*prevRecording.CumIntake) } @@ -1727,29 +1071,51 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm } currentDepletion := float64(totalDepletionQty) - cumDepletionQty := prevCumDepletionQty + currentDepletion + cumDepletionQty, err := s.Repository.GetCumulativeDepletionByProjectFlockKandangUntil(tx, recording.ProjectFlockKandangId, recording.RecordDatetime) + if err != nil { + return fmt.Errorf("getCumulativeDepletionByProjectFlockKandangUntil: %w", err) + } updates := map[string]any{ - "total_depletion_qty": cumDepletionQty, + "total_depletion_qty": currentDepletion, } - recording.TotalDepletionQty = &cumDepletionQty + recording.TotalDepletionQty = ¤tDepletion + recording.TotalDepletionCumQty = &cumDepletionQty var remainingChick float64 if totalChick > 0 { totalChickFloat := float64(totalChick) - remainingChick = totalChickFloat - cumDepletionQty - if remainingChick < 0 { - remainingChick = 0 - } - updates["total_chick_qty"] = remainingChick - recording.TotalChickQty = &remainingChick + if s.FifoSvc != nil { + // totalChick already represents available qty (total_qty - total_used_qty). + remainingChick = totalChickFloat + updates["total_chick_qty"] = remainingChick + recording.TotalChickQty = &remainingChick - cumRate := 0.0 - if totalChickFloat > 0 { - cumRate = (cumDepletionQty / totalChickFloat) * 100 + 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 } - updates["cum_depletion_rate"] = cumRate - recording.CumDepletionRate = &cumRate } else { updates["total_chick_qty"] = gorm.Expr("NULL") updates["cum_depletion_rate"] = gorm.Expr("NULL") @@ -1757,6 +1123,9 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm recording.CumDepletionRate = nil } + depletionRate := computeDepletionRate(prevRecording, currentDepletion, totalChick) + recording.DepletionRate = &depletionRate + var feedIntake float64 if remainingChick > 0 && usageInGrams > 0 { feedIntake = (usageInGrams / remainingChick) * 1000 @@ -1847,6 +1216,81 @@ 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, @@ -1999,16 +1443,17 @@ func (s *recordingService) attachProductionStandard(ctx context.Context, item *e var standard productionStandardValues var standardFcr *float64 - if category == string(utils.ProjectFlockCategoryLaying) { - 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 + 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 } } @@ -2019,21 +1464,6 @@ func (s *recordingService) attachProductionStandard(ctx context.Context, item *e if growthDetail != nil { standard.FeedIntake = growthDetail.FeedIntake standard.MaxDepletion = growthDetail.MaxDepletion - if category == string(utils.ProjectFlockCategoryLaying) && growthDetail.TargetMeanBw != nil && item.ProjectFlockKandang.ProjectFlock.FcrId > 0 { - targetWeight := *growthDetail.TargetMeanBw - if targetWeight > 10 { - targetWeight = targetWeight / 1000 - } - if targetWeight > 0 { - fcrStd, ok, err := s.Repository.GetFcrStandardNumber(db, item.ProjectFlockKandang.ProjectFlock.FcrId, targetWeight) - if err != nil { - return err - } - if ok { - standardFcr = &fcrStd - } - } - } } item.StandardHenDay = standard.HenDay diff --git a/internal/modules/production/recordings/services/recording_fifo.service.go b/internal/modules/production/recordings/services/recording_fifo.service.go new file mode 100644 index 00000000..375b75ce --- /dev/null +++ b/internal/modules/production/recordings/services/recording_fifo.service.go @@ -0,0 +1,703 @@ +package service + +import ( + "context" + "errors" + "strings" + + 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, + } +} + +func (s *recordingService) consumeRecordingStocks( + ctx context.Context, + tx *gorm.DB, + stocks []entity.RecordingStock, + note string, + actorID uint, +) error { + if len(stocks) == 0 || s.FifoSvc == nil { + return nil + } + if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { + return errors.New("stock log repository is not available") + } + + for _, stock := range stocks { + if stock.Id == 0 { + continue + } + + var desired float64 + if stock.UsageQty != nil { + desired = *stock.UsageQty + } + var pending float64 + if stock.PendingQty != nil { + pending = *stock.PendingQty + } + desiredTotal := desired + pending + + result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ + UsableKey: recordingStockUsableKey, + UsableID: stock.Id, + ProductWarehouseID: stock.ProductWarehouseId, + Quantity: desiredTotal, + AllowPending: true, + Tx: tx, + }) + if err != nil { + s.Log.Errorf("Failed to consume FIFO stock for recording stock %d: %+v", stock.Id, err) + return err + } + + if err := s.Repository.UpdateStockUsage(tx, stock.Id, result.UsageQuantity, result.PendingQuantity); err != nil { + return err + } + + logDecrease := result.UsageQuantity + if result.PendingQuantity > 0 { + logDecrease += result.PendingQuantity + } + if logDecrease > 0 && strings.TrimSpace(note) != "" && actorID != 0 { + log := &entity.StockLog{ + ProductWarehouseId: stock.ProductWarehouseId, + CreatedBy: actorID, + Decrease: logDecrease, + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: stock.RecordingId, + Notes: note, + } + stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, stock.ProductWarehouseId, 1) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") + } + if len(stockLogs) > 0 { + latestStockLog := stockLogs[0] + log.Stock = latestStockLog.Stock + log.Stock -= log.Decrease + } else { + log.Stock -= log.Decrease + } + + if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { + return err + } + } + } + + return nil +} + +func (s *recordingService) consumeRecordingDepletions( + ctx context.Context, + tx *gorm.DB, + depletions []entity.RecordingDepletion, + note string, + actorID uint, +) error { + if len(depletions) == 0 || s.FifoSvc == nil { + return nil + } + if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { + return errors.New("stock log repository is not available") + } + + for _, depletion := range depletions { + if depletion.Id == 0 { + continue + } + + sourceWarehouseID := uint(0) + if depletion.SourceProductWarehouseId != nil { + sourceWarehouseID = *depletion.SourceProductWarehouseId + } + if sourceWarehouseID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Source product warehouse tidak ditemukan untuk depletion") + } + + desired := depletion.Qty + depletion.PendingQty + result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ + UsableKey: recordingDepletionUsableKey, + UsableID: depletion.Id, + ProductWarehouseID: sourceWarehouseID, + Quantity: desired, + AllowPending: false, + Tx: tx, + }) + if err != nil { + s.Log.Errorf("Failed to consume FIFO stock for recording depletion %d: %+v", depletion.Id, err) + return err + } + + if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, result.PendingQuantity); err != nil { + return err + } + + logDecrease := result.UsageQuantity + if result.PendingQuantity > 0 { + logDecrease += result.PendingQuantity + } + if logDecrease > 0 && strings.TrimSpace(note) != "" && actorID != 0 { + log := &entity.StockLog{ + ProductWarehouseId: sourceWarehouseID, + CreatedBy: actorID, + Decrease: logDecrease, + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: depletion.RecordingId, + Notes: note, + } + stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, sourceWarehouseID, 1) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") + } + if len(stockLogs) > 0 { + latestStockLog := stockLogs[0] + log.Stock = latestStockLog.Stock + log.Stock -= log.Decrease + } else { + log.Stock -= log.Decrease + } + + if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { + return err + } + } + + destDelta := depletion.Qty + depletion.PendingQty + if depletion.ProductWarehouseId != 0 && destDelta > 0 && strings.TrimSpace(note) != "" && actorID != 0 { + if depletion.ProductWarehouseId == sourceWarehouseID { + continue + } + log := &entity.StockLog{ + ProductWarehouseId: depletion.ProductWarehouseId, + CreatedBy: actorID, + Increase: destDelta, + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: depletion.RecordingId, + Notes: note, + } + stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, depletion.ProductWarehouseId, 1) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") + } + if len(stockLogs) > 0 { + latestStockLog := stockLogs[0] + log.Stock = latestStockLog.Stock + log.Stock += log.Increase + } else { + log.Stock += log.Increase + } + + if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { + return err + } + } + } + + 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, + stocks []entity.RecordingStock, + note string, + actorID uint, +) error { + if len(stocks) == 0 || s.FifoSvc == nil { + return nil + } + if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { + return errors.New("stock log repository is not available") + } + + for _, stock := range stocks { + if stock.Id == 0 { + continue + } + if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ + UsableKey: recordingStockUsableKey, + UsableID: stock.Id, + Tx: tx, + }); err != nil { + s.Log.Errorf("Failed to release FIFO stock for recording stock %d: %+v", stock.Id, err) + return err + } + + if err := s.Repository.UpdateStockUsage(tx, stock.Id, 0, 0); err != nil { + return err + } + + if stock.UsageQty != nil && *stock.UsageQty > 0 && strings.TrimSpace(note) != "" && actorID != 0 { + log := &entity.StockLog{ + ProductWarehouseId: stock.ProductWarehouseId, + CreatedBy: actorID, + Increase: *stock.UsageQty, + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: stock.RecordingId, + Notes: note, + } + stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, stock.ProductWarehouseId, 1) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") + } + if len(stockLogs) > 0 { + latestStockLog := stockLogs[0] + log.Stock = latestStockLog.Stock + log.Stock += log.Increase + } else { + log.Stock += log.Increase + } + + if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { + return err + } + } + } + + return nil +} + +func (s *recordingService) releaseRecordingDepletions( + ctx context.Context, + tx *gorm.DB, + depletions []entity.RecordingDepletion, + note string, + actorID uint, +) error { + if len(depletions) == 0 || s.FifoSvc == nil { + return nil + } + if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { + return errors.New("stock log repository is not available") + } + + for _, depletion := range depletions { + if depletion.Id == 0 { + continue + } + + sourceWarehouseID := uint(0) + if depletion.SourceProductWarehouseId != nil { + sourceWarehouseID = *depletion.SourceProductWarehouseId + } + if sourceWarehouseID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Source product warehouse tidak ditemukan untuk depletion") + } + if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ + UsableKey: recordingDepletionUsableKey, + UsableID: depletion.Id, + Tx: tx, + }); err != nil { + s.Log.Errorf("Failed to release FIFO stock for recording depletion %d: %+v", depletion.Id, err) + return err + } + + if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, 0); err != nil { + return err + } + + logIncrease := depletion.Qty + if depletion.PendingQty > 0 { + logIncrease += depletion.PendingQty + } + if logIncrease > 0 && strings.TrimSpace(note) != "" && actorID != 0 { + log := &entity.StockLog{ + ProductWarehouseId: sourceWarehouseID, + CreatedBy: actorID, + Increase: logIncrease, + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: depletion.RecordingId, + Notes: note, + } + stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, sourceWarehouseID, 1) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") + } + if len(stockLogs) > 0 { + latestStockLog := stockLogs[0] + log.Stock = latestStockLog.Stock + log.Stock += log.Increase + } else { + log.Stock += log.Increase + } + + if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { + return err + } + } + + destDelta := depletion.Qty + depletion.PendingQty + if depletion.ProductWarehouseId != 0 && destDelta > 0 && strings.TrimSpace(note) != "" && actorID != 0 { + if depletion.ProductWarehouseId == sourceWarehouseID { + continue + } + log := &entity.StockLog{ + ProductWarehouseId: depletion.ProductWarehouseId, + CreatedBy: actorID, + Decrease: destDelta, + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: depletion.RecordingId, + Notes: note, + } + stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, depletion.ProductWarehouseId, 1) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") + } + if len(stockLogs) > 0 { + latestStockLog := stockLogs[0] + log.Stock = latestStockLog.Stock + log.Stock -= log.Decrease + } else { + log.Stock -= log.Decrease + } + + if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { + return err + } + } + } + + 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 (s *recordingService) logRecordingEggUsage( + ctx context.Context, + tx *gorm.DB, + eggs []entity.RecordingEgg, + note string, + actorID uint, +) error { + if len(eggs) == 0 || s.StockLogRepo == nil { + return nil + } + if strings.TrimSpace(note) == "" || actorID == 0 { + return nil + } + + logs := make([]*entity.StockLog, 0, len(eggs)) + for _, egg := range eggs { + if egg.ProductWarehouseId == 0 || egg.Qty <= 0 { + continue + } + stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, egg.ProductWarehouseId, 1) + if err != nil { + s.Log.Errorf("Failed to get stock logs: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") + } + latestStockLog := &entity.StockLog{} + if len(stockLogs) > 0 { + latestStockLog = stockLogs[0] + } else { + latestStockLog.Stock = 0 + } + logs = append(logs, &entity.StockLog{ + ProductWarehouseId: egg.ProductWarehouseId, + CreatedBy: actorID, + Decrease: float64(egg.Qty), + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: egg.RecordingId, + Notes: note, + Stock: latestStockLog.Stock - float64(egg.Qty), + }) + } + if len(logs) == 0 { + return nil + } + + return s.StockLogRepo.WithTx(tx).CreateMany(ctx, logs, nil) +} + +func (s *recordingService) logRecordingEggRollback( + ctx context.Context, + tx *gorm.DB, + eggs []entity.RecordingEgg, + note string, + actorID uint, +) error { + if len(eggs) == 0 || s.StockLogRepo == nil { + return nil + } + if strings.TrimSpace(note) == "" || actorID == 0 { + return nil + } + + for _, egg := range eggs { + if egg.ProductWarehouseId == 0 || egg.Qty <= 0 { + continue + } + log := &entity.StockLog{ + ProductWarehouseId: egg.ProductWarehouseId, + CreatedBy: actorID, + Decrease: float64(egg.Qty), + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: egg.RecordingId, + Notes: note, + } + if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { + return err + } + } + + return nil +} + +func (s *recordingService) replenishRecordingEggs( + ctx context.Context, + tx *gorm.DB, + eggs []entity.RecordingEgg, + note string, + actorID uint, +) error { + if len(eggs) == 0 || s.FifoSvc == nil { + return nil + } + if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { + return errors.New("stock log repository is not available") + } + + for _, egg := range eggs { + if egg.Id == 0 || egg.ProductWarehouseId == 0 || egg.Qty <= 0 { + continue + } + if _, err := s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ + StockableKey: fifo.StockableKeyRecordingEgg, + StockableID: egg.Id, + ProductWarehouseID: egg.ProductWarehouseId, + Quantity: float64(egg.Qty), + Tx: tx, + }); err != nil { + s.Log.Errorf("Failed to replenish FIFO stock for recording egg %d: %+v", egg.Id, err) + return err + } + + if strings.TrimSpace(note) != "" && actorID != 0 { + log := &entity.StockLog{ + ProductWarehouseId: egg.ProductWarehouseId, + CreatedBy: actorID, + Increase: float64(egg.Qty), + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: egg.RecordingId, + Notes: note, + } + stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, egg.ProductWarehouseId, 1) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") + } + if len(stockLogs) > 0 { + latestStockLog := stockLogs[0] + log.Stock = latestStockLog.Stock + log.Stock += log.Increase + } else { + log.Stock += log.Increase + } + + if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { + return err + } + } + } + + return nil +} + +type desiredStock struct { + Usage float64 + Pending float64 +} + +type desiredDepletion struct { + Qty float64 + Pending float64 +} + +func resetStockQuantitiesForFIFO(stocks []entity.RecordingStock, enabled bool) []desiredStock { + desired := make([]desiredStock, len(stocks)) + for i := range stocks { + if stocks[i].UsageQty != nil { + desired[i].Usage = *stocks[i].UsageQty + } + 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 + } + 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 { + 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].PendingQty = 0 + } + return desired +} + +func applyDepletionDesiredQuantities(depletions []entity.RecordingDepletion, desired []desiredDepletion, enabled bool) { + if !enabled { + return + } + for i := range depletions { + if i >= len(desired) { + break + } + depletions[i].Qty = desired[i].Qty + depletions[i].PendingQty = desired[i].Pending + } +} + +func (s *recordingService) syncRecordingStocks( + ctx context.Context, + tx *gorm.DB, + recordingID uint, + existing []entity.RecordingStock, + incoming []validation.Stock, + note string, + 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) + } + + existingByWarehouse := make(map[uint][]entity.RecordingStock) + for _, stock := range existing { + existingByWarehouse[stock.ProductWarehouseId] = append(existingByWarehouse[stock.ProductWarehouseId], stock) + } + + stocksToConsume := make([]entity.RecordingStock, 0, len(incoming)) + for _, item := range incoming { + list := existingByWarehouse[item.ProductWarehouseId] + var stock entity.RecordingStock + if len(list) > 0 { + stock = list[0] + existingByWarehouse[item.ProductWarehouseId] = list[1:] + } else { + zero := 0.0 + stock = entity.RecordingStock{ + RecordingId: recordingID, + ProductWarehouseId: item.ProductWarehouseId, + UsageQty: &zero, + PendingQty: &zero, + } + if err := tx.Create(&stock).Error; err != nil { + return err + } + } + + desired := item.Qty + stock.UsageQty = &desired + zero := 0.0 + stock.PendingQty = &zero + stocksToConsume = append(stocksToConsume, stock) + } + + var leftovers []entity.RecordingStock + for _, list := range existingByWarehouse { + leftovers = append(leftovers, list...) + } + if len(leftovers) > 0 { + if err := s.releaseRecordingStocks(ctx, tx, leftovers, note, actorID); err != nil { + return err + } + ids := make([]uint, 0, len(leftovers)) + for _, stock := range leftovers { + if stock.Id != 0 { + ids = append(ids, stock.Id) + } + } + if len(ids) > 0 { + if err := tx.Where("id IN ?", ids).Delete(&entity.RecordingStock{}).Error; err != nil { + return err + } + } + } + + if len(stocksToConsume) == 0 { + return nil + } + return s.consumeRecordingStocks(ctx, tx, stocksToConsume, note, actorID) +} diff --git a/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go b/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go index b3d7e7bc..68867265 100644 --- a/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go +++ b/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go @@ -49,8 +49,8 @@ func (r *TransferLayingRepositoryImpl) GenerateMovementNumber(ctx context.Contex if err != nil { return "", err } - // Format: TL00001, TL00002, dst - movementNumber := fmt.Sprintf("TL%05d", seq) + + movementNumber := fmt.Sprintf("TL-%05d", seq) return movementNumber, nil } diff --git a/internal/modules/production/transfer_layings/services/transfer_laying.service.go b/internal/modules/production/transfer_layings/services/transfer_laying.service.go index e6e9a862..15351e56 100644 --- a/internal/modules/production/transfer_layings/services/transfer_laying.service.go +++ b/internal/modules/production/transfer_layings/services/transfer_laying.service.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "math" "strings" "time" @@ -743,7 +744,7 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { repoTx := s.Repository.WithTx(dbTransaction) approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) - + stockAllocationRepo := commonRepo.NewStockAllocationRepository(dbTransaction) sourceRepoTx := repository.NewLayingTransferSourceRepository(dbTransaction) targetRepoTx := repository.NewLayingTransferTargetRepository(dbTransaction) stockLogRepoTx := rStockLogs.NewStockLogRepository(dbTransaction) @@ -817,6 +818,27 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( return fiber.NewError(fiber.StatusInternalServerError, "Gagal update source usage qty") } + targetShares := distributeProportionalWithRounding(targets, totalTargetQty, sourceShare) + + for i, target := range targets { + roundedQty := math.Round(targetShares[i]) + if roundedQty <= 0 { + continue + } + mappingAllocation := &entity.StockAllocation{ + StockableType: fifo.UsableKeyTransferToLayingOut.String(), + StockableId: source.Id, + UsableType: fifo.StockableKeyTransferToLayingIn.String(), + UsableId: target.Id, + ProductWarehouseId: *source.ProductWarehouseId, + Qty: roundedQty, + Status: entity.StockAllocationStatusActive, + } + if err := stockAllocationRepo.CreateOne(c.Context(), mappingAllocation, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal create mapping allocation source→target") + } + } + stockLogDecrease := &entity.StockLog{ ProductWarehouseId: *source.ProductWarehouseId, CreatedBy: actorID, @@ -937,36 +959,6 @@ func createApprovalTransferLaying(ctx context.Context, tx *gorm.DB, transferLayi return err } -func (s *transferLayingService) getOrCreateProductWarehouse(ctx context.Context, tx *gorm.DB, productID uint, warehouseID uint, quantity float64, actorID uint, projectFlockKandangId *uint) (*entity.ProductWarehouse, error) { - - productWarehouseRepoTx := rInventory.NewProductWarehouseRepository(tx) - - existing, err := productWarehouseRepoTx.GetProductWarehouseByProductAndWarehouseID(ctx, productID, warehouseID) - if err == nil && existing != nil { - - if err := productWarehouseRepoTx.PatchOne(ctx, existing.Id, map[string]any{"qty": gorm.Expr("qty + ?", quantity)}, nil); err != nil { - return nil, err - } - return existing, nil - } - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, err - } - - newWarehouse := &entity.ProductWarehouse{ - ProductId: productID, - WarehouseId: warehouseID, - ProjectFlockKandangId: projectFlockKandangId, - Quantity: quantity, - } - - if err := productWarehouseRepoTx.CreateOne(ctx, newWarehouse, nil); err != nil { - return nil, err - } - - return newWarehouse, nil -} - func (s transferLayingService) GetAvailableQtyPerKandang(ctx *fiber.Ctx, projectFlockID uint) (*entity.ProjectFlock, map[uint]float64, error) { pf, err := s.ProjectFlockRepo.GetByID(ctx.Context(), projectFlockID, func(db *gorm.DB) *gorm.DB { @@ -1060,3 +1052,34 @@ func (s transferLayingService) GetMaxTargetQtyPerKandang(c *fiber.Ctx, projectFl return kandangMaxTargetQty, nil } + +func distributeProportionalWithRounding(targets []entity.LayingTransferTarget, totalTargetQty, sourceShare float64) []float64 { + if len(targets) == 0 { + return []float64{} + } + + targetShares := make([]float64, len(targets)) + totalRounded := 0.0 + + for i, target := range targets { + targetShares[i] = (target.TotalQty / totalTargetQty) * sourceShare + totalRounded += math.Round(targetShares[i]) + } + + diff := sourceShare - totalRounded + + if diff != 0 { + maxIdx := 0 + maxDecimal := 0.0 + for i, share := range targetShares { + decimal := share - math.Round(share) + if decimal > maxDecimal { + maxDecimal = decimal + maxIdx = i + } + } + targetShares[maxIdx] += diff + } + + return targetShares +} diff --git a/internal/modules/purchases/validations/purchase.validation.go b/internal/modules/purchases/validations/purchase.validation.go index 564cc96f..6f5d3013 100644 --- a/internal/modules/purchases/validations/purchase.validation.go +++ b/internal/modules/purchases/validations/purchase.validation.go @@ -51,7 +51,7 @@ type ReceivePurchaseItemRequest struct { type ReceivePurchaseRequest struct { Action string `form:"action" json:"action" validate:"required,oneof=APPROVED REJECTED"` - Items []ReceivePurchaseItemRequest `form:"items" json:"items" validate:"min=1,dive"` + Items []ReceivePurchaseItemRequest `form:"items" json:"items" validate:"omitempty,dive"` TravelDocuments []*multipart.FileHeader `form:"travel_documents" json:"-" validate:"omitempty,dive"` Notes *string `form:"notes" json:"notes,omitempty" validate:"omitempty,max=500"` } diff --git a/internal/utils/recording/util.recording.go b/internal/utils/recording/util.recording.go index f40818bf..2b146f5f 100644 --- a/internal/utils/recording/util.recording.go +++ b/internal/utils/recording/util.recording.go @@ -28,12 +28,26 @@ func MapDepletions(recordingID uint, items []validation.Depletion) []entity.Reco return nil } - result := make([]entity.RecordingDepletion, 0, len(items)) + aggregate := make(map[uint]float64, len(items)) for _, item := range items { + if item.ProductWarehouseId == 0 || item.Qty == 0 { + continue + } + aggregate[item.ProductWarehouseId] += item.Qty + } + if len(aggregate) == 0 { + return nil + } + + result := make([]entity.RecordingDepletion, 0, len(aggregate)) + for warehouseID, qty := range aggregate { + if qty == 0 { + continue + } result = append(result, entity.RecordingDepletion{ RecordingId: recordingID, - ProductWarehouseId: item.ProductWarehouseId, - Qty: item.Qty, + ProductWarehouseId: warehouseID, + Qty: qty, }) } return result