From edd77c52653e128dcb1cb9a4357b8ba657f26ff0 Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 20 Jan 2026 16:40:37 +0700 Subject: [PATCH 01/15] [FIX/BE-US] purchase edit qty approval staf add adjustment fifo system --- .../common/service/common.fifo.service.go | 41 +++++++++++++++++ .../purchases/services/purchase.service.go | 44 ++++++++++++++++--- 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/internal/common/service/common.fifo.service.go b/internal/common/service/common.fifo.service.go index b99e6c35..14cbb5c1 100644 --- a/internal/common/service/common.fifo.service.go +++ b/internal/common/service/common.fifo.service.go @@ -25,6 +25,7 @@ type FifoService interface { Replenish(ctx context.Context, req StockReplenishRequest) (*StockReplenishResult, error) Consume(ctx context.Context, req StockConsumeRequest) (*StockConsumeResult, error) ReleaseUsage(ctx context.Context, req StockReleaseRequest) error + AdjustStockableQuantity(ctx context.Context, req StockAdjustRequest) error } type fifoService struct { @@ -95,6 +96,15 @@ type StockReplenishRequest struct { Tx *gorm.DB } +type StockAdjustRequest struct { + StockableKey fifo.StockableKey + StockableID uint + ProductWarehouseID uint + Quantity float64 + Note *string + Tx *gorm.DB +} + type PendingResolution struct { UsableKey fifo.UsableKey UsableID uint @@ -137,6 +147,37 @@ 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") + } + if req.ProductWarehouseID == 0 { + return errors.New("product warehouse id is required") + } + if req.Quantity == 0 { + return nil + } + if req.Quantity > 0 { + return errors.New("quantity must be negative") + } + + cfg, ok := fifo.Stockable(req.StockableKey) + if !ok { + return fmt.Errorf("stockable %q is not registered", req.StockableKey) + } + + return s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error { + if err := s.incrementStockableQty(ctx, tx, cfg, req.StockableID, req.Quantity); err != nil { + return err + } + + return s.productWarehouseRepo.AdjustQuantities(ctx, map[uint]float64{ + req.ProductWarehouseID: req.Quantity, + }, func(db *gorm.DB) *gorm.DB { + return s.txOrDB(tx, db) + }) + }) +} func (s *fifoService) Replenish(ctx context.Context, req StockReplenishRequest) (*StockReplenishResult, error) { if req.StockableID == 0 || strings.TrimSpace(req.StockableKey.String()) == "" { diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index b0914853..b7efbc05 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -844,6 +844,11 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation pwID uint qty float64 }, 0, len(prepared)) + fifoSubs := make([]struct { + itemID uint + pwID uint + qty float64 + }, 0, len(prepared)) for _, prep := range prepared { item := prep.item @@ -877,9 +882,18 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation totalQtyDeltas[item.Id] += deltaQty } case deltaQty < 0 && newPWID != nil: - deltas[*newPWID] += deltaQty // negative - affected[*newPWID] = struct{}{} - totalQtyDeltas[item.Id] += deltaQty + if s.FifoSvc != nil { + fifoSubs = append(fifoSubs, struct { + itemID uint + pwID uint + qty float64 + }{itemID: item.Id, pwID: *newPWID, qty: deltaQty}) + affected[*newPWID] = struct{}{} + } else { + deltas[*newPWID] += deltaQty // negative + affected[*newPWID] = struct{}{} + totalQtyDeltas[item.Id] += deltaQty + } } dateCopy := prep.receivedDate @@ -919,10 +933,6 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation return err } - if err := pwRepoTx.CleanupEmpty(c.Context(), affected); err != nil { - return err - } - if len(priceUpdates) > 0 { if err := repoTx.UpdatePricing(c.Context(), purchase.Id, priceUpdates); err != nil { return err @@ -967,6 +977,26 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation return err } } + for _, adj := range fifoSubs { + if adj.pwID == 0 || adj.qty >= 0 { + continue + } + if err := s.FifoSvc.AdjustStockableQuantity(c.Context(), commonSvc.StockAdjustRequest{ + StockableKey: fifo.StockableKeyPurchaseItems, + StockableID: adj.itemID, + ProductWarehouseID: adj.pwID, + Quantity: adj.qty, + Tx: tx, + }); err != nil { + return err + } + } + } + + if len(affected) > 0 { + if err := pwRepoTx.CleanupEmpty(c.Context(), affected); err != nil { + return err + } } return nil From e4e17f16f9129c893aa4061011ac751d5591f534 Mon Sep 17 00:00:00 2001 From: giovanni Date: Tue, 20 Jan 2026 18:18:41 +0700 Subject: [PATCH 02/15] fix data produksi not show response --- internal/modules/closings/dto/closing.dto.go | 40 +++++++++---------- .../closings/services/closing.service.go | 8 ++-- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/internal/modules/closings/dto/closing.dto.go b/internal/modules/closings/dto/closing.dto.go index 1c191d29..82e11f49 100644 --- a/internal/modules/closings/dto/closing.dto.go +++ b/internal/modules/closings/dto/closing.dto.go @@ -98,26 +98,26 @@ type ClosingEggSalesDTO struct { } type ClosingPerformanceDTO struct { - Depletion float64 `json:"depletion"` - Age float64 `json:"age_day"` - MortalityStd float64 `json:"mor_std"` - MortalityAct float64 `json:"mor_act"` - DeffMortality float64 `json:"mor_diff"` - FcrStd float64 `json:"fcr_std"` - FcrAct float64 `json:"fcr_act"` - DeffFcr float64 `json:"fcr_diff"` - AwgAct float64 `json:"awg_act"` - AwgStd float64 `json:"awg_std"` - FeedIntake float64 `json:"feed_intake"` - FeedIntakeStd float64 `json:"feed_intake_std"` - HenDayAct *float64 `json:"hen_day_act,omitempty"` - HendayStd float64 `json:"hen_day_std"` - EggMass *float64 `json:"egg_mass,omitempty"` - EggMassStd float64 `json:"egg_mass_std"` - EggWeight *float64 `json:"egg_weight,omitempty"` - EggWeightStd float64 `json:"egg_weight_std"` - HenHouseAct *float64 `json:"hen_housed_act,omitempty"` - HenHouseStd float64 `json:"hen_housed_std"` + Depletion float64 `json:"depletion"` + Age float64 `json:"age_day"` + MortalityStd float64 `json:"mor_std"` + MortalityAct float64 `json:"mor_act"` + DeffMortality float64 `json:"mor_diff"` + FcrStd float64 `json:"fcr_std"` + FcrAct float64 `json:"fcr_act"` + DeffFcr float64 `json:"fcr_diff"` + AwgAct float64 `json:"awg_act"` + AwgStd float64 `json:"awg_std"` + FeedIntake float64 `json:"feed_intake"` + FeedIntakeStd float64 `json:"feed_intake_std"` + HenDayAct float64 `json:"hen_day_act,omitempty"` + HendayStd float64 `json:"hen_day_std"` + EggMass float64 `json:"egg_mass,omitempty"` + EggMassStd float64 `json:"egg_mass_std"` + EggWeight float64 `json:"egg_weight,omitempty"` + EggWeightStd float64 `json:"egg_weight_std"` + HenHouseAct float64 `json:"hen_housed_act,omitempty"` + HenHouseStd float64 `json:"hen_housed_std"` } type ClosingSalesGroupDTO struct { diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 443eec7f..daca980f 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -930,19 +930,19 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint if !isGrowing { if targetAverages.HenDayCount > 0 { henDayAct := targetAverages.HenDayAvg - performance.HenDayAct = &henDayAct + performance.HenDayAct = henDayAct } if targetAverages.HenHouseCount > 0 { henHouseAct := targetAverages.HenHouseAvg - performance.HenHouseAct = &henHouseAct + performance.HenHouseAct = henHouseAct } if targetAverages.EggWeightCount > 0 { eggWeight := targetAverages.EggWeightAvg - performance.EggWeight = &eggWeight + performance.EggWeight = eggWeight } if targetAverages.EggMassCount > 0 { eggMass := targetAverages.EggMassAvg - performance.EggMass = &eggMass + performance.EggMass = eggMass } } performance.DeffFcr = performance.FcrStd - performance.FcrAct From aa4da6868067bc189a70974e2bf9fdce41f326b0 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 20 Jan 2026 22:07:07 +0700 Subject: [PATCH 03/15] refactor: unify GetOne method to return approval alongside transfer laying --- .../controllers/transfer_laying.controller.go | 2 +- .../services/transfer_laying.service.go | 32 +++++++++---------- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go b/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go index 13c39334..d0ee5061 100644 --- a/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go +++ b/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go @@ -70,7 +70,7 @@ func (u *TransferLayingController) GetOne(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") } - result, approval, err := u.TransferLayingService.GetOneWithApproval(c, uint(id)) + result, approval, err := u.TransferLayingService.GetOne(c, uint(id)) if err != nil { return err } 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 e64b9cc2..8e0269cf 100644 --- a/internal/modules/production/transfer_layings/services/transfer_laying.service.go +++ b/internal/modules/production/transfer_layings/services/transfer_laying.service.go @@ -28,8 +28,7 @@ import ( type TransferLayingService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.LayingTransfer, int64, error) - GetOne(ctx *fiber.Ctx, id uint) (*entity.LayingTransfer, error) - GetOneWithApproval(ctx *fiber.Ctx, id uint) (*entity.LayingTransfer, *entity.Approval, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.LayingTransfer, *entity.Approval, error) CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.LayingTransfer, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.LayingTransfer, error) DeleteOne(ctx *fiber.Ctx, id uint) error @@ -156,14 +155,15 @@ func (s transferLayingService) GetAll(c *fiber.Ctx, params *validation.Query) ([ return transferLayings, total, nil } -func (s transferLayingService) GetOne(c *fiber.Ctx, id uint) (*entity.LayingTransfer, error) { +func (s transferLayingService) GetOne(c *fiber.Ctx, id uint) (*entity.LayingTransfer, *entity.Approval, error) { transferLaying, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "TransferLaying not found") + return nil, nil, fiber.NewError(fiber.StatusNotFound, "TransferLaying not found") } if err != nil { s.Log.Errorf("Failed get transferLaying by id: %+v", err) - return nil, err + return nil, nil, err } approvalRepo := commonRepo.NewApprovalRepository(s.Repository.DB()) @@ -174,15 +174,6 @@ func (s transferLayingService) GetOne(c *fiber.Ctx, id uint) (*entity.LayingTran transferLaying.LatestApproval = latestApproval } - return transferLaying, nil -} - -func (s transferLayingService) GetOneWithApproval(c *fiber.Ctx, id uint) (*entity.LayingTransfer, *entity.Approval, error) { - transferLaying, err := s.GetOne(c, id) - if err != nil { - return nil, nil, err - } - return transferLaying, transferLaying.LatestApproval, nil } @@ -406,7 +397,12 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat transfer laying") } - return s.GetOne(c, createBody.Id) + laying_transfer, _, err := s.GetOne(c, createBody.Id) + if err != nil { + return nil, err + } + return laying_transfer, nil + } func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.LayingTransfer, error) { @@ -582,7 +578,9 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, return nil, err } - return s.GetOne(c, id) + layingTransfer, _, err := s.GetOne(c, id) + + return layingTransfer, err } func (s transferLayingService) DeleteOne(c *fiber.Ctx, id uint) error { @@ -773,7 +771,7 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( updated := make([]entity.LayingTransfer, 0, len(approvableIDs)) for _, approvableID := range approvableIDs { - transfer, err := s.GetOne(c, approvableID) + transfer, _, err := s.GetOne(c, approvableID) if err != nil { return nil, err } From dd4dcc1c39573eef867d0f71e32b3a193a07d421 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 20 Jan 2026 22:10:47 +0700 Subject: [PATCH 04/15] FEAT[BE[: add avg weight and avg amount on get penjualan harian --- .../repports/dto/repportMarketing.dto.go | 296 +++++++----------- .../repports/services/repport.service.go | 2 +- 2 files changed, 118 insertions(+), 180 deletions(-) diff --git a/internal/modules/repports/dto/repportMarketing.dto.go b/internal/modules/repports/dto/repportMarketing.dto.go index 92ee9a77..edb2887f 100644 --- a/internal/modules/repports/dto/repportMarketing.dto.go +++ b/internal/modules/repports/dto/repportMarketing.dto.go @@ -40,99 +40,24 @@ type RepportMarketingItemDTO struct { type Summary struct { TotalQty int `json:"total_qty"` TotalWeightKg float64 `json:"total_weight_kg"` + AverageWeightKg float64 `json:"average_weight_kg"` + AverageSalesAmount float64 `json:"average_sales_amount"` TotalSalesAmount int64 `json:"total_sales_amount"` TotalHppAmount int64 `json:"total_hpp_amount"` TotalHppPricePerKg float64 `json:"total_hpp_price_per_kg"` } -type RepportMarketingResponseDTO struct { - Items []RepportMarketingItemDTO `json:"items"` - Total *Summary `json:"total,omitempty"` -} - type ProductRelationDTOFixed struct { productDTO.ProductRelationDTO ProductPrice float64 `json:"product_price"` SellingPrice *float64 `json:"selling_price,omitempty"` } -func ToRepportMarketingItemDTO(mdp entity.MarketingDeliveryProduct, hppPricePerKg float64, category string) RepportMarketingItemDTO { - soDate := time.Time{} - agingDays := 0 - if mdp.MarketingProduct.Marketing.SoDate.Year() > 1 { - soDate = mdp.MarketingProduct.Marketing.SoDate - agingDays = int(time.Since(soDate).Hours() / 24) - } - - realizationDate := time.Time{} - if mdp.DeliveryDate != nil { - realizationDate = *mdp.DeliveryDate - } - - doNumber := marketingDTO.GenerateDeliveryOrderNumber(mdp.MarketingProduct.Marketing.SoNumber, mdp.DeliveryDate, mdp.MarketingProduct.ProductWarehouse.WarehouseId) - - totalWeightKg := mdp.UsageQty * mdp.AvgWeight - salesAmount := totalWeightKg * mdp.UnitPrice - - var hpp float64 - var hppAmount float64 - if isProductEligibleForHpp(mdp, category) { - hpp = hppPricePerKg - hppAmount = totalWeightKg * hppPricePerKg - } - - item := RepportMarketingItemDTO{ - ID: int(mdp.Id), - SoDate: soDate, - RealizationDate: realizationDate, - AgingDays: agingDays, - DoNumber: doNumber, - MarketingType: getMarketingType(mdp), - Qty: mdp.UsageQty, - AverageWeightKg: mdp.AvgWeight, - TotalWeightKg: totalWeightKg, - SalesPricePerKg: mdp.UnitPrice, - HppPricePerKg: hpp, - SalesAmount: salesAmount, - HppAmount: hppAmount, - } - - if mdp.MarketingProduct.ProductWarehouse.WarehouseId != 0 { - mapped := warehouseDTO.ToWarehouseRelationDTO(mdp.MarketingProduct.ProductWarehouse.Warehouse) - item.Warehouse = &mapped - } - - if mdp.MarketingProduct.Marketing.CustomerId != 0 { - mapped := customerDTO.ToCustomerRelationDTO(mdp.MarketingProduct.Marketing.Customer) - item.Customer = &mapped - } - - if mdp.MarketingProduct.Marketing.SalesPersonId != 0 { - mapped := userDTO.ToUserRelationDTO(mdp.MarketingProduct.Marketing.SalesPerson) - item.Sales = &mapped - } - - item.VehicleNumber = mdp.VehicleNumber - - if mdp.MarketingProduct.ProductWarehouse.ProductId != 0 { - mapped := productDTO.ToProductRelationDTO(mdp.MarketingProduct.ProductWarehouse.Product) - item.Product = newProductRelationDTOFixedPtr(&mapped) - } - - return item -} - -func ToRepportMarketingItemDTOs(mdps []entity.MarketingDeliveryProduct, hppPricePerKg float64, category string) []RepportMarketingItemDTO { +func ToMarketingReportItems(mdps []entity.MarketingDeliveryProduct, hppMap map[uint]float64) []RepportMarketingItemDTO { items := make([]RepportMarketingItemDTO, 0, len(mdps)) - for _, mdp := range mdps { - items = append(items, ToRepportMarketingItemDTO(mdp, hppPricePerKg, category)) - } - return items -} -func ToRepportMarketingItemDTOsWithHppMap(mdps []entity.MarketingDeliveryProduct, hppMap map[uint]float64) []RepportMarketingItemDTO { - items := make([]RepportMarketingItemDTO, 0, len(mdps)) for _, mdp := range mdps { + // Get HPP and category from map hppPerKg := float64(0) category := "" if projectFlockKandang := mdp.MarketingProduct.ProductWarehouse.ProjectFlockKandang; projectFlockKandang != nil { @@ -142,101 +67,111 @@ func ToRepportMarketingItemDTOsWithHppMap(mdps []entity.MarketingDeliveryProduct category = projectFlockKandang.ProjectFlock.Category } - item := ToRepportMarketingItemDTO(mdp, hppPerKg, category) + // Calculate dates + soDate := time.Time{} + agingDays := 0 + if mdp.MarketingProduct.Marketing.SoDate.Year() > 1 { + soDate = mdp.MarketingProduct.Marketing.SoDate + agingDays = int(time.Since(soDate).Hours() / 24) + } + + realizationDate := time.Time{} + if mdp.DeliveryDate != nil { + realizationDate = *mdp.DeliveryDate + } + + totalWeightKg := mdp.UsageQty * mdp.AvgWeight + salesAmount := totalWeightKg * mdp.UnitPrice + + var hpp float64 + var hppAmount float64 + + var hasAyam, hasTelur, hasTrading bool + for _, flag := range mdp.MarketingProduct.ProductWarehouse.Product.Flags { + ft := utils.FlagType(flag.Name) + + if ft == utils.FlagAyamAfkir || ft == utils.FlagAyamCulling || ft == utils.FlagAyamMati || + ft == utils.FlagDOC || ft == utils.FlagPullet || ft == utils.FlagLayer { + hasAyam = true + } + + if ft == utils.FlagTelur || ft == utils.FlagTelurUtuh || ft == utils.FlagTelurPecah || + ft == utils.FlagTelurPutih || ft == utils.FlagTelurRetak { + hasTelur = true + } + + if ft == utils.FlagOVK || ft == utils.FlagObat || ft == utils.FlagVitamin || ft == utils.FlagKimia || + ft == utils.FlagPakan || ft == utils.FlagPreStarter || ft == utils.FlagStarter || ft == utils.FlagFinisher { + hasTrading = true + } + } + + // Determine marketing type + marketingType := "trading" + if hasTrading { + marketingType = "trading" + } else if hasTelur { + marketingType = "telur" + } else if hasAyam { + marketingType = "ayam" + } + + eligibleForHpp := false + + if utils.ProjectFlockCategory(category) == utils.ProjectFlockCategoryGrowing { + eligibleForHpp = hasAyam + } else { + eligibleForHpp = hasAyam || hasTelur + } + + if eligibleForHpp { + hpp = hppPerKg + hppAmount = totalWeightKg * hppPerKg + } + + item := RepportMarketingItemDTO{ + ID: int(mdp.Id), + SoDate: soDate, + RealizationDate: realizationDate, + AgingDays: agingDays, + DoNumber: marketingDTO.GenerateDeliveryOrderNumber(mdp.MarketingProduct.Marketing.SoNumber, mdp.DeliveryDate, mdp.MarketingProduct.ProductWarehouse.WarehouseId), + MarketingType: marketingType, + Qty: mdp.UsageQty, + AverageWeightKg: mdp.AvgWeight, + TotalWeightKg: totalWeightKg, + SalesPricePerKg: mdp.UnitPrice, + HppPricePerKg: hpp, + SalesAmount: salesAmount, + HppAmount: hppAmount, + VehicleNumber: mdp.VehicleNumber, + } + + if mdp.MarketingProduct.ProductWarehouse.WarehouseId != 0 { + mapped := warehouseDTO.ToWarehouseRelationDTO(mdp.MarketingProduct.ProductWarehouse.Warehouse) + item.Warehouse = &mapped + } + + if mdp.MarketingProduct.Marketing.CustomerId != 0 { + mapped := customerDTO.ToCustomerRelationDTO(mdp.MarketingProduct.Marketing.Customer) + item.Customer = &mapped + } + + if mdp.MarketingProduct.Marketing.SalesPersonId != 0 { + mapped := userDTO.ToUserRelationDTO(mdp.MarketingProduct.Marketing.SalesPerson) + item.Sales = &mapped + } + + if mdp.MarketingProduct.ProductWarehouse.ProductId != 0 { + mapped := productDTO.ToProductRelationDTO(mdp.MarketingProduct.ProductWarehouse.Product) + item.Product = newProductRelationDTOFixedPtr(&mapped) + } + items = append(items, item) } + return items } -func getMarketingType(mdp entity.MarketingDeliveryProduct) string { - hasAyam, hasTelur, hasTrading := checkProductFlags(mdp.MarketingProduct.ProductWarehouse.Product.Flags) - - if hasAyam { - return "ayam" - } - if hasTelur { - return "telur" - } - if hasTrading { - return "trading" - } - return "trading" // default to trading if no flags found -} - -func checkProductFlags(flags []entity.Flag) (hasAyam, hasTelur, hasTrading bool) { - if len(flags) == 0 { - return false, false, false - } - - for _, flag := range flags { - ft := utils.FlagType(flag.Name) - - if ft == utils.FlagAyamAfkir || ft == utils.FlagAyamCulling || ft == utils.FlagAyamMati || - ft == utils.FlagDOC || ft == utils.FlagPullet || ft == utils.FlagLayer { - hasAyam = true - } - - if ft == utils.FlagTelur || ft == utils.FlagTelurUtuh || ft == utils.FlagTelurPecah || - ft == utils.FlagTelurPutih || ft == utils.FlagTelurRetak { - hasTelur = true - } - - if ft == utils.FlagOVK || ft == utils.FlagObat || ft == utils.FlagVitamin || ft == utils.FlagKimia || - ft == utils.FlagPakan || ft == utils.FlagPreStarter || ft == utils.FlagStarter || ft == utils.FlagFinisher { - hasTrading = true - } - } - - return hasAyam, hasTelur, hasTrading -} - -func isProductEligibleForHpp(mdp entity.MarketingDeliveryProduct, category string) bool { - hasAyam, hasTelur, _ := checkProductFlags(mdp.MarketingProduct.ProductWarehouse.Product.Flags) - - if utils.ProjectFlockCategory(category) == utils.ProjectFlockCategoryGrowing { - return hasAyam - } - - return hasAyam || hasTelur -} - -func ToSummary(mdps []entity.MarketingDeliveryProduct, hppPricePerKg float64, category string) *Summary { - if len(mdps) == 0 { - return nil - } - - totalQty := 0 - totalWeightKg := 0.0 - totalEligibleWeightKg := 0.0 - totalSalesAmount := int64(0) - totalHppAmount := int64(0) - - for _, mdp := range mdps { - calculatedTotalWeight := mdp.UsageQty * mdp.AvgWeight - totalQty += int(mdp.UsageQty) - totalWeightKg += calculatedTotalWeight - totalSalesAmount += int64(calculatedTotalWeight * mdp.UnitPrice) - - if isProductEligibleForHpp(mdp, category) { - totalEligibleWeightKg += calculatedTotalWeight - totalHppAmount += int64(calculatedTotalWeight * hppPricePerKg) - } - } - - totalHppPricePerKg := float64(0) - if totalEligibleWeightKg > 0 { - totalHppPricePerKg = float64(totalHppAmount) / totalEligibleWeightKg - } - - return &Summary{ - TotalQty: totalQty, - TotalWeightKg: totalWeightKg, - TotalSalesAmount: totalSalesAmount, - TotalHppAmount: totalHppAmount, - TotalHppPricePerKg: totalHppPricePerKg, - } -} - func ToSummaryFromDTOItems(items []RepportMarketingItemDTO) *Summary { if len(items) == 0 { return nil @@ -244,6 +179,8 @@ func ToSummaryFromDTOItems(items []RepportMarketingItemDTO) *Summary { totalQty := 0 totalWeightKg := 0.0 + avgSalesAmount := 0.0 + avgWeightKg := 0.0 totalSalesAmount := int64(0) totalHppAmount := int64(0) @@ -259,25 +196,26 @@ func ToSummaryFromDTOItems(items []RepportMarketingItemDTO) *Summary { totalHppPricePerKg = float64(totalHppAmount) / totalWeightKg } + if len(items) > 0 { + avgSalesAmount = float64(totalSalesAmount) / float64(len(items)) + } + + if totalQty > 0 { + avgWeightKg = totalWeightKg / float64(totalQty) + avgSalesAmount = float64(totalSalesAmount) / float64(totalQty) // ← TAMBAHAN INI + } + return &Summary{ TotalQty: totalQty, TotalWeightKg: totalWeightKg, + AverageWeightKg: avgWeightKg, + AverageSalesAmount: avgSalesAmount, TotalSalesAmount: totalSalesAmount, TotalHppAmount: totalHppAmount, TotalHppPricePerKg: totalHppPricePerKg, } } -func ToRepportMarketingResponseDTO(mdps []entity.MarketingDeliveryProduct, hppPricePerKg float64, category string) RepportMarketingResponseDTO { - items := ToRepportMarketingItemDTOs(mdps, hppPricePerKg, category) - total := ToSummary(mdps, hppPricePerKg, category) - - return RepportMarketingResponseDTO{ - Items: items, - Total: total, - } -} - func newProductRelationDTOFixedPtr(original *productDTO.ProductRelationDTO) *ProductRelationDTOFixed { if original == nil { return nil diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index a0e0f350..090a284b 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -181,7 +181,7 @@ func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.Marketing } } - items := dto.ToRepportMarketingItemDTOsWithHppMap(deliveryProducts, hppMap) + items := dto.ToMarketingReportItems(deliveryProducts, hppMap) return items, total, nil } From ad3bb0e29a348e5e8f2fb021663b01a331a47984 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 20 Jan 2026 22:28:34 +0700 Subject: [PATCH 05/15] FEAT[BE[: enhance marketing report items with aging days calculation --- .../repports/dto/repportMarketing.dto.go | 17 ++- .../repports/services/repport.service.go | 104 ++++++++---------- 2 files changed, 54 insertions(+), 67 deletions(-) diff --git a/internal/modules/repports/dto/repportMarketing.dto.go b/internal/modules/repports/dto/repportMarketing.dto.go index edb2887f..751796e9 100644 --- a/internal/modules/repports/dto/repportMarketing.dto.go +++ b/internal/modules/repports/dto/repportMarketing.dto.go @@ -53,11 +53,10 @@ type ProductRelationDTOFixed struct { SellingPrice *float64 `json:"selling_price,omitempty"` } -func ToMarketingReportItems(mdps []entity.MarketingDeliveryProduct, hppMap map[uint]float64) []RepportMarketingItemDTO { +func ToMarketingReportItems(mdps []entity.MarketingDeliveryProduct, hppMap map[uint]float64, agingMap map[int]int) []RepportMarketingItemDTO { items := make([]RepportMarketingItemDTO, 0, len(mdps)) for _, mdp := range mdps { - // Get HPP and category from map hppPerKg := float64(0) category := "" if projectFlockKandang := mdp.MarketingProduct.ProductWarehouse.ProjectFlockKandang; projectFlockKandang != nil { @@ -67,12 +66,15 @@ func ToMarketingReportItems(mdps []entity.MarketingDeliveryProduct, hppMap map[u category = projectFlockKandang.ProjectFlock.Category } - // Calculate dates soDate := time.Time{} agingDays := 0 if mdp.MarketingProduct.Marketing.SoDate.Year() > 1 { soDate = mdp.MarketingProduct.Marketing.SoDate - agingDays = int(time.Since(soDate).Hours() / 24) + if ag, exists := agingMap[int(mdp.Id)]; exists { + agingDays = ag + } else { + agingDays = int(time.Since(soDate).Hours() / 24) + } } realizationDate := time.Time{} @@ -106,7 +108,6 @@ func ToMarketingReportItems(mdps []entity.MarketingDeliveryProduct, hppMap map[u } } - // Determine marketing type marketingType := "trading" if hasTrading { marketingType = "trading" @@ -196,13 +197,9 @@ func ToSummaryFromDTOItems(items []RepportMarketingItemDTO) *Summary { totalHppPricePerKg = float64(totalHppAmount) / totalWeightKg } - if len(items) > 0 { - avgSalesAmount = float64(totalSalesAmount) / float64(len(items)) - } - if totalQty > 0 { avgWeightKg = totalWeightKg / float64(totalQty) - avgSalesAmount = float64(totalSalesAmount) / float64(totalQty) // ← TAMBAHAN INI + avgSalesAmount = float64(totalSalesAmount) / float64(totalQty) } return &Summary{ diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 090a284b..579436eb 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -165,6 +165,47 @@ func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.Marketing return nil, 0, err } + customerGroups := make(map[uint][]entity.MarketingDeliveryProduct) + for _, dp := range deliveryProducts { + customerID := dp.MarketingProduct.Marketing.CustomerId + customerGroups[customerID] = append(customerGroups[customerID], dp) + } + + agingMap := make(map[int]int) + for customerID := range customerGroups { + transactions, err := s.CustomerPaymentRepo.GetCustomerPaymentTransactions(c.Context(), &customerID) + if err != nil { + s.Log.Warnf("Failed to get transactions for customer %d: %v", customerID, err) + continue + } + + initialBalance, err := s.CustomerPaymentRepo.GetInitialBalanceByCustomer(c.Context(), customerID) + if err != nil { + initialBalance = 0 + } + + runningBalance := initialBalance + for i, tx := range transactions { + if tx.TransactionType == "SALES" { + previousBalance := runningBalance + runningBalance -= tx.TotalPrice + currentBalance := runningBalance + + _, paymentDate := s.determineSalesStatusAndPaymentDate(transactions, i, previousBalance, currentBalance) + + if paymentDate != nil { + agingDays := int(paymentDate.Sub(tx.TransDate).Hours() / 24) + agingMap[int(tx.TransactionID)] = agingDays + } else { + agingDays := int(time.Since(tx.TransDate).Hours() / 24) + agingMap[int(tx.TransactionID)] = agingDays + } + } else if tx.TransactionType == "PAYMENT" { + runningBalance += tx.PaymentAmount + } + } + } + projectFlockIDMap := make(map[uint]bool) hppMap := make(map[uint]float64) @@ -181,7 +222,7 @@ func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.Marketing } } - items := dto.ToMarketingReportItems(deliveryProducts, hppMap) + items := dto.ToMarketingReportItems(deliveryProducts, hppMap, agingMap) return items, total, nil } @@ -422,12 +463,10 @@ func (s *repportService) GetCustomerPayment(ctx *fiber.Ctx, params *validation.C return nil, 0, err } - // Determine customer IDs to process var customerIDs []uint var totalCustomers int64 if len(params.CustomerIDs) > 0 { - // Specific customer IDs mode (no pagination) customerIDs = params.CustomerIDs totalCustomers = int64(len(customerIDs)) @@ -435,7 +474,6 @@ func (s *repportService) GetCustomerPayment(ctx *fiber.Ctx, params *validation.C return []dto.CustomerPaymentReportItem{}, 0, nil } } else { - // Multiple customers mode with pagination page := params.Page limit := params.Limit if page < 1 { @@ -574,15 +612,7 @@ func (s *repportService) processCustomerPayment(ctx context.Context, customerID func (s *repportService) determineSalesStatusAndPaymentDate(transactions []repportRepo.CustomerPaymentTransaction, currentIndex int, previousBalance, currentBalance float64) (string, *time.Time) { currentSales := transactions[currentIndex] - // Status Logic: - // 1. LUNAS: previousBalance >= salesAmount (paid from deposit) - // 2. LUNAS: future payments make AR >= 0 (eventually paid) - // 3. DIBAYAR SEBAGIAN: has payment but not enough - // 4. BELUM LUNAS: no payment at all - if previousBalance >= currentSales.TotalPrice { - // Cari payment yang digunakan untuk melunasi sales ini dengan FIFO - // Track payment allocations that are consumed by previous sales type paymentAllocation struct { date time.Time amount float64 @@ -591,7 +621,6 @@ func (s *repportService) determineSalesStatusAndPaymentDate(transactions []reppo allocations := []paymentAllocation{} runningBalance := 0.0 - // Process all transactions before current sales to build allocation map for i := 0; i < currentIndex; i++ { if transactions[i].TransactionType == "PAYMENT" { allocations = append(allocations, paymentAllocation{ @@ -604,7 +633,6 @@ func (s *repportService) determineSalesStatusAndPaymentDate(transactions []reppo salesAmount := transactions[i].TotalPrice remainingToConsume := salesAmount - // Consume from oldest allocations first (FIFO) for j := range allocations { if remainingToConsume <= 0 { break @@ -623,22 +651,18 @@ func (s *repportService) determineSalesStatusAndPaymentDate(transactions []reppo } } - // Now find which allocation covers the current sales amountNeeded := currentSales.TotalPrice for _, alloc := range allocations { available := alloc.amount - alloc.consumed if available > 0 { if amountNeeded <= available { - // This allocation fully covers the sales return "LUNAS", &alloc.date } else { - // This allocation partially covers, continue to next amountNeeded -= available } } } - // If we get here, use the oldest allocation if len(allocations) > 0 { return "LUNAS", &allocations[0].date } @@ -690,7 +714,6 @@ func mapRecordingToProductionResultDTO(record entity.Recording) dto.ProductionRe if record.Day != nil { result.Woa = float64(*record.Day) } - // avgWeight := calculateAverageBodyWeight(record.BodyWeights) avgWeight := 1.0 if avgWeight > 0 { result.Bw = avgWeight @@ -1570,12 +1593,9 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes dataRows := make([]dto.HppPerKandangRowDTO, 0, len(repoRows)) perRangeMap := make(map[weightRangeKey]*weightRangeAggregate) var totalBirds int64 - // var totalWeight float64 var totalEggPieces int64 var totalEggKg float64 - // var totalRemainingValueRp int64 var totalEggValueRp int64 - // var totalHppSum float64 var totalHppCount int var totalDocPriceSum float64 var totalDocPriceCount int @@ -1589,14 +1609,6 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes continue } - // birdsFloat := row.RemainingChickenBirds - // if math.IsNaN(birdsFloat) || math.IsInf(birdsFloat, 0) { - // birdsFloat = 0 - // } - // weightFloat := row.RemainingChickenWeight - // if math.IsNaN(weightFloat) || math.IsInf(weightFloat, 0) { - // weightFloat = 0 - // } eggPiecesFloatRemaining := row.EggProductionPiecesRemaining if math.IsNaN(eggPiecesFloatRemaining) || math.IsInf(eggPiecesFloatRemaining, 0) { eggPiecesFloatRemaining = 0 @@ -1632,13 +1644,8 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes weightMax := weightMin + 0.09 rangeKey := weightRangeKey{Min: weightMin, Max: weightMax} - // rowBirds := int64(math.Round(birdsFloat)) costEntry := costMap[row.ProjectFlockKandangID] totalCost := costEntry.FeedCost + costEntry.OvkCost + costEntry.DocCost + costEntry.BudgetCost + costEntry.ExpenseCost - // hppRp := 0.0 - // if weightFloat > 0 { - // hppRp = totalCost / weightFloat - // } eggHpp := 0.0 if eggWeightFloat > 0 { eggHpp = (totalCost / eggWeightFloat) / 1000 @@ -1646,7 +1653,6 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes rowEggPieces := int64(math.Round(eggPiecesFloatRemaining)) rowEggValue := int64(eggHpp * eggRemainingWeightFloatRemaining) - // rowRemainingValue := int64(hppRp * weightFloat) avgDocPrice := int64(0) if costEntry.DocQty > 0 { avgDocPrice = int64(math.Round(costEntry.DocCost / costEntry.DocQty)) @@ -1673,35 +1679,22 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes WeightMin: weightMin, WeightMax: weightMax, }, - AvgWeightKg: avgWeight, - NameWithPeriode: nameWithPeriod, - // FeedCostRp: costEntry.FeedCost, - // OvkCostRp: costEntry.OvkCost, + AvgWeightKg: avgWeight, + NameWithPeriode: nameWithPeriod, DocSuppliers: docSupplierMap[row.ProjectFlockKandangID], FeedSuppliers: feedSupplierMap[row.ProjectFlockKandangID], EggProductionPieces: int64(math.Round(eggPiecesFloatRemaining)), EggProductionKg: eggRemainingWeightFloatRemaining, - // EggProductionTotalWeightKg: eggWeightFloat, - // EggProductionTotalPieces: int64(math.Round(eggTotalPiecesFloat)), - AverageDocPriceRp: avgDocPrice, - // HppRp: hppRp, - EggHppRpPerKg: eggHpp, - // RemainingValueRp: rowRemainingValue, - EggValueRp: rowEggValue, + AverageDocPriceRp: avgDocPrice, + EggHppRpPerKg: eggHpp, + EggValueRp: rowEggValue, }) - // totalBirds += rowBirds - // totalWeight += weightFloat totalEggPieces += rowEggPieces totalEggKg += eggRemainingWeightFloatRemaining - // totalRemainingValueRp += rowRemainingValue totalEggValueRp += rowEggValue totalAvgWeightSum += avgWeight totalAvgWeightCount++ - // if weightFloat > 0 { - // totalHppSum += hppRp - // totalHppCount++ - // } if avgDocPrice > 0 { totalDocPriceSum += float64(avgDocPrice) totalDocPriceCount++ @@ -1728,8 +1721,6 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes } rangeSummary := rangeAgg.Summary - // rangeAgg.RemainingBirds += rowBirds - // rangeAgg.RemainingWeightKg += row.RemainingChickenWeight rangeAgg.AvgWeightSum += avgWeight rangeAgg.AvgWeightCount++ for _, supplier := range feedSupplierMap[row.ProjectFlockKandangID] { @@ -1744,7 +1735,6 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes } rangeSummary.EggProductionPieces += rowEggPieces rangeSummary.EggProductionKg += eggRemainingWeightFloatRemaining - // rangeSummary.RemainingValueRp += rowRemainingValue rangeSummary.EggValueRp += rowEggValue if eggWeightFloat > 0 { rangeAgg.EggHppSum += eggHpp From d50ab7cc977c0724dada0abc7050dbdadd43d881 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 20 Jan 2026 22:42:16 +0700 Subject: [PATCH 06/15] FEAT[BE]: add default filterby become so_date in report markeing --- .../salesorder_delivery_product.repository.go | 10 +++++++--- internal/modules/repports/services/repport.service.go | 1 - .../modules/repports/validations/repport.validation.go | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go index e219b041..1ec0bddf 100644 --- a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go +++ b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go @@ -225,8 +225,12 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C } } - if filters.FilterBy != "" && (filters.StartDate != "" || filters.EndDate != "") { - if filters.FilterBy == "so_date" { + if filters.StartDate != "" || filters.EndDate != "" { + filterBy := filters.FilterBy + if filterBy == "" { + filterBy = "so_date" + } + if filterBy == "so_date" { if filters.StartDate != "" { if startDate, err := utils.ParseDateString(filters.StartDate); err == nil { db = db.Where("marketings.so_date >= ?", startDate) @@ -238,7 +242,7 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C db = db.Where("marketings.so_date < ?", nextDate) } } - } else if filters.FilterBy == "realization_date" { + } else if filterBy == "realization_date" { if filters.StartDate != "" { if startDate, err := utils.ParseDateString(filters.StartDate); err == nil { db = db.Where("marketing_delivery_products.delivery_date >= ?", startDate) diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 579436eb..03b1b370 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -175,7 +175,6 @@ func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.Marketing for customerID := range customerGroups { transactions, err := s.CustomerPaymentRepo.GetCustomerPaymentTransactions(c.Context(), &customerID) if err != nil { - s.Log.Warnf("Failed to get transactions for customer %d: %v", customerID, err) continue } diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index e0161b5c..8047f718 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -26,7 +26,7 @@ type MarketingQuery struct { AreaId int64 `query:"area_id" validate:"omitempty"` LocationId int64 `query:"location_id" validate:"omitempty"` MarketingType string `query:"marketing_type" validate:"omitempty,oneof=ayam telur trading"` - FilterBy string `query:"filter_by" validate:"omitempty,oneof=so_date realization_date"` + FilterBy string `query:"filter_by" validate:"omitempty,oneof= so_date realization_date"` StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"` EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"` SortBy string `query:"sort_by" validate:"omitempty,oneof=so_date realization_date customer warehouse product sales_person vehicle_number sales_amount hpp_amount qty average_weight total_weight sales_price hpp_price aging_days"` From 67ecdbc1ddef985e2854fa196ccc8292224a81f2 Mon Sep 17 00:00:00 2001 From: giovanni Date: Tue, 20 Jan 2026 23:01:58 +0700 Subject: [PATCH 07/15] fix duplicate name, time type and phase id create phase activity --- .../services/phase-activity.service.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/internal/modules/master/phase-activities/services/phase-activity.service.go b/internal/modules/master/phase-activities/services/phase-activity.service.go index 1c6b15ce..c34e6a31 100644 --- a/internal/modules/master/phase-activities/services/phase-activity.service.go +++ b/internal/modules/master/phase-activities/services/phase-activity.service.go @@ -110,6 +110,17 @@ func (s *phaseActivityService) CreateOne(c *fiber.Ctx, req *validation.Create) ( return nil, fiber.NewError(fiber.StatusBadRequest, "time_type cannot be empty") } + existing, err := s.Repository.First(c.Context(), func(db *gorm.DB) *gorm.DB { + return db.Where("phase_id = ? AND name = ? AND time_type = ?", phase.Id, name, timeType) + }) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to check phaseActivity uniqueness: %+v", err) + return nil, err + } + if existing != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "phase activity with same name and time_type already exists") + } + createBody := &entity.PhaseActivity{ PhaseId: phase.Id, Name: name, From d0625e7d21d128c3bfb894c2895bc476c6605d9e Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 21 Jan 2026 09:45:19 +0700 Subject: [PATCH 08/15] FIX[BE]: fixing closing penjualan add sumary --- .../controllers/closing.controller.go | 4 +- .../closings/dto/closingMarketing.dto.go | 99 +++++++++++-------- 2 files changed, 59 insertions(+), 44 deletions(-) diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index a43687ac..8b79fc92 100644 --- a/internal/modules/closings/controllers/closing.controller.go +++ b/internal/modules/closings/controllers/closing.controller.go @@ -160,7 +160,7 @@ func (u *ClosingController) GetPenjualan(c *fiber.Ctx) error { Code: fiber.StatusOK, Status: "success", Message: "Get closing penjualan successfully", - Data: dto.ToPenjualanRealisasiResponseDTO(uint(projectFlockID), result), + Data: dto.ToPenjualanRealisasiResponseDTO(result), }) } @@ -190,7 +190,7 @@ func (u *ClosingController) GetPenjualanByProjectFlockKandang(c *fiber.Ctx) erro Code: fiber.StatusOK, Status: "success", Message: "Get closing penjualan by project flock kandang successfully", - Data: dto.ToPenjualanRealisasiResponseDTO(uint(projectFlockID), result), + Data: dto.ToPenjualanRealisasiResponseDTO(result), }) } diff --git a/internal/modules/closings/dto/closingMarketing.dto.go b/internal/modules/closings/dto/closingMarketing.dto.go index 1a790ad6..223b9d11 100644 --- a/internal/modules/closings/dto/closingMarketing.dto.go +++ b/internal/modules/closings/dto/closingMarketing.dto.go @@ -12,23 +12,31 @@ import ( // === Response DTO === type SalesDTO struct { - Id uint `json:"id"` - RealizationDate time.Time `json:"realization_date"` - Age int `json:"age"` - DoNumber string `json:"do_number"` - Product *productDTO.ProductRelationDTO `json:"product,omitempty"` - Customer *customerDTO.CustomerRelationDTO `json:"customer,omitempty"` - Qty float64 `json:"qty"` - Weight float64 `json:"weight"` - AvgWeight float64 `json:"avg_weight"` - Price float64 `json:"price"` - TotalPrice float64 `json:"total_price"` - Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"` - PaymentStatus string `json:"payment_status"` + Id uint `json:"id"` + RealizationDate time.Time `json:"realization_date"` + Age int `json:"age"` + DoNumber string `json:"do_number"` + Product *productDTO.ProductRelationDTO `json:"product,omitempty"` + Customer *customerDTO.CustomerRelationDTO `json:"customer,omitempty"` + Qty float64 `json:"qty"` + Weight float64 `json:"weight"` + AvgWeight float64 `json:"avg_weight"` + SalesPrice float64 `json:"sales_price"` + TotalSalesPrice float64 `json:"total_sales_price"` + ActualPrice float64 `json:"actual_price"` + TotalActualPrice float64 `json:"total_actual_price"` + Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"` +} +type SummaryDTO struct { + TotalSalesPrice float64 `json:"total_sales_price"` + AvgSalesPrice float64 `json:"avg_sales_price"` + TotalActualPrice float64 `json:"total_actual_price"` + AvgActualPrice float64 `json:"avg_actual_price"` } type PenjualanRealisasiResponseDTO struct { - Sales []SalesDTO `json:"sales"` + Sales []SalesDTO `json:"sales"` + Summary SummaryDTO `json:"summary"` } // === Mapper Functions === @@ -63,19 +71,38 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO { doNumber := deliveryOrdersDTO.GenerateDeliveryOrderNumber(e.MarketingProduct.Marketing.SoNumber, e.DeliveryDate, e.MarketingProduct.ProductWarehouse.Warehouse.Id) return SalesDTO{ - Id: e.Id, - RealizationDate: realizationDate, - Age: age, - DoNumber: doNumber, - Product: product, - Customer: customer, - Qty: e.UsageQty, - Weight: e.TotalWeight, - AvgWeight: e.AvgWeight, - Price: e.UnitPrice, - TotalPrice: e.TotalPrice, - Kandang: kandang, - PaymentStatus: "Paid", + Id: e.Id, + RealizationDate: realizationDate, + Age: age, + DoNumber: doNumber, + Product: product, + Customer: customer, + Qty: e.UsageQty, + Weight: e.TotalWeight, + AvgWeight: e.AvgWeight, + SalesPrice: e.MarketingProduct.UnitPrice, + TotalSalesPrice: e.MarketingProduct.TotalPrice, + ActualPrice: e.UnitPrice, + TotalActualPrice: e.TotalPrice, + Kandang: kandang, + } +} + +func ToSummaryDto(e []entity.MarketingDeliveryProduct) SummaryDTO { + + var totalSalesPrice, totalActualPrice float64 + count := len(e) + + for _, item := range e { + totalSalesPrice += item.MarketingProduct.TotalPrice + totalActualPrice += item.TotalPrice + } + + return SummaryDTO{ + TotalSalesPrice: totalSalesPrice, + TotalActualPrice: totalActualPrice, + AvgSalesPrice: totalSalesPrice / float64(count), + AvgActualPrice: totalActualPrice / float64(count), } } @@ -87,25 +114,13 @@ func ToSalesDTOs(e []entity.MarketingDeliveryProduct) []SalesDTO { return result } -func ToPenjualanRealisasiResponseDTO(projectFlockID uint, e []entity.MarketingDeliveryProduct) PenjualanRealisasiResponseDTO { - +func ToPenjualanRealisasiResponseDTO(e []entity.MarketingDeliveryProduct) PenjualanRealisasiResponseDTO { return PenjualanRealisasiResponseDTO{ - - Sales: ToSalesDTOs(e), + Sales: ToSalesDTOs(e), + Summary: ToSummaryDto(e), } } -func extractPeriodFromRealisasi(realisasi []entity.MarketingDeliveryProduct) int { - if len(realisasi) > 0 { - for _, item := range realisasi { - if item.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil { - return item.MarketingProduct.ProductWarehouse.ProjectFlockKandang.Period - } - } - } - return 0 -} - func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, deliveryDate *time.Time) int { if projectFlockKandang == nil || deliveryDate == nil || len(projectFlockKandang.Chickins) == 0 { return 0 From 894efa7aa54ccdcc88173eb35b19eea6af11c678 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 21 Jan 2026 09:46:03 +0700 Subject: [PATCH 09/15] FIX[BE]: fixing report penjualan add avg weight and price to response --- .../modules/repports/dto/repportMarketing.dto.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/internal/modules/repports/dto/repportMarketing.dto.go b/internal/modules/repports/dto/repportMarketing.dto.go index 751796e9..336b6576 100644 --- a/internal/modules/repports/dto/repportMarketing.dto.go +++ b/internal/modules/repports/dto/repportMarketing.dto.go @@ -41,7 +41,7 @@ type Summary struct { TotalQty int `json:"total_qty"` TotalWeightKg float64 `json:"total_weight_kg"` AverageWeightKg float64 `json:"average_weight_kg"` - AverageSalesAmount float64 `json:"average_sales_amount"` + AverageSalesPrice float64 `json:"average_sales_price"` TotalSalesAmount int64 `json:"total_sales_amount"` TotalHppAmount int64 `json:"total_hpp_amount"` TotalHppPricePerKg float64 `json:"total_hpp_price_per_kg"` @@ -180,7 +180,7 @@ func ToSummaryFromDTOItems(items []RepportMarketingItemDTO) *Summary { totalQty := 0 totalWeightKg := 0.0 - avgSalesAmount := 0.0 + avgSalesPrice := 0.0 avgWeightKg := 0.0 totalSalesAmount := int64(0) totalHppAmount := int64(0) @@ -190,6 +190,7 @@ func ToSummaryFromDTOItems(items []RepportMarketingItemDTO) *Summary { totalWeightKg += item.TotalWeightKg totalSalesAmount += int64(item.SalesAmount) totalHppAmount += int64(item.HppAmount) + avgSalesPrice += item.SalesPricePerKg } totalHppPricePerKg := float64(0) @@ -197,16 +198,19 @@ func ToSummaryFromDTOItems(items []RepportMarketingItemDTO) *Summary { totalHppPricePerKg = float64(totalHppAmount) / totalWeightKg } + if len(items) > 0 { + avgSalesPrice = avgSalesPrice / float64(len(items)) + } + if totalQty > 0 { avgWeightKg = totalWeightKg / float64(totalQty) - avgSalesAmount = float64(totalSalesAmount) / float64(totalQty) } return &Summary{ TotalQty: totalQty, TotalWeightKg: totalWeightKg, AverageWeightKg: avgWeightKg, - AverageSalesAmount: avgSalesAmount, + AverageSalesPrice: avgSalesPrice, TotalSalesAmount: totalSalesAmount, TotalHppAmount: totalHppAmount, TotalHppPricePerKg: totalHppPricePerKg, From c2d2701d728c8b33a8cfce0858d5f86d2c3c13c0 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 21 Jan 2026 09:57:44 +0700 Subject: [PATCH 10/15] FIX[BE] fix wrong calculation on summary report marketing --- internal/modules/closings/dto/closingMarketing.dto.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/internal/modules/closings/dto/closingMarketing.dto.go b/internal/modules/closings/dto/closingMarketing.dto.go index 223b9d11..eb6ff23f 100644 --- a/internal/modules/closings/dto/closingMarketing.dto.go +++ b/internal/modules/closings/dto/closingMarketing.dto.go @@ -90,19 +90,22 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO { func ToSummaryDto(e []entity.MarketingDeliveryProduct) SummaryDTO { - var totalSalesPrice, totalActualPrice float64 + var totalSalesPrice, totalActualPrice, sumSales, sumActual float64 count := len(e) for _, item := range e { totalSalesPrice += item.MarketingProduct.TotalPrice totalActualPrice += item.TotalPrice + sumSales += item.MarketingProduct.UnitPrice + sumActual += item.UnitPrice + } return SummaryDTO{ TotalSalesPrice: totalSalesPrice, TotalActualPrice: totalActualPrice, - AvgSalesPrice: totalSalesPrice / float64(count), - AvgActualPrice: totalActualPrice / float64(count), + AvgSalesPrice: sumSales / float64(count), + AvgActualPrice: sumActual / float64(count), } } From 16a0b848bcf33babb0656ad45eff9376ba1c25cb Mon Sep 17 00:00:00 2001 From: ragilap Date: Wed, 21 Jan 2026 13:06:45 +0700 Subject: [PATCH 11/15] [FIX/BE-US] adjustment recording --- .../dashboards/repositories/dashboard_stats.repository.go | 4 ++-- .../recordings/repositories/recording.repository.go | 5 +++-- .../production/recordings/services/recording.service.go | 6 +++--- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/internal/modules/dashboards/repositories/dashboard_stats.repository.go b/internal/modules/dashboards/repositories/dashboard_stats.repository.go index 7582680b..828dd96c 100644 --- a/internal/modules/dashboards/repositories/dashboard_stats.repository.go +++ b/internal/modules/dashboards/repositories/dashboard_stats.repository.go @@ -285,7 +285,7 @@ func (r *DashboardRepositoryImpl) SumEggProductionWeightGrams(ctx context.Contex db := r.DB().WithContext(ctx). Table("recording_eggs AS re"). - Select("COALESCE(SUM(re.qty * re.weight), 0)"). + Select("COALESCE(SUM(re.weight * 1000), 0)"). Joins("JOIN recordings AS r ON r.id = re.recording_id"). Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). @@ -648,7 +648,7 @@ func (r *DashboardRepositoryImpl) GetEggWeightWeeklyGrams(ctx context.Context, s Table("recording_eggs AS re"). Select(` ((r.day - 1) / 7 + 1) AS week, - COALESCE(SUM(re.qty * re.weight), 0) AS egg_weight_grams`). + COALESCE(SUM(re.weight * 1000), 0) AS egg_weight_grams`). Joins("JOIN recordings AS r ON r.id = re.recording_id"). Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index 9e783134..6cb65c6c 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -171,6 +171,7 @@ func (r *RecordingRepositoryImpl) GenerateNextDay(tx *gorm.DB, projectFlockKanda var days []int if err := tx.Model(&entity.Recording{}). Where("project_flock_kandangs_id = ?", projectFlockKandangId). + Where("deleted_at IS NULL"). Where("day IS NOT NULL"). Pluck("day", &days).Error; err != nil { return 0, err @@ -399,7 +400,7 @@ func (r *RecordingRepositoryImpl) GetEggSummaryByRecording(tx *gorm.DB, recordin } err = tx. Table("recording_eggs"). - Select("COALESCE(SUM(recording_eggs.qty), 0) AS total_qty, COALESCE(SUM(recording_eggs.qty * COALESCE(recording_eggs.weight, 0)), 0) AS total_weight_grams"). + Select("COALESCE(SUM(recording_eggs.qty), 0) AS total_qty, COALESCE(SUM(COALESCE(recording_eggs.weight, 0) * 1000), 0) AS total_weight_grams"). Where("recording_eggs.recording_id = ?", recordingID). Scan(&result).Error if err != nil { @@ -485,7 +486,7 @@ func (r *RecordingRepositoryImpl) GetTotalEggProductionWeightByProjectFlockID(ct var result float64 err := r.DB().WithContext(ctx). Table("recording_eggs"). - Select("COALESCE(SUM(recording_eggs.qty * recording_eggs.weight), 0) / 1000"). + Select("COALESCE(SUM(recording_eggs.weight), 0)"). Joins("JOIN recordings ON recordings.id = recording_eggs.recording_id"). Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = recordings.project_flock_kandangs_id"). Where("project_flock_kandangs.project_flock_id = ?", projectFlockID). diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 80611109..a5486ab7 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -1157,7 +1157,7 @@ func eggsMatch(existing []entity.RecordingEgg, incoming []validation.Egg) bool { } current := existingTotals[egg.ProductWarehouseId] current.Qty += egg.Qty - current.Weight += float64(egg.Qty) * weight + current.Weight += weight existingTotals[egg.ProductWarehouseId] = current } @@ -1169,7 +1169,7 @@ func eggsMatch(existing []entity.RecordingEgg, incoming []validation.Egg) bool { } current := incomingTotals[egg.ProductWarehouseId] current.Qty += egg.Qty - current.Weight += float64(egg.Qty) * weight + current.Weight += weight incomingTotals[egg.ProductWarehouseId] = current } @@ -1328,7 +1328,7 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm var eggMass float64 if remainingChick > 0 && totalEggWeightGrams > 0 { - eggMass = totalEggWeightGrams / remainingChick + eggMass = (totalEggWeightGrams / remainingChick) / 1000 updates["egg_mass"] = eggMass recording.EggMass = &eggMass } else { From e8a89f0f17aa8ecb465f421ee985a71bf22b41a1 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 21 Jan 2026 13:52:46 +0700 Subject: [PATCH 12/15] FEAT[BE]: update warehouse DTO references in product warehouse and add UOM preload --- .../dto/product_warehouse.dto.go | 143 ++++-------------- .../services/product_warehouse.service.go | 1 + .../marketing/dto/deliveryorder.dto.go | 11 +- 3 files changed, 35 insertions(+), 120 deletions(-) diff --git a/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go b/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go index 57a13021..b8f51c52 100644 --- a/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go +++ b/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go @@ -5,6 +5,7 @@ import ( entity "gitlab.com/mbugroup/lti-api.git/internal/entities" productDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/dto" + warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" ) // === DTO Structs === @@ -16,60 +17,29 @@ type ProductWarehouseRelationDTO struct { Quantity float64 `json:"quantity"` } -type ProductWarehousNestedDTO struct { - Id uint `json:"id"` - Product *productDTO.ProductRelationDTO `json:"product,omitempty"` - Warehouse *WarehouseRelationDTO `json:"warehouse,omitempty"` -} - type ProductWarehouseListDTO struct { ProductWarehouseRelationDTO - Product *productDTO.ProductRelationDTO `json:"product,omitempty"` - Warehouse *WarehouseRelationDTO `json:"warehouse,omitempty"` - ProjectFlockKandang *ProjectFlockKandangRelationDTO `json:"project_flock_kandang,omitempty"` - CreatedUser *UserRelationDTO `json:"created_user,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type UserRelationDTO struct { - Id uint `json:"id"` - Username string `json:"username"` + Product *productDTO.ProductRelationDTO `json:"product,omitempty"` + Warehouse *warehouseDTO.WarehouseRelationDTO `json:"warehouse,omitempty"` + ProjectFlockKandang *ProjectFlockKandangRelationDTO `json:"project_flock_kandang,omitempty"` + CreatedUser *UserRelationDTO `json:"created_user,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type ProductWarehouseDetailDTO struct { ProductWarehouseListDTO } -// Nested DTOs for relations -type ProductRelationDTO struct { - Id uint `json:"id"` - Name string `json:"name"` - Sku string `json:"sku"` - Flags []string `json:"flags"` +type ProductWarehousNestedDTO struct { + Id uint `json:"id"` + Product *productDTO.ProductRelationDTO `json:"product,omitempty"` + Warehouse *warehouseDTO.WarehouseRelationDTO `json:"warehouse,omitempty"` } -type WarehouseRelationDTO struct { - Id uint `json:"id"` - Name string `json:"name"` - Kandang *KandangRelationDTO `json:"kandang,omitempty"` - Location *LocationRelationDTO `json:"location,omitempty"` - Area *AreaRelationDTO `json:"area,omitempty"` -} - -type KandangRelationDTO struct { - Id uint `json:"id"` - Name string `json:"name"` -} - -type LocationRelationDTO struct { - Id uint `json:"id"` - Name string `json:"name"` -} - -type AreaRelationDTO struct { - Id uint `json:"id"` - Name string `json:"name"` +type UserRelationDTO struct { + Id uint `json:"id"` + Username string `json:"username"` } type ProjectFlockKandangRelationDTO struct { @@ -96,65 +66,28 @@ func ToProductWarehouseRelationDTO(e entity.ProductWarehouse) ProductWarehouseRe } } -func ToProductWarehouseNestedDTO(e entity.ProductWarehouse) ProductWarehousNestedDTO { - product := productDTO.ToProductRelationDTO(e.Product) - - return ProductWarehousNestedDTO{ - Id: e.Id, - Product: &product, - Warehouse: &WarehouseRelationDTO{ - Id: e.Warehouse.Id, - Name: e.Warehouse.Name, - }, - } -} - func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDTO { dto := ProductWarehouseListDTO{ ProductWarehouseRelationDTO: ToProductWarehouseRelationDTO(e), - // CreatedAt: e.CreatedAt, - // UpdatedAt: e.UpdatedAt, } // Map Product relation jika ada if e.Product.Id != 0 { product := productDTO.ToProductRelationDTO(e.Product) - // Tambahkan flock name ke product name jika ada project flock + // Create a copy with flock name appended if exists if e.ProjectFlockKandang != nil && e.ProjectFlockKandang.ProjectFlock.Id != 0 { - product.Name = product.Name + " (" + e.ProjectFlockKandang.ProjectFlock.FlockName + ")" + productCopy := product + productCopy.Name = product.Name + " (" + e.ProjectFlockKandang.ProjectFlock.FlockName + ")" + dto.Product = &productCopy + } else { + dto.Product = &product } - - dto.Product = &product } // Map Warehouse relation jika ada if e.Warehouse.Id != 0 { - warehouse := WarehouseRelationDTO{ - Id: e.Warehouse.Id, - Name: e.Warehouse.Name, - } - // Map Kandang jika ada - if e.Warehouse.Kandang != nil && e.Warehouse.Kandang.Id != 0 { - warehouse.Kandang = &KandangRelationDTO{ - Id: e.Warehouse.Kandang.Id, - Name: e.Warehouse.Kandang.Name, - } - } - // Map Location jika ada - if e.Warehouse.Location != nil && e.Warehouse.Location.Id != 0 { - warehouse.Location = &LocationRelationDTO{ - Id: e.Warehouse.Location.Id, - Name: e.Warehouse.Location.Name, - } - } - - if e.Warehouse.Area.Id != 0 { - warehouse.Area = &AreaRelationDTO{ - Id: e.Warehouse.Area.Id, - Name: e.Warehouse.Area.Name, - } - } + warehouse := warehouseDTO.ToWarehouseRelationDTO(e.Warehouse) dto.Warehouse = &warehouse } @@ -168,7 +101,6 @@ func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDT Period: e.ProjectFlockKandang.Period, } - // Map ProjectFlock jika ada if e.ProjectFlockKandang.ProjectFlock.Id != 0 { pfkDTO.ProjectFlock = &ProjectFlockRelationDTO{ Id: e.ProjectFlockKandang.ProjectFlock.Id, @@ -179,15 +111,6 @@ func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDT dto.ProjectFlockKandang = pfkDTO } - // Map CreatedUser relation jika ada - // if e.CreatedUser.Id != 0 { - // user := UserRelationDTO{ - // Id: e.CreatedUser.Id, - // Username: e.CreatedUser.Name, - // } - // dto.CreatedUser = &user - // } - return dto } @@ -205,23 +128,13 @@ func ToProductWarehouseDetailDTO(e entity.ProductWarehouse) ProductWarehouseDeta } } -func ToKandangRelationDTO(e entity.Kandang) KandangRelationDTO { - return KandangRelationDTO{ - Id: e.Id, - Name: e.Name, - } -} +func ToProductWarehouseNestedDTO(e entity.ProductWarehouse) ProductWarehousNestedDTO { + product := productDTO.ToProductRelationDTO(e.Product) + warehouse := warehouseDTO.ToWarehouseRelationDTO(e.Warehouse) -func ToLocationRelationDTO(e entity.Location) LocationRelationDTO { - return LocationRelationDTO{ - Id: e.Id, - Name: e.Name, - } -} - -func ToAreaRelationDTO(e entity.Area) AreaRelationDTO { - return AreaRelationDTO{ - Id: e.Id, - Name: e.Name, + return ProductWarehousNestedDTO{ + Id: e.Id, + Product: &product, + Warehouse: &warehouse, } } diff --git a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go index 152bfa24..ea194c36 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -40,6 +40,7 @@ func (s productWarehouseService) withRelations(db *gorm.DB) *gorm.DB { return db. Preload("Product.Flags"). Preload("Product"). + Preload("Product.Uom"). Preload("Warehouse"). Preload("Warehouse.Location"). Preload("Warehouse.Area"). diff --git a/internal/modules/marketing/dto/deliveryorder.dto.go b/internal/modules/marketing/dto/deliveryorder.dto.go index a6eea180..4bcbacca 100644 --- a/internal/modules/marketing/dto/deliveryorder.dto.go +++ b/internal/modules/marketing/dto/deliveryorder.dto.go @@ -9,6 +9,7 @@ import ( approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" productwarehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/dto" customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto" + warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) @@ -68,10 +69,10 @@ type DeliveryItemDTO struct { } type DeliveryGroupDTO struct { - DoNumber string `json:"do_number"` - DeliveryDate *time.Time `json:"delivery_date"` - Warehouse *productwarehouseDTO.WarehouseRelationDTO `json:"warehouse,omitempty"` - Deliveries []DeliveryItemDTO `json:"deliveries"` + DoNumber string `json:"do_number"` + DeliveryDate *time.Time `json:"delivery_date"` + Warehouse *warehouseDTO.WarehouseRelationDTO `json:"warehouse,omitempty"` + Deliveries []DeliveryItemDTO `json:"deliveries"` } type DeliveryMarketingProductDTO struct { @@ -286,7 +287,7 @@ func groupDeliveryProducts(products []MarketingDeliveryProductDTO, soNumber stri if !exists { group = &DeliveryGroupDTO{ DeliveryDate: product.DeliveryDate, - Warehouse: &productwarehouseDTO.WarehouseRelationDTO{ + Warehouse: &warehouseDTO.WarehouseRelationDTO{ Id: warehouseId, Name: warehouseName, }, From ca6d0b160b4db8248f028e22dddb5ba5fdeb6b01 Mon Sep 17 00:00:00 2001 From: giovanni Date: Wed, 21 Jan 2026 14:17:43 +0700 Subject: [PATCH 13/15] fix --- .../validations/daily-checklist.validation.go | 2 +- .../services/config-checklist.service.go | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/internal/modules/daily-checklists/validations/daily-checklist.validation.go b/internal/modules/daily-checklists/validations/daily-checklist.validation.go index 35ef8bb9..9157c4e2 100644 --- a/internal/modules/daily-checklists/validations/daily-checklist.validation.go +++ b/internal/modules/daily-checklists/validations/daily-checklist.validation.go @@ -52,7 +52,7 @@ type SummaryQuery struct { type ReportQuery struct { Page int `query:"page" validate:"required,number,min=1,gt=0"` - Limit int `query:"limit" validate:"required,number,min=1,max=100,gt=0"` + Limit int `query:"limit" validate:"required,number,min=1,gt=0"` Month int `query:"bulan" validate:"required,number,min=1,max=12"` Year int `query:"tahun" validate:"required,number,min=1900"` AreaID *uint `query:"area_id" validate:"omitempty"` diff --git a/internal/modules/master/config-checklists/services/config-checklist.service.go b/internal/modules/master/config-checklists/services/config-checklist.service.go index 0c96e3d5..97cd42c7 100644 --- a/internal/modules/master/config-checklists/services/config-checklist.service.go +++ b/internal/modules/master/config-checklists/services/config-checklist.service.go @@ -76,6 +76,9 @@ func (s *configChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create) if err := s.Validate.Struct(req); err != nil { return nil, err } + if req.PercentageThresholdBad > req.PercentageThresholdEnough { + return nil, fiber.NewError(fiber.StatusBadRequest, "percentage_threshold_bad cannot be greater than percentage_threshold_enough") + } date, err := time.Parse("2006-01-02", req.Date) if err != nil { @@ -100,6 +103,11 @@ func (s configChecklistService) UpdateOne(c *fiber.Ctx, req *validation.Update, if err := s.Validate.Struct(req); err != nil { return nil, err } + if req.PercentageThresholdBad != nil && req.PercentageThresholdEnough != nil { + if *req.PercentageThresholdBad > *req.PercentageThresholdEnough { + return nil, fiber.NewError(fiber.StatusBadRequest, "percentage_threshold_bad cannot be greater than percentage_threshold_enough") + } + } updateBody := make(map[string]any) From a73b44808ff2e6bebbc090d7e309f7016c9ede6d Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 21 Jan 2026 15:16:30 +0700 Subject: [PATCH 14/15] FIX[BE]: fixing wrong index data on adjustment. change get from stocklogs to adjustment table --- internal/entities/adjustment_stock.go | 29 ++---- .../adjustments/dto/adjustment.dto.go | 30 +++--- .../adjustment_stock.repository.go | 8 ++ .../services/adjustment.service.go | 99 ++++++++++++------- .../services/product_warehouse.service.go | 7 +- 5 files changed, 99 insertions(+), 74 deletions(-) diff --git a/internal/entities/adjustment_stock.go b/internal/entities/adjustment_stock.go index bbc93167..ef27d0c2 100644 --- a/internal/entities/adjustment_stock.go +++ b/internal/entities/adjustment_stock.go @@ -2,28 +2,17 @@ package entities import "time" -// AdjustmentStock tracks FIFO allocation for stock adjustments -// - For INCREASE adjustments (Stockable): Tracks stock added to warehouse -// - For DECREASE adjustments (Usable): Tracks stock consumed from warehouse type AdjustmentStock struct { - Id uint `gorm:"primaryKey"` - StockLogId uint `gorm:"column:stock_log_id;not null;index"` - ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` + Id uint `gorm:"primaryKey"` + StockLogId uint `gorm:"column:stock_log_id;not null;index"` + ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` + TotalQty float64 `gorm:"column:total_qty;default:0"` + TotalUsed float64 `gorm:"column:total_used;default:0"` + UsageQty float64 `gorm:"column:usage_qty;default:0"` + PendingQty float64 `gorm:"column:pending_qty;default:0"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"` - // === FIFO FIELDS FOR INCREASE ADJUSTMENT (Stockable) === - // Tracks stock added to warehouse via adjustment INCREASE - TotalQty float64 `gorm:"column:total_qty;default:0"` // Total lot quantity available - TotalUsed float64 `gorm:"column:total_used;default:0"` // Quantity already used from this lot - - // === FIFO FIELDS FOR DECREASE ADJUSTMENT (Usable) === - // Tracks stock consumed from warehouse via adjustment DECREASE - UsageQty float64 `gorm:"column:usage_qty;default:0"` // Actual quantity consumed - PendingQty float64 `gorm:"column:pending_qty;default:0"` // Pending quantity (waiting for stock) - - CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"` - UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"` - - // Relations StockLog *StockLog `gorm:"foreignKey:StockLogId;references:Id"` ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` } diff --git a/internal/modules/inventory/adjustments/dto/adjustment.dto.go b/internal/modules/inventory/adjustments/dto/adjustment.dto.go index 008f9966..1ce3da1b 100644 --- a/internal/modules/inventory/adjustments/dto/adjustment.dto.go +++ b/internal/modules/inventory/adjustments/dto/adjustment.dto.go @@ -100,38 +100,42 @@ func ToProductWarehouseDTO(e *entity.ProductWarehouse) *ProductWarehouseDTO { } } -func ToAdjustmentRelationDTO(e *entity.StockLog) AdjustmentRelationDTO { +func ToAdjustmentRelationDTO(e *entity.AdjustmentStock) AdjustmentRelationDTO { return AdjustmentRelationDTO{ Id: e.Id, - Note: e.Notes, - Increase: e.Increase, - Decrease: e.Decrease, + Note: e.StockLog.Notes, + Increase: e.TotalQty, + Decrease: e.UsageQty, ProductWarehouseId: e.ProductWarehouseId, ProductWarehouse: ToProductWarehouseDTO(e.ProductWarehouse), } } -func ToAdjustmentListDTO(e *entity.StockLog) AdjustmentListDTO { +func ToAdjustmentListDTO(e *entity.AdjustmentStock) AdjustmentListDTO { var createdUser *userDTO.UserRelationDTO - if e.CreatedUser != nil { + if e.StockLog != nil && e.StockLog.CreatedUser != nil { createdUser = &userDTO.UserRelationDTO{ - Id: e.CreatedUser.Id, - IdUser: e.CreatedUser.IdUser, - Email: e.CreatedUser.Email, - Name: e.CreatedUser.Name, + Id: e.StockLog.CreatedUser.Id, + IdUser: e.StockLog.CreatedUser.IdUser, + Email: e.StockLog.CreatedUser.Email, + Name: e.StockLog.CreatedUser.Name, } } + createdAt := time.Time{} + if e.StockLog != nil { + createdAt = e.StockLog.CreatedAt + } + return AdjustmentListDTO{ AdjustmentRelationDTO: ToAdjustmentRelationDTO(e), CreatedUser: createdUser, - CreatedAt: e.CreatedAt, + CreatedAt: createdAt, } } -func ToAdjustmentDetailDTO(e *entity.StockLog) AdjustmentDetailDTO { +func ToAdjustmentDetailDTO(e *entity.AdjustmentStock) AdjustmentDetailDTO { return AdjustmentDetailDTO{ AdjustmentListDTO: ToAdjustmentListDTO(e), - // UpdatedAt: e.UpdatedAt, } } diff --git a/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go b/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go index 8d62b05c..fa2685e7 100644 --- a/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go +++ b/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go @@ -33,6 +33,14 @@ func (r *adjustmentStockRepositoryImpl) CreateOne(ctx context.Context, data *ent func (r *adjustmentStockRepositoryImpl) GetByStockLogID(ctx context.Context, stockLogID uint) (*entity.AdjustmentStock, error) { var record entity.AdjustmentStock err := r.db.WithContext(ctx). + Preload("StockLog"). + Preload("StockLog.ProductWarehouse"). + Preload("StockLog.ProductWarehouse.Product"). + Preload("StockLog.ProductWarehouse.Warehouse"). + Preload("StockLog.CreatedUser"). + Preload("ProductWarehouse"). + Preload("ProductWarehouse.Product"). + Preload("ProductWarehouse.Warehouse"). Where("stock_log_id = ?", stockLogID). First(&record).Error if err != nil { diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index 71b985c2..c92d059b 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -25,9 +25,9 @@ import ( ) type AdjustmentService interface { - Adjustment(ctx *fiber.Ctx, req *validation.Create) (*entity.StockLog, error) - GetOne(ctx *fiber.Ctx, id uint) (*entity.StockLog, error) - AdjustmentHistory(ctx *fiber.Ctx, query *validation.Query) ([]*entity.StockLog, int64, error) + Adjustment(ctx *fiber.Ctx, req *validation.Create) (*entity.AdjustmentStock, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.AdjustmentStock, error) + AdjustmentHistory(ctx *fiber.Ctx, query *validation.Query) ([]*entity.AdjustmentStock, int64, error) } type adjustmentService struct { @@ -73,10 +73,8 @@ func (s *adjustmentService) withRelations(db *gorm.DB) *gorm.DB { Preload("CreatedUser") } -func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.StockLog, error) { - stockLog, err := s.StockLogsRepository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { - return s.withRelations(db).Preload("ProductWarehouse.Product.ProductCategory") - }) +func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.AdjustmentStock, error) { + adjustmentStock, err := s.AdjustmentStockRepository.GetByStockLogID(c.Context(), id) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found") @@ -85,14 +83,10 @@ func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.StockLog, err return nil, err } - if stockLog.LoggableType != string(utils.StockLogTypeAdjustment) { - return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found") - } - - return stockLog, nil + return adjustmentStock, nil } -func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*entity.StockLog, error) { +func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*entity.AdjustmentStock, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } @@ -111,12 +105,13 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e if req.Quantity <= 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Quantity must be greater than zero") } + transactionType := strings.ToUpper(req.TransactionType) if transactionType != string(utils.StockLogTransactionTypeIncrease) && transactionType != string(utils.StockLogTransactionTypeDecrease) { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transaction type") } - var createdLogId uint + var createdAdjustmentStockId uint var projectFlockKandangID *uint pfkID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.WarehouseID)) @@ -151,7 +146,8 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e return nil, err } err = s.StockLogsRepository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { - productWarehouse, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(ctx, uint(req.ProductID), uint(req.WarehouseID)) + + productWarehouse, err := s.ProductWarehouseRepo.FindByProductWarehouseAndPfk(ctx, uint(req.ProductID), uint(req.WarehouseID), projectFlockKandangID) if err != nil { s.Log.Errorf("Failed to get product warehouse: %+v", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse") @@ -171,14 +167,14 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e newLog.Increase = afterQuantity } else { if productWarehouse.Quantity < req.Quantity { - return fiber.NewError(fiber.StatusBadRequest, "Insufficient stock for adjustment") + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock. Current: %.2f, Requested: %.2f", productWarehouse.Quantity, req.Quantity)) } afterQuantity -= req.Quantity newLog.Decrease = afterQuantity } if err := s.StockLogsRepository.WithTx(tx).CreateOne(ctx, newLog, nil); err != nil { - s.Log.Errorf("Failed to create stock log: %+v", err) + return err } @@ -187,7 +183,7 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e ProductWarehouseId: productWarehouse.Id, } if err := s.AdjustmentStockRepository.WithTx(tx).CreateOne(ctx, adjustmentStock, nil); err != nil { - s.Log.Errorf("Failed to create adjustment stock: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create adjustment stock record") } @@ -212,7 +208,7 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e UsableID: adjustmentStock.Id, ProductWarehouseID: uint(productWarehouse.Id), Quantity: req.Quantity, - AllowPending: false, // Don't allow pending for adjustment + AllowPending: false, Tx: tx, }) if err != nil { @@ -220,24 +216,27 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e } } - // Update ProductWarehouse quantity (for backward compatibility/reporting) - + // LEGACY: Update ProductWarehouse quantity (for backward compatibility/reporting) 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 } - createdLogId = newLog.Id + createdAdjustmentStockId = adjustmentStock.Id return nil }) if err != nil { s.Log.Errorf("Transaction failed in CreateOne: %+v", err) + var fiberErr *fiber.Error + if errors.As(err, &fiberErr) { + return nil, err + } return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to process adjustment transaction") } - return s.GetOne(c, createdLogId) + return s.GetOne(c, createdAdjustmentStockId) } func (s *adjustmentService) getActiveProjectFlockKandangID(ctx context.Context, warehouseID uint) (uint, error) { @@ -266,13 +265,15 @@ func (s *adjustmentService) getActiveProjectFlockKandangID(ctx context.Context, return uint(projectFlockKandang.Id), nil } -func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Query) ([]*entity.StockLog, int64, error) { +func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Query) ([]*entity.AdjustmentStock, int64, error) { if err := s.Validate.Struct(query); err != nil { return nil, 0, err } offset := (query.Page - 1) * query.Limit + var isProductsExist bool isWarehousesExist, err := s.WarehouseRepo.IdExists(c.Context(), uint(query.WarehouseID)) + if err != nil { return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate warehouse") } @@ -280,7 +281,8 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu return nil, 0, fiber.NewError(fiber.StatusNotFound, "Warehouse not found") } - isProductsExist, err := s.ProductRepo.IdExists(c.Context(), uint(query.ProductID)) + isProductsExist, err = s.ProductRepo.IdExists(c.Context(), uint(query.ProductID)) + if err != nil { s.Log.Errorf("Failed to check product existence: %+v", err) return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product") @@ -289,28 +291,51 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu return nil, 0, fiber.NewError(fiber.StatusNotFound, "Product not found") } - stockLogs, total, err := s.StockLogsRepository.GetAll(c.Context(), offset, query.Limit, func(db *gorm.DB) *gorm.DB { + var adjustmentStocks []entity.AdjustmentStock + var total int64 - db = s.withRelations(db) + q := s.AdjustmentStockRepository.DB().WithContext(c.Context()).Model(&entity.AdjustmentStock{}). + Preload("StockLog"). + Preload("StockLog.ProductWarehouse"). + Preload("StockLog.ProductWarehouse.Product"). + Preload("StockLog.ProductWarehouse.Warehouse"). + Preload("StockLog.CreatedUser"). + Preload("ProductWarehouse"). + Preload("ProductWarehouse.Product"). + Preload("ProductWarehouse.Warehouse") - db = db.Where("loggable_type = ?", string(utils.StockLogTypeAdjustment)) + if query.ProductID > 0 { + q = q.Joins("JOIN stock_logs ON stock_logs.id = adjustment_stocks.stock_log_id"). + Joins("JOIN product_warehouses ON product_warehouses.id = stock_logs.product_warehouse_id"). + Where("product_warehouses.product_id = ?", query.ProductID) + } - if query.TransactionType != "" { - db = db.Where("transaction_type = ?", strings.ToUpper(query.TransactionType)) - } - db = s.StockLogsRepository.ApplyProductWarehouseFilters(db, uint(query.ProductID), uint(query.WarehouseID)) + if query.WarehouseID > 0 { + q = q.Joins("JOIN stock_logs ON stock_logs.id = adjustment_stocks.stock_log_id"). + Joins("JOIN product_warehouses ON product_warehouses.id = stock_logs.product_warehouse_id"). + Where("product_warehouses.warehouse_id = ?", query.WarehouseID) + } - return db.Order("created_at DESC") - }) + if query.TransactionType != "" { + q = q.Joins("JOIN stock_logs ON stock_logs.id = adjustment_stocks.stock_log_id"). + Where("stock_logs.transaction_type = ?", strings.ToUpper(query.TransactionType)) + } + + if err = q.Count(&total).Error; err != nil { + s.Log.Errorf("Failed to get adjustments: %+v", err) + return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to get adjustment history") + } + + err = q.Offset(offset).Limit(query.Limit).Order("created_at DESC").Find(&adjustmentStocks).Error if err != nil { s.Log.Errorf("Failed to get adjustments: %+v", err) return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to get adjustment history") } - result := make([]*entity.StockLog, len(stockLogs)) - for i, v := range stockLogs { - result[i] = &v + result := make([]*entity.AdjustmentStock, len(adjustmentStocks)) + for i := range adjustmentStocks { + result[i] = &adjustmentStocks[i] } return result, total, nil diff --git a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go index ea194c36..5b89808c 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -3,15 +3,14 @@ package service import ( "errors" + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/validations" kandangrepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" - - "github.com/go-playground/validator/v10" - "github.com/gofiber/fiber/v2" - "github.com/sirupsen/logrus" "gorm.io/gorm" ) From 32a8557a3b52324d9a677a291a61711765d6f02c Mon Sep 17 00:00:00 2001 From: M1 AIR Date: Wed, 21 Jan 2026 15:56:58 +0700 Subject: [PATCH 15/15] Change rules cicd no conflicts --- .gitlab-ci.yml | 104 ++++++----------------------------- ci/development.yml | 90 ++++++++++++++++++++++++++++++ ci/production.yml | 133 +++++++++++++++++++++++++++++++++++++++++++++ ci/staging.yml | 133 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 373 insertions(+), 87 deletions(-) create mode 100644 ci/development.yml create mode 100644 ci/production.yml create mode 100644 ci/staging.yml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 53f28b3e..aa0dc969 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,90 +1,20 @@ -stages: - - deploy +workflow: + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + - if: '$CI_COMMIT_BRANCH == "development"' + - if: '$CI_COMMIT_BRANCH == "staging"' + - if: '$CI_COMMIT_BRANCH == "production"' + - when: never -deploy-dev: - stage: deploy - image: alpine:3.20 - variables: - DEPLOY_APP: "LTI-MBUGROUP" - # Opsional: kalau pakai submodule, ini bikin clone submodule pakai SSH juga - GIT_SUBMODULE_STRATEGY: recursive - GIT_DEPTH: "1" +include: + - local: "ci/development.yml" + rules: + - if: '$CI_COMMIT_BRANCH == "development"' - before_script: - - echo "🧰 Installing dependencies..." - - apk update && apk add --no-cache openssh git curl bash + - local: "ci/staging.yml" + rules: + - if: '$CI_COMMIT_BRANCH == "staging"' - # Setup SSH di runner - - mkdir -p ~/.ssh - - echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa - - chmod 600 ~/.ssh/id_rsa - - eval "$(ssh-agent -s)" - - ssh-add ~/.ssh/id_rsa - - # Trust host keys (server + gitlab) biar SSH gak nanya interaktif - - ssh-keyscan -H "$SERVER_IP" >> ~/.ssh/known_hosts - - ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts - - script: - - echo "🚀 Deploying latest code to $SERVER_USER@$SERVER_IP" - - - > - if ssh -o StrictHostKeyChecking=no "$SERVER_USER@$SERVER_IP" " - set -e - - cd /home/devops/docker/deployment/development/lti-api - - # Pastikan remote origin SSH (antisipasi kalau pernah ke-set HTTPS) - git remote set-url origin git@gitlab.com:mbugroup/lti-api.git - - # Pastikan server percaya gitlab.com juga (untuk git fetch via SSH) - mkdir -p ~/.ssh - ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts - - # Fetch/reset pakai SSH - GIT_SSH_COMMAND='ssh -o StrictHostKeyChecking=no' git fetch origin development - git reset --hard origin/development - - docker compose restart dev-api-lti || docker compose up -d dev-api-lti - "; then - STATUS='success'; - else - STATUS='failed'; - fi; - - RUN_URL="${CI_PROJECT_URL}/-/pipelines/${CI_PIPELINE_ID}"; - - if [ "$STATUS" = "success" ]; then - COLOR=3066993; - TITLE="✅ Deployment API Succeeded"; - DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` completed successfully."; - else - COLOR=15158332; - TITLE="❌ Deployment API Failed Gaes"; - DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` failed."; - fi; - - echo "{ - \"username\": \"CI Bot\", - \"embeds\": [{ - \"title\": \"$TITLE\", - \"description\": \"$DESC\", - \"color\": $COLOR, - \"fields\": [ - {\"name\": \"Repository\", \"value\": \"${CI_PROJECT_PATH}\", \"inline\": true}, - {\"name\": \"Actor\", \"value\": \"${GITLAB_USER_LOGIN}\", \"inline\": true}, - {\"name\": \"Commit\", \"value\": \"${CI_COMMIT_SHA}\", \"inline\": false}, - {\"name\": \"Pipeline\", \"value\": \"[Open run](${RUN_URL})\", \"inline\": false} - ] - }] - }" > payload.json; - - echo "📡 Sending notification to Discord..."; - curl -sS -H "Content-Type: application/json" \ - -d @payload.json "$DISCORD_WEBHOOK_URL"; - - only: - - development - - environment: - name: development \ No newline at end of file + - local: "ci/production.yml" + rules: + - if: '$CI_COMMIT_BRANCH == "production"' diff --git a/ci/development.yml b/ci/development.yml new file mode 100644 index 00000000..43d574b9 --- /dev/null +++ b/ci/development.yml @@ -0,0 +1,90 @@ +stages: + - deploy + +deploy-dev: + stage: deploy + image: alpine:3.20 + variables: + DEPLOY_APP: "LTI-MBUGROUP" + # Opsional: kalau pakai submodule, ini bikin clone submodule pakai SSH juga + GIT_SUBMODULE_STRATEGY: recursive + GIT_DEPTH: "1" + + before_script: + - echo "🧰 Installing dependencies..." + - apk update && apk add --no-cache openssh git curl bash + + # Setup SSH di runner + - mkdir -p ~/.ssh + - echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa + - chmod 600 ~/.ssh/id_rsa + - eval "$(ssh-agent -s)" + - ssh-add ~/.ssh/id_rsa + + # Trust host keys (server + gitlab) biar SSH gak nanya interaktif + - ssh-keyscan -H "$SERVER_IP" >> ~/.ssh/known_hosts + - ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts + + script: + - echo "🚀 Deploying latest code to $SERVER_USER@$SERVER_IP" + + - > + if ssh -o StrictHostKeyChecking=no "$SERVER_USER@$SERVER_IP" " + set -e + + cd /home/devops/docker/deployment/development/lti-api + + # Pastikan remote origin SSH (antisipasi kalau pernah ke-set HTTPS) + git remote set-url origin git@gitlab.com:mbugroup/lti-api.git + + # Pastikan server percaya gitlab.com juga (untuk git fetch via SSH) + mkdir -p ~/.ssh + ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts + + # Fetch/reset pakai SSH + GIT_SSH_COMMAND='ssh -o StrictHostKeyChecking=no' git fetch origin development + git reset --hard origin/development + + docker compose restart dev-api-lti || docker compose up -d dev-api-lti + "; then + STATUS='success'; + else + STATUS='failed'; + fi; + + RUN_URL="${CI_PROJECT_URL}/-/pipelines/${CI_PIPELINE_ID}"; + + if [ "$STATUS" = "success" ]; then + COLOR=3066993; + TITLE="✅ Deployment API Succeeded"; + DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` completed successfully."; + else + COLOR=15158332; + TITLE="❌ Deployment API Failed Gaes"; + DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` failed."; + fi; + + echo "{ + \"username\": \"CI Bot\", + \"embeds\": [{ + \"title\": \"$TITLE\", + \"description\": \"$DESC\", + \"color\": $COLOR, + \"fields\": [ + {\"name\": \"Repository\", \"value\": \"${CI_PROJECT_PATH}\", \"inline\": true}, + {\"name\": \"Actor\", \"value\": \"${GITLAB_USER_LOGIN}\", \"inline\": true}, + {\"name\": \"Commit\", \"value\": \"${CI_COMMIT_SHA}\", \"inline\": false}, + {\"name\": \"Pipeline\", \"value\": \"[Open run](${RUN_URL})\", \"inline\": false} + ] + }] + }" > payload.json; + + echo "📡 Sending notification to Discord..."; + curl -sS -H "Content-Type: application/json" \ + -d @payload.json "$DISCORD_WEBHOOK_URL"; + + only: + - development + + environment: + name: development diff --git a/ci/production.yml b/ci/production.yml new file mode 100644 index 00000000..511a9eff --- /dev/null +++ b/ci/production.yml @@ -0,0 +1,133 @@ +stages: + - build + - migrate + - deploy + - seed + +default: + tags: + - self-hosted-prod + +workflow: + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' + when: always + - when: never + +variables: + DOCKER_BUILDKIT: "1" + + IMAGE_TAG: "production_${CI_COMMIT_SHORT_SHA}" + IMAGE_NAME: "${CI_REGISTRY_IMAGE}:${IMAGE_TAG}" + IMAGE_LATEST: "${CI_REGISTRY_IMAGE}:production_latest" + + DEPLOY_DIR: "/opt/deploy/lti" + COMPOSE_FILE: "docker-compose.yaml" + +# ========================= +# BUILD (AUTO) +# ========================= +build_production: + stage: build + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' + script: | + set -e + docker info + + echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" + + echo "✅ Build image: $IMAGE_NAME" + docker build -t "$IMAGE_NAME" -f Dockerfile . + + echo "✅ Push image: $IMAGE_NAME" + docker push "$IMAGE_NAME" + + echo "✅ Tag latest: $IMAGE_LATEST" + docker tag "$IMAGE_NAME" "$IMAGE_LATEST" + docker push "$IMAGE_LATEST" + + +# ========================= +# MIGRATE (PRODUCTION - MANUAL) +# ========================= +migrate_production: + stage: migrate + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' + when: manual + allow_failure: false + needs: + - job: build_production + artifacts: false + script: | + set -e + cd /opt/deploy/lti + test -f .env || (echo "❌ .env not found" && exit 1) + + set -a + . ./.env + set +a + + # Validasi env wajib + : "${DB_HOST:?DB_HOST not set}" + : "${DB_PORT:?DB_PORT not set}" + : "${DB_USER:?DB_USER not set}" + : "${DB_PASSWORD:?DB_PASSWORD not set}" + : "${DB_NAME:?DB_NAME not set}" + + DB_SSLMODE="${DB_SSLMODE:-require}" + export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE}" + + echo "✅ Running migrations (production)..." + docker run --rm \ + -v "$CI_PROJECT_DIR/internal/database/migrations:/migrations:ro" \ + migrate/migrate:v4.15.2 \ + -path=/migrations -database "$DATABASE_URL" up + + +# ========================= +# DEPLOY (AUTO) +# ========================= +deploy_production: + stage: deploy + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' + needs: + - job: migrate_production + artifacts: false + - job: build_production + artifacts: false + script: | + set -e + docker info + echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" + + cd "$DEPLOY_DIR" + test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1) + test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1) + + docker compose -f "$COMPOSE_FILE" pull + docker compose -f "$COMPOSE_FILE" up -d --force-recreate + docker image prune -f + + +# ========================= +# SEED (MANUAL) +# ========================= +seed_production: + stage: seed + rules: + - if: '$CI_COMMIT_BRANCH == "production"' + when: manual + script: | + set -e + cd /opt/deploy/lti + test -f .env || (echo "❌ .env not found" && exit 1) + + echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" + + docker compose --env-file .env pull seed + docker compose --env-file .env run --rm seed + + diff --git a/ci/staging.yml b/ci/staging.yml new file mode 100644 index 00000000..511a9eff --- /dev/null +++ b/ci/staging.yml @@ -0,0 +1,133 @@ +stages: + - build + - migrate + - deploy + - seed + +default: + tags: + - self-hosted-prod + +workflow: + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' + when: always + - when: never + +variables: + DOCKER_BUILDKIT: "1" + + IMAGE_TAG: "production_${CI_COMMIT_SHORT_SHA}" + IMAGE_NAME: "${CI_REGISTRY_IMAGE}:${IMAGE_TAG}" + IMAGE_LATEST: "${CI_REGISTRY_IMAGE}:production_latest" + + DEPLOY_DIR: "/opt/deploy/lti" + COMPOSE_FILE: "docker-compose.yaml" + +# ========================= +# BUILD (AUTO) +# ========================= +build_production: + stage: build + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' + script: | + set -e + docker info + + echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" + + echo "✅ Build image: $IMAGE_NAME" + docker build -t "$IMAGE_NAME" -f Dockerfile . + + echo "✅ Push image: $IMAGE_NAME" + docker push "$IMAGE_NAME" + + echo "✅ Tag latest: $IMAGE_LATEST" + docker tag "$IMAGE_NAME" "$IMAGE_LATEST" + docker push "$IMAGE_LATEST" + + +# ========================= +# MIGRATE (PRODUCTION - MANUAL) +# ========================= +migrate_production: + stage: migrate + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' + when: manual + allow_failure: false + needs: + - job: build_production + artifacts: false + script: | + set -e + cd /opt/deploy/lti + test -f .env || (echo "❌ .env not found" && exit 1) + + set -a + . ./.env + set +a + + # Validasi env wajib + : "${DB_HOST:?DB_HOST not set}" + : "${DB_PORT:?DB_PORT not set}" + : "${DB_USER:?DB_USER not set}" + : "${DB_PASSWORD:?DB_PASSWORD not set}" + : "${DB_NAME:?DB_NAME not set}" + + DB_SSLMODE="${DB_SSLMODE:-require}" + export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE}" + + echo "✅ Running migrations (production)..." + docker run --rm \ + -v "$CI_PROJECT_DIR/internal/database/migrations:/migrations:ro" \ + migrate/migrate:v4.15.2 \ + -path=/migrations -database "$DATABASE_URL" up + + +# ========================= +# DEPLOY (AUTO) +# ========================= +deploy_production: + stage: deploy + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' + needs: + - job: migrate_production + artifacts: false + - job: build_production + artifacts: false + script: | + set -e + docker info + echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" + + cd "$DEPLOY_DIR" + test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1) + test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1) + + docker compose -f "$COMPOSE_FILE" pull + docker compose -f "$COMPOSE_FILE" up -d --force-recreate + docker image prune -f + + +# ========================= +# SEED (MANUAL) +# ========================= +seed_production: + stage: seed + rules: + - if: '$CI_COMMIT_BRANCH == "production"' + when: manual + script: | + set -e + cd /opt/deploy/lti + test -f .env || (echo "❌ .env not found" && exit 1) + + echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" + + docker compose --env-file .env pull seed + docker compose --env-file .env run --rm seed + +