From cbd3047a171df7d359f8e7e506252053ac8ba08a Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Mon, 22 Dec 2025 13:51:27 +0700 Subject: [PATCH 01/12] Feat[BE]: on chickin laying covert Pullet to Layer --- .../chickins/services/chickin.service.go | 20 +++---------------- .../services/project_flock_kandang.service.go | 12 +++-------- .../repports/services/repport.service.go | 1 - 3 files changed, 6 insertions(+), 27 deletions(-) diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index b8eefa49..0c513e88 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -188,7 +188,6 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) - productWarehouseTx := s.ProductWarehouseRepo.WithTx(dbTransaction) if err := s.Repository.WithTx(dbTransaction).CreateMany(c.Context(), newChikins, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create chickins") @@ -199,19 +198,6 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti return fiber.NewError(fiber.StatusInternalServerError, "Failed to get latest approval") } - if category == string(utils.ProjectFlockCategoryLaying) { - for _, chickin := range newChikins { - updates := map[string]any{"qty": gorm.Expr("qty - ?", chickin.PendingUsageQty)} - - if err := productWarehouseTx.PatchOne(c.Context(), chickin.ProductWarehouseId, updates, nil); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Product warehouse %d not found", chickin.ProductWarehouseId)) - } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to update product warehouse quantity") - } - } - } - var approvalAction entity.ApprovalAction if isFirstTime { approvalAction = entity.ApprovalActionCreated @@ -472,9 +458,9 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit } pfkID := approvableID - targetPW, err := s.getOrCreateProductWarehouse(c, warehouse.Id, "PULLET", dbTransaction, actorID, &pfkID) + targetPW, err := s.getOrCreateProductWarehouse(c, warehouse.Id, "LAYER", dbTransaction, actorID, &pfkID) if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get/create PULLET product warehouse") + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get/create LAYER product warehouse") } if err := s.convertChickinsToTarget(c, chickins, targetPW, dbTransaction, actorID); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to convert chickins to target") @@ -549,7 +535,7 @@ func (s *chickinService) getOrCreateProductWarehouse(ctx *fiber.Ctx, warehouseId products, err := s.ProductWarehouseRepo.GetByFlagAndWarehouseID(ctx.Context(), categoryCode, warehouseId) if err == nil && len(products) > 0 { existingPW := &products[0] - // Update project_flock_kandang_id if not already set + if existingPW.ProjectFlockKandangId == nil && projectFlockKandangId != nil { existingPW.ProjectFlockKandangId = projectFlockKandangId if err := s.ProductWarehouseRepo.WithTx(dbTransaction).UpdateOne(ctx.Context(), existingPW.Id, existingPW, nil); err != nil { diff --git a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go index cf2d87ee..66fee8ce 100644 --- a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go +++ b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go @@ -555,6 +555,7 @@ func (s projectFlockKandangService) calculateAvailableQuantityForProductWarehous availableQty := productWarehouse.Quantity if projectFlockKandang.ProjectFlock.Category == string(utils.ProjectFlockCategoryGrowing) { + var totalPendingQty float64 for _, chickin := range projectFlockKandang.Chickins { @@ -568,15 +569,8 @@ func (s projectFlockKandangService) calculateAvailableQuantityForProductWarehous availableQty = 0 } } else if projectFlockKandang.ProjectFlock.Category == string(utils.ProjectFlockCategoryLaying) { - var totalPopulation float64 - var totalPendingQty float64 - populations, err := s.PopulationRepo.GetByProjectFlockKandangIDAndProductWarehouseID(c.Context(), projectFlockKandang.Id, productWarehouse.Id) - if err == nil { - for _, pop := range populations { - totalPopulation += pop.TotalQty - } - } + var totalPendingQty float64 for _, chickin := range projectFlockKandang.Chickins { if chickin.ProductWarehouseId == productWarehouse.Id && chickin.DeletedAt.Time.IsZero() && chickin.PendingUsageQty > 0 { @@ -584,7 +578,7 @@ func (s projectFlockKandangService) calculateAvailableQuantityForProductWarehous } } - availableQty = productWarehouse.Quantity - totalPopulation - totalPendingQty + availableQty = productWarehouse.Quantity - totalPendingQty if availableQty < 0 { availableQty = 0 } diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index fbca69b7..f9642bd2 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -138,7 +138,6 @@ func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.Marketing func (s *repportService) calculateHppPricePerKg(ctx context.Context, projectFlockID uint, category string) float64 { totalCost := s.getTotalProjectCost(ctx, projectFlockID) if totalCost == 0 { - s.Log.Warnf("HPP calculation: No cost found for project flock ID %d. Check if purchase items are linked to project_flock_kandang_id", projectFlockID) return 0 } From 3d13cd966a206e32893cb43d7e11466f9e3c01b0 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 23 Dec 2025 12:26:35 +0700 Subject: [PATCH 02/12] Feat[BE]: integrate FIFO service for chickin stock management --- .../modules/production/chickins/module.go | 32 ++- .../chickins/services/chickin.service.go | 249 ++++++++++-------- internal/utils/fifo/constants.go | 1 + 3 files changed, 169 insertions(+), 113 deletions(-) diff --git a/internal/modules/production/chickins/module.go b/internal/modules/production/chickins/module.go index f4e91056..df0ebd26 100644 --- a/internal/modules/production/chickins/module.go +++ b/internal/modules/production/chickins/module.go @@ -2,6 +2,7 @@ package chickins import ( "fmt" + "strings" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -9,6 +10,7 @@ import ( commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" @@ -36,16 +38,44 @@ func (ChickinModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * projectflockpopulationrepo := rProjectFlock.NewProjectFlockPopulationRepository(db) projectFlockRepo := rProjectFlock.NewProjectflockRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) userRepo := rUser.NewUserRepository(db) + fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) + if err := fifoService.RegisterUsable(fifo.UsableConfig{ + Key: fifo.UsablekeyProjectChickin, + Table: "project_chickins", + Columns: fifo.UsableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + UsageQuantity: "usage_qty", + PendingQuantity: "pending_usage_qty", + CreatedAt: "id", + }, + }); err != nil { + if !strings.Contains(strings.ToLower(err.Error()), "already registered") { + panic(fmt.Sprintf("failed to register chickin usable workflow: %v", err)) + } + } + approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowChickin, utils.ChickinApprovalSteps); err != nil { panic(fmt.Sprintf("failed to register chickin approval workflow: %v", err)) } - chickinService := sChickin.NewChickinService(chickinRepo, kandangRepo, warehouseRepo, productWarehouseRepo, projectFlockRepo, projectflockkandangrepo, projectflockpopulationrepo, chickinDetailRepo, validate) + chickinService := sChickin.NewChickinService( + chickinRepo, + kandangRepo, + warehouseRepo, + productWarehouseRepo, + projectFlockRepo, + projectflockkandangrepo, + projectflockpopulationrepo, + chickinDetailRepo, + validate, + fifoService) userService := sUser.NewUserService(userRepo, validate) ChickinRoutes(router, userService, chickinService) diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 0c513e88..fe78080b 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -1,6 +1,7 @@ package service import ( + "context" "errors" "fmt" "strings" @@ -16,6 +17,7 @@ import ( validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/validations" rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -23,6 +25,8 @@ import ( "gorm.io/gorm" ) +var chickinUsableKey = fifo.UsablekeyProjectChickin + type ChickinService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectChickin, int64, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectChickin, error) @@ -43,9 +47,10 @@ type chickinService struct { ProjectflockKandangRepo rProjectFlock.ProjectFlockKandangRepository ProjectflockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository ProjectChickinDetailRepo repository.ProjectChickinDetailRepository + FifoSvc commonSvc.FifoService } -func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo KandangRepo.KandangRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, projectFlockRepo rProjectFlock.ProjectflockRepository, projectflockkandangRepo rProjectFlock.ProjectFlockKandangRepository, projectflockpopulationRepo rProjectFlock.ProjectFlockPopulationRepository, projectChickinDetailRepo repository.ProjectChickinDetailRepository, validate *validator.Validate) ChickinService { +func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo KandangRepo.KandangRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, projectFlockRepo rProjectFlock.ProjectflockRepository, projectflockkandangRepo rProjectFlock.ProjectFlockKandangRepository, projectflockpopulationRepo rProjectFlock.ProjectFlockPopulationRepository, projectChickinDetailRepo repository.ProjectChickinDetailRepository, validate *validator.Validate, fifoSvc commonSvc.FifoService) ChickinService { return &chickinService{ Log: utils.Log, Validate: validate, @@ -57,6 +62,7 @@ func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo Kan ProjectflockKandangRepo: projectflockkandangRepo, ProjectflockPopulationRepo: projectflockpopulationRepo, ProjectChickinDetailRepo: projectChickinDetailRepo, + FifoSvc: fifoSvc, } } @@ -124,8 +130,6 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti return nil, fiber.NewError(fiber.StatusNotFound, "Warehouse for Kandang not found") } - category := strings.ToUpper(strings.TrimSpace(projectFlockKandang.ProjectFlock.Category)) - actorID, err := m.ActorIDFromContext(c) if err != nil { return nil, err @@ -152,20 +156,16 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid ChickInDate format for product warehouse %d", chickinReq.ProductWarehouseId)) } - availableQty, err := s.calculateAvailableQuantity(c, req.ProjectFlockKandangId, productWarehouse, category) - if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to calculate available quantity for product warehouse %d", chickinReq.ProductWarehouseId)) - } - + availableQty := productWarehouse.Quantity if availableQty <= 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("No available quantity for product warehouse %d", chickinReq.ProductWarehouseId)) + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("No available stock in product warehouse %d for chickin", chickinReq.ProductWarehouseId)) } newChickin := &entity.ProjectChickin{ ProjectFlockKandangId: req.ProjectFlockKandangId, ChickInDate: chickinDate, - UsageQty: 0, - PendingUsageQty: availableQty, + UsageQty: availableQty, + PendingUsageQty: 0, ProductWarehouseId: chickinReq.ProductWarehouseId, Notes: chickinReq.Note, CreatedBy: actorID, @@ -193,6 +193,25 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti return fiber.NewError(fiber.StatusInternalServerError, "Failed to create chickins") } + for _, chickin := range newChikins { + if err := s.ConsumeChickinStocks(c.Context(), dbTransaction, chickin); err != nil { + return err + } + + if chickin.PendingUsageQty > 0 { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot create chickin %d - insufficient stock. Required: %.0f, Available: %.0f, Pending: %.0f", chickin.Id, chickin.UsageQty+chickin.PendingUsageQty, chickin.UsageQty, chickin.PendingUsageQty)) + } + } + + warehouseDeltas := make(map[uint]float64) + for _, chickin := range newChikins { + warehouseDeltas[chickin.ProductWarehouseId] -= chickin.UsageQty + } + if err := s.adjustProductWarehouseQuantities(c.Context(), dbTransaction, warehouseDeltas); err != nil { + s.Log.Errorf("Failed to adjust product warehouses: %+v", err) + return err + } + latest, err := approvalSvcTx.LatestByTarget(c.Context(), utils.ApprovalWorkflowChickin, projectFlockKandang.Id, nil) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get latest approval") @@ -287,6 +306,27 @@ func (s chickinService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { + chickin, err := s.Repository.GetByID(c.Context(), id, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Chickin not found") + } + return err + } + + if chickin.UsageQty > 0 { + if err := s.ReleaseChickinStocks(c.Context(), s.Repository.DB(), chickin); err != nil { + return err + } + + warehouseDeltas := make(map[uint]float64) + warehouseDeltas[chickin.ProductWarehouseId] += chickin.UsageQty + if err := s.adjustProductWarehouseQuantities(c.Context(), s.Repository.DB(), warehouseDeltas); err != nil { + s.Log.Errorf("Failed to adjust product warehouses for deleted chickin %d: %+v", chickin.Id, err) + return err + } + } + if err := s.Repository.DeleteOne(c.Context(), id); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, "Chickin not found") @@ -297,54 +337,6 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { return nil } -func (s chickinService) calculateAvailableQuantity(ctx *fiber.Ctx, projectFlockKandangID uint, productWarehouse *entity.ProductWarehouse, category string) (float64, error) { - availableQty := productWarehouse.Quantity - - if category == string(utils.ProjectFlockCategoryGrowing) { - var totalPendingQty float64 - - chickins, err := s.Repository.GetByProjectFlockKandangID(ctx.Context(), projectFlockKandangID) - if err == nil { - for _, chickin := range chickins { - - if chickin.ProductWarehouseId == productWarehouse.Id && chickin.DeletedAt.Time.IsZero() && chickin.PendingUsageQty > 0 { - totalPendingQty += chickin.PendingUsageQty - } - } - } - - availableQty = productWarehouse.Quantity - totalPendingQty - if availableQty < 0 { - availableQty = 0 - } - } else if category == string(utils.ProjectFlockCategoryLaying) { - var totalPopulation float64 - var totalPendingQty float64 - - populations, err := s.ProjectflockPopulationRepo.GetByProjectFlockKandangIDAndProductWarehouseID(ctx.Context(), projectFlockKandangID, productWarehouse.Id) - if err == nil { - for _, pop := range populations { - totalPopulation += pop.TotalQty - } - } - chickins, err := s.Repository.GetByProjectFlockKandangID(ctx.Context(), projectFlockKandangID) - if err == nil { - for _, chickin := range chickins { - - if chickin.ProductWarehouseId == productWarehouse.Id && chickin.DeletedAt.Time.IsZero() && chickin.PendingUsageQty > 0 { - totalPendingQty += chickin.PendingUsageQty - } - } - } - availableQty = productWarehouse.Quantity - totalPopulation - totalPendingQty - if availableQty < 0 { - availableQty = 0 - } - } - - return availableQty, nil -} - func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entity.ProjectChickin, error) { if err := s.Validate.Struct(req); err != nil { return nil, err @@ -373,11 +365,10 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit } for _, id := range approvableIDs { - idCopy := id - if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "ProjectFlockKandang", ID: &idCopy, Exists: s.ProjectflockKandangRepo.IdExists}); err != nil { + + if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "ProjectFlockKandang", ID: &id, Exists: s.ProjectflockKandangRepo.IdExists}); err != nil { return nil, err } - latestApproval, err := approvalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowChickin, id, nil) if err != nil { @@ -400,7 +391,6 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) chickinRepoTx := repository.NewChickinRepository(dbTransaction) - productWarehouseTx := s.ProductWarehouseRepo.WithTx(dbTransaction) for _, approvableID := range approvableIDs { if _, err := approvalSvc.CreateApproval( @@ -477,27 +467,17 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit continue } - kandangForRejection, err := s.ProjectflockKandangRepo.GetByID(c.Context(), approvableID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("ProjectFlockKandang %d not found", approvableID)) - } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get ProjectFlockKandang") - } - - categoryForRejection := strings.ToUpper(strings.TrimSpace(kandangForRejection.ProjectFlock.Category)) - for _, chickin := range chickins { - if categoryForRejection == string(utils.ProjectFlockCategoryGrowing) { - updates := map[string]any{"qty": gorm.Expr("qty + ?", chickin.PendingUsageQty)} + if err := s.ReleaseChickinStocks(c.Context(), dbTransaction, &chickin); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to release stock for rejected chickin %d: %v", chickin.Id, err)) + } - if err := productWarehouseTx.PatchOne(c.Context(), chickin.ProductWarehouseId, updates, nil); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Product warehouse %d not found during rejection", chickin.ProductWarehouseId)) - } - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to restore product warehouse quantity for chickin %d", chickin.Id)) - } + warehouseDeltas := make(map[uint]float64) + warehouseDeltas[chickin.ProductWarehouseId] += chickin.UsageQty + if err := s.adjustProductWarehouseQuantities(c.Context(), dbTransaction, warehouseDeltas); err != nil { + s.Log.Errorf("Failed to adjust product warehouses for rejected chickin %d: %+v", chickin.Id, err) + return err } if err := chickinRepoTx.DeleteOne(c.Context(), chickin.Id); err != nil { @@ -558,7 +538,6 @@ func (s *chickinService) getOrCreateProductWarehouse(ctx *fiber.Ctx, warehouseId WarehouseId: warehouseId, ProjectFlockKandangId: projectFlockKandangId, Quantity: 0, - // CreatedBy: actorID, } if err := s.ProductWarehouseRepo.WithTx(dbTransaction).CreateOne(ctx.Context(), newPW, nil); err != nil { @@ -574,10 +553,10 @@ func (s *chickinService) convertChickinsToTarget(ctx *fiber.Ctx, chickins []enti return fmt.Errorf("invalid target product warehouse") } - chickinRepoTx := repository.NewChickinRepository(dbTransaction) - productWarehouseTx := s.ProductWarehouseRepo.WithTx(dbTransaction) ProjectFlockPopulationRepotx := s.ProjectflockPopulationRepo.WithTx(dbTransaction) + var totalQuantityAdded float64 + for _, chickin := range chickins { populationExists, err := ProjectFlockPopulationRepotx.ExistsByProjectChickinID(ctx.Context(), chickin.Id) @@ -590,34 +569,7 @@ func (s *chickinService) convertChickinsToTarget(ctx *fiber.Ctx, chickins []enti continue } - quantityToConvert := chickin.PendingUsageQty - - if err := chickinRepoTx.PatchOne(ctx.Context(), chickin.Id, map[string]any{ - "usage_qty": quantityToConvert, - "pending_usage_qty": 0, - }, nil); err != nil { - return fmt.Errorf("failed to update chickin %d qty: %w", chickin.Id, err) - } - - if chickin.ProductWarehouseId != targetPW.Id { - if err := productWarehouseTx.PatchOne(ctx.Context(), chickin.ProductWarehouseId, map[string]any{ - "qty": gorm.Expr("qty - ?", quantityToConvert), - }, nil); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Source product warehouse %d not found", chickin.ProductWarehouseId)) - } - return fmt.Errorf("failed to deduct source warehouse quantity for chickin %d: %w", chickin.Id, err) - } - } - - if err := productWarehouseTx.PatchOne(ctx.Context(), targetPW.Id, map[string]any{ - "qty": gorm.Expr("qty + ?", quantityToConvert), - }, nil); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Target product warehouse %d not found", targetPW.Id)) - } - return fmt.Errorf("failed to update target warehouse quantity: %w", err) - } + quantityToConvert := chickin.UsageQty population := &entity.ProjectFlockPopulation{ ProjectChickinId: chickin.Id, @@ -630,7 +582,80 @@ func (s *chickinService) convertChickinsToTarget(ctx *fiber.Ctx, chickins []enti if err := ProjectFlockPopulationRepotx.CreateOne(ctx.Context(), population, nil); err != nil { return err } + + totalQuantityAdded += quantityToConvert + } + + if totalQuantityAdded > 0 { + if err := s.ProductWarehouseRepo.AdjustQuantities(ctx.Context(), map[uint]float64{ + targetPW.Id: totalQuantityAdded, + }, func(db *gorm.DB) *gorm.DB { + return dbTransaction + }); err != nil { + return fmt.Errorf("failed to update target product warehouse quantity: %w", err) + } } return nil } + +func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin) error { + if chickin == nil || s.FifoSvc == nil { + return nil + } + + var desired float64 = chickin.UsageQty + + result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ + UsableKey: chickinUsableKey, + UsableID: chickin.Id, + ProductWarehouseID: chickin.ProductWarehouseId, + Quantity: desired, + AllowPending: false, + Tx: tx, + }) + if err != nil { + s.Log.Errorf("Failed to consume FIFO stock for chickin %d: %+v", chickin.Id, err) + return err + } + + if err := tx.Model(&entity.ProjectChickin{}).Where("id = ?", chickin.Id).Updates(map[string]interface{}{ + "usage_qty": result.UsageQuantity, + "pending_usage_qty": result.PendingQuantity, + }).Error; err != nil { + return err + } + + return nil +} + +func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin) error { + if chickin == nil || s.FifoSvc == nil { + return nil + } + + if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ + UsableKey: chickinUsableKey, + UsableID: chickin.Id, + Tx: tx, + }); err != nil { + s.Log.Errorf("Failed to release FIFO stock for chickin %d: %+v", chickin.Id, err) + return err + } + + if err := tx.Model(&entity.ProjectChickin{}).Where("id = ?", chickin.Id).Updates(map[string]interface{}{ + "usage_qty": 0, + "pending_usage_qty": 0, + }).Error; err != nil { + return err + } + + return nil +} + +func (s *chickinService) adjustProductWarehouseQuantities(ctx context.Context, tx *gorm.DB, deltas map[uint]float64) error { + if len(deltas) == 0 { + return nil + } + return s.ProductWarehouseRepo.AdjustQuantities(ctx, deltas, func(*gorm.DB) *gorm.DB { return tx }) +} diff --git a/internal/utils/fifo/constants.go b/internal/utils/fifo/constants.go index c47d3cd7..c1a79444 100644 --- a/internal/utils/fifo/constants.go +++ b/internal/utils/fifo/constants.go @@ -2,4 +2,5 @@ package fifo const ( UsableKeyRecordingStock UsableKey = "RECORDING_STOCK" + UsablekeyProjectChickin UsableKey = "PROJECT_CHICKIN" ) From c55fdb75a7a0ecf249785eb337e85c45d885ea4c Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 23 Dec 2025 14:10:08 +0700 Subject: [PATCH 03/12] Feat[BE]: add document handling to stock transfer process --- internal/entities/stock-transfer.go | 1 + internal/entities/stock_transfer_delivery.go | 34 ++++----- .../controllers/transfer.controller.go | 7 +- .../inventory/transfers/dto/transfer.dto.go | 25 ++++++- .../modules/inventory/transfers/module.go | 12 ++- .../transfers/services/transfer.service.go | 73 +++++++++++-------- 6 files changed, 95 insertions(+), 57 deletions(-) diff --git a/internal/entities/stock-transfer.go b/internal/entities/stock-transfer.go index e003d601..7da7a9f5 100644 --- a/internal/entities/stock-transfer.go +++ b/internal/entities/stock-transfer.go @@ -20,4 +20,5 @@ type StockTransfer struct { Details []StockTransferDetail `gorm:"foreignKey:StockTransferId"` Deliveries []StockTransferDelivery `gorm:"foreignKey:StockTransferId"` CreatedUser *User `gorm:"foreignKey:CreatedBy"` + Documents []Document `gorm:"foreignKey:DocumentableId;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` } diff --git a/internal/entities/stock_transfer_delivery.go b/internal/entities/stock_transfer_delivery.go index 3a7562ea..69324b65 100644 --- a/internal/entities/stock_transfer_delivery.go +++ b/internal/entities/stock_transfer_delivery.go @@ -4,20 +4,20 @@ import "time" // DETAIL EKSPEDISI type StockTransferDelivery struct { - Id uint64 `gorm:"primaryKey;autoIncrement"` - StockTransferId uint64 - SupplierId uint64 - VehiclePlate string - DriverName string - DocumentNumber string - DocumentPath string - ShippingCostItem float64 - ShippingCostTotal float64 - CreatedAt time.Time - UpdatedAt time.Time - DeletedAt *time.Time `gorm:"index"` - // Relations - StockTransfer *StockTransfer `gorm:"foreignKey:StockTransferId"` - Supplier *Supplier `gorm:"foreignKey:SupplierId"` - Items []StockTransferDeliveryItem `gorm:"foreignKey:StockTransferDeliveryId"` -} \ No newline at end of file + Id uint64 `gorm:"primaryKey;autoIncrement"` + StockTransferId uint64 + SupplierId uint64 + VehiclePlate string + DriverName string + DocumentNumber string + DocumentPath string + ShippingCostItem float64 + ShippingCostTotal float64 + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time `gorm:"index"` + // Relations + StockTransfer *StockTransfer `gorm:"foreignKey:StockTransferId"` + Supplier *Supplier `gorm:"foreignKey:SupplierId"` + Items []StockTransferDeliveryItem `gorm:"foreignKey:StockTransferDeliveryId"` +} diff --git a/internal/modules/inventory/transfers/controllers/transfer.controller.go b/internal/modules/inventory/transfers/controllers/transfer.controller.go index b53d6e9a..c21e5286 100644 --- a/internal/modules/inventory/transfers/controllers/transfer.controller.go +++ b/internal/modules/inventory/transfers/controllers/transfer.controller.go @@ -80,15 +80,14 @@ func (u *TransferController) CreateOne(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") } - // ambil file form, err := c.MultipartForm() if err != nil { return fiber.NewError(fiber.StatusBadRequest, "Invalid multipart form") } - _ = form.File["documents"] - // todo: tunggu ada aws baru proses - result, err := u.TransferService.CreateOne(c, &req) + files := form.File["documents"] + + result, err := u.TransferService.CreateOne(c, &req, files) if err != nil { return err } diff --git a/internal/modules/inventory/transfers/dto/transfer.dto.go b/internal/modules/inventory/transfers/dto/transfer.dto.go index fe97ce0f..d38fb78d 100644 --- a/internal/modules/inventory/transfers/dto/transfer.dto.go +++ b/internal/modules/inventory/transfers/dto/transfer.dto.go @@ -43,6 +43,14 @@ type SupplierSimpleDTO struct { Name string `json:"name"` } +type DocumentDTO struct { + Id uint `json:"id"` + Path string `json:"path"` + Name string `json:"name"` + Ext string `json:"ext"` + Size float64 `json:"size"` +} + type WarehouseDetailDTO struct { Id uint `json:"id"` Name string `json:"name"` @@ -57,6 +65,7 @@ type TransferListDTO struct { UpdatedAt time.Time `json:"updated_at"` Details []TransferDetailItemDTO `json:"details"` Deliveries []TransferDeliveryDTO `json:"deliveries"` + Documents []DocumentDTO `json:"documents"` } type TransferDetailDTO struct { @@ -79,7 +88,6 @@ type TransferDeliveryDTO struct { VehiclePlate string `json:"vehicle_plate"` DriverName string `json:"driver_name"` DocumentNumber string `json:"document_number"` - DocumentPath string `json:"document_path"` ShippingCostItem float64 `json:"shipping_cost_item"` ShippingCostTotal float64 `json:"shipping_cost_total"` Items []TransferDeliveryItemDTO `json:"items"` @@ -174,6 +182,7 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { Quantity: item.Quantity, }) } + deliveries = append(deliveries, TransferDeliveryDTO{ Id: del.Id, Supplier: SupplierSimpleDTO{ @@ -183,12 +192,22 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { VehiclePlate: del.VehiclePlate, DriverName: del.DriverName, DocumentNumber: del.DocumentNumber, - DocumentPath: del.DocumentPath, ShippingCostItem: del.ShippingCostItem, ShippingCostTotal: del.ShippingCostTotal, Items: items, }) } + var documents []DocumentDTO + for _, doc := range e.Documents { + documents = append(documents, DocumentDTO{ + Id: doc.Id, + Path: doc.Path, + Name: doc.Name, + Ext: doc.Ext, + Size: doc.Size, + }) + } + return TransferListDTO{ TransferRelationDTO: ToTransferRelationDTO(e), CreatedUser: createdUser, @@ -196,6 +215,7 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { UpdatedAt: e.UpdatedAt, Details: details, Deliveries: deliveries, + Documents: documents, } } @@ -232,7 +252,6 @@ func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { VehiclePlate: del.VehiclePlate, DriverName: del.DriverName, DocumentNumber: del.DocumentNumber, - DocumentPath: del.DocumentPath, ShippingCostItem: del.ShippingCostItem, ShippingCostTotal: del.ShippingCostTotal, }) diff --git a/internal/modules/inventory/transfers/module.go b/internal/modules/inventory/transfers/module.go index 19a0ded6..9389f9f4 100644 --- a/internal/modules/inventory/transfers/module.go +++ b/internal/modules/inventory/transfers/module.go @@ -1,10 +1,14 @@ package transfers import ( + "context" + "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "gorm.io/gorm" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rStockTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/repositories" sTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services" @@ -29,8 +33,14 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate userRepo := rUser.NewUserRepository(db) warehouseRepo := rWarehouse.NewWarehouseRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) + documentRepo := commonRepo.NewDocumentRepository(db) - transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo, warehouseRepo, projectFlockKandangRepo) + documentSvc, err := commonSvc.NewDocumentServiceFromConfig(context.Background(), documentRepo) + if err != nil { + panic(err) + } + + transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo, warehouseRepo, projectFlockKandangRepo, documentSvc) userService := sUser.NewUserService(userRepo, validate) TransferRoutes(router, userService, transferService) diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index f94295f6..33ca77ff 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "mime/multipart" "strings" "github.com/go-playground/validator/v10" @@ -27,7 +28,7 @@ import ( type TransferService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.StockTransfer, int64, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.StockTransfer, error) - CreateOne(ctx *fiber.Ctx, req *validation.TransferRequest) (*entity.StockTransfer, error) + CreateOne(ctx *fiber.Ctx, req *validation.TransferRequest, files []*multipart.FileHeader) (*entity.StockTransfer, error) } type transferService struct { @@ -42,9 +43,10 @@ type transferService struct { SupplierRepo rSupplier.SupplierRepository WarehouseRepo warehouseRepo.WarehouseRepository ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository + DocumentSvc commonSvc.DocumentService } -func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository) TransferService { +func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, documentSvc commonSvc.DocumentService) TransferService { return &transferService{ Log: utils.Log, Validate: validate, @@ -57,6 +59,7 @@ func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTr SupplierRepo: supplierRepo, WarehouseRepo: warehouseRepo, ProjectFlockKandangRepo: projectFlockKandangRepo, + DocumentSvc: documentSvc, } } @@ -72,7 +75,10 @@ func (s transferService) withRelations(db *gorm.DB) *gorm.DB { Preload("Details"). Preload("Details.Product"). Preload("Deliveries.Items"). - Preload("Deliveries.Supplier") + Preload("Deliveries.Supplier"). + Preload("Documents", func(db *gorm.DB) *gorm.DB { + return db.Where("documentable_type = ?", "STOCK_TRANSFER") + }) } func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.StockTransfer, int64, error) { @@ -94,31 +100,31 @@ func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit return nil, 0, err } - s.Log.Infof("Retrieved %d transfers", len(transfers)) - return transfers, total, nil } func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, error) { - var transfer entity.StockTransfer + s.Log.Infof("Attempting to get StockTransfer with ID: %d", id) transferPtr, err := s.StockTransferRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { return s.withRelations(db) }) if err != nil { + s.Log.Errorf("Error getting StockTransfer ID %d: %+v", id, err) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Transfer not found") } - s.Log.Errorf("Failed to get transfer by ID: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get transfer") } - s.Log.Infof("Retrieved transfer: %+v", transfer) + if transferPtr != nil { + s.Log.Infof("StockTransfer %d has %d documents", transferPtr.Id, len(transferPtr.Documents)) + } return transferPtr, nil } -func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferRequest) (*entity.StockTransfer, error) { +func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferRequest, files []*multipart.FileHeader) (*entity.StockTransfer, error) { pwIDs := make([]uint, 0, len(req.Products)) @@ -180,7 +186,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques seqNum, err := s.StockTransferRepo.GetNextMovementNumber(c.Context()) if err != nil { - s.Log.Errorf("Failed to get next movement number: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to generate movement number") } movementNumber := fmt.Sprintf("PND-MBU-%05d", seqNum) @@ -198,10 +203,8 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques err = s.StockTransferRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { if err := s.StockTransferRepo.WithTx(tx).CreateOne(c.Context(), entityTransfer, nil); err != nil { - s.Log.Errorf("Failed to create stock transfer: %+v", err) return err } - s.Log.Infof("Stock transfer created: %+v", entityTransfer.Id) var details []*entity.StockTransferDetail for _, product := range req.Products { @@ -212,10 +215,8 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques }) } if err := s.StockTransferDetailRepo.WithTx(tx).CreateMany(c.Context(), details, nil); err != nil { - s.Log.Errorf("Failed to create stock transfer details: %+v", err) return err } - s.Log.Infof("Stock transfer details created for transfer ID: %+v", entityTransfer.Id) var deliveries []*entity.StockTransferDelivery for _, delivery := range req.Deliveries { @@ -224,13 +225,11 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques SupplierId: uint64(delivery.SupplierID), VehiclePlate: delivery.VehiclePlate, DriverName: delivery.DriverName, - DocumentPath: "https://tourism.gov.in/sites/default/files/2019-04/dummy-pdf_2.pdf", ShippingCostItem: delivery.DeliveryCostPerItem, ShippingCostTotal: delivery.DeliveryCost, }) } if err := s.StockTransferDeliveryRepo.WithTx(tx).CreateMany(c.Context(), deliveries, nil); err != nil { - s.Log.Errorf("Failed to create stock transfer deliveries: %+v", err) return err } @@ -256,27 +255,46 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } } if err := s.StockTransferDeliveryItemRepo.WithTx(tx).CreateMany(c.Context(), deliveryItems, nil); err != nil { - s.Log.Errorf("Failed to create stock transfer delivery items: %+v", err) return err } - s.Log.Infof("Stock transfer delivery items created for transfer ID: %+v", entityTransfer.Id) + + actorIDCopy := actorID + if s.DocumentSvc != nil && len(files) > 0 { + s.Log.Infof("Starting document upload for %d files", len(files)) + documentFiles := make([]commonSvc.DocumentFile, 0, len(files)) + for idx, file := range files { + docIndex := idx + documentFiles = append(documentFiles, commonSvc.DocumentFile{ + File: file, + Type: "STOCK_TRANSFER_DOCUMENT", + Index: &docIndex, + }) + } + _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ + DocumentableType: "STOCK_TRANSFER", + DocumentableID: entityTransfer.Id, + CreatedBy: &actorIDCopy, + Files: documentFiles, + }) + if err != nil { + s.Log.Errorf("Failed to upload documents for transfer %d: %+v", entityTransfer.Id, err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload transfer documents") + } + s.Log.Infof("Successfully uploaded documents for transfer ID %d", entityTransfer.Id) + } for _, product := range req.Products { sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID)) if err != nil { - s.Log.Errorf("Failed to get source product warehouse: %+v", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to get source product warehouse") } if sourcePW.Quantity < product.ProductQty { - s.Log.Errorf("Insufficient stock in source warehouse for product ID: %+v", product.ProductID) return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock in source warehouse for product ID: %d", product.ProductID)) } sourcePW.Quantity -= product.ProductQty if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(c.Context(), sourcePW.Id, sourcePW, nil); err != nil { - s.Log.Errorf("Failed to update source product warehouse: %+v", err) return err } - s.Log.Infof("Source product warehouse updated: %+v", sourcePW.Id) decreaseLog := &entity.StockLog{ Decrease: product.ProductQty, @@ -287,7 +305,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques CreatedBy: actorID, } if err := s.StockLogsRepository.WithTx(tx).CreateOne(c.Context(), decreaseLog, nil); err != nil { - s.Log.Errorf("Failed to create stock log decrease: %+v", err) return err } @@ -295,7 +312,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques c.Context(), uint(product.ProductID), uint(req.DestinationWarehouseID), ) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - s.Log.Errorf("Failed to get destination product warehouse: %+v", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to get destination product warehouse") } if err != nil && errors.Is(err, gorm.ErrRecordNotFound) { @@ -311,18 +327,14 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques ProjectFlockKandangId: &projectFlockKandangID, } if err := s.ProductWarehouseRepo.WithTx(tx).CreateOne(c.Context(), destPW, nil); err != nil { - s.Log.Errorf("Failed to create destination product warehouse: %+v", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to create destination product warehouse") } - s.Log.Infof("Destination product warehouse created: %+v", destPW.Id) } destPW.Quantity += product.ProductQty if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(c.Context(), destPW.Id, destPW, nil); err != nil { - s.Log.Errorf("Failed to update destination product warehouse: %+v", err) return err } - s.Log.Infof("Destination product warehouse updated: %+v", destPW.Id) increaseLog := &entity.StockLog{ Increase: product.ProductQty, @@ -333,7 +345,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques CreatedBy: actorID, } if err := s.StockLogsRepository.WithTx(tx).CreateOne(c.Context(), increaseLog, nil); err != nil { - s.Log.Errorf("Failed to create stock log increase: %+v", err) return err } } @@ -343,7 +354,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques if err != nil { s.Log.Errorf("Transaction failed in CreateOne: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to process transfer transaction") + return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to process transfer transaction: %v", err)) } result, err := s.GetOne(c, uint(entityTransfer.Id)) @@ -359,7 +370,6 @@ func (s *transferService) getActiveProjectFlockKandangID(ctx context.Context, wa if errors.Is(err, gorm.ErrRecordNotFound) { return 0, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Gudang dengan ID %d tidak ditemukan", warehouseID)) } - s.Log.Errorf("Failed to get warehouse %d: %+v", warehouseID, err) return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data gudang") } @@ -372,7 +382,6 @@ func (s *transferService) getActiveProjectFlockKandangID(ctx context.Context, wa if errors.Is(err, gorm.ErrRecordNotFound) { return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Kandang %d belum memiliki project flock aktif", *warehouse.KandangId)) } - s.Log.Errorf("Failed to get active project flock for kandang %d: %+v", *warehouse.KandangId, err) return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil project flock kandang") } From e935843cba2f12d452057e39510c4d5cfd918d8d Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 23 Dec 2025 17:51:42 +0700 Subject: [PATCH 04/12] Feat[BE]: refactor document handling in transfer service and introduce document type constants --- internal/entities/stock_transfer_delivery.go | 1 + .../controllers/transfer.controller.go | 9 ++- .../inventory/transfers/dto/transfer.dto.go | 62 ++++++++++--------- .../transfers/services/transfer.service.go | 39 ++++++------ internal/utils/constant.go | 15 ++++- 5 files changed, 73 insertions(+), 53 deletions(-) diff --git a/internal/entities/stock_transfer_delivery.go b/internal/entities/stock_transfer_delivery.go index 69324b65..0eeccc04 100644 --- a/internal/entities/stock_transfer_delivery.go +++ b/internal/entities/stock_transfer_delivery.go @@ -20,4 +20,5 @@ type StockTransferDelivery struct { StockTransfer *StockTransfer `gorm:"foreignKey:StockTransferId"` Supplier *Supplier `gorm:"foreignKey:SupplierId"` Items []StockTransferDeliveryItem `gorm:"foreignKey:StockTransferDeliveryId"` + Documents []Document `gorm:"foreignKey:DocumentableId;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` } diff --git a/internal/modules/inventory/transfers/controllers/transfer.controller.go b/internal/modules/inventory/transfers/controllers/transfer.controller.go index c21e5286..4f060dc2 100644 --- a/internal/modules/inventory/transfers/controllers/transfer.controller.go +++ b/internal/modules/inventory/transfers/controllers/transfer.controller.go @@ -68,7 +68,7 @@ func (u *TransferController) GetOne(c *fiber.Ctx) error { Code: fiber.StatusOK, Status: "success", Message: "Get transfer successfully", - Data: dto.ToTransferListDTO(*result), + Data: dto.ToTransferDetailDTO(*result), }) } @@ -87,6 +87,11 @@ func (u *TransferController) CreateOne(c *fiber.Ctx) error { files := form.File["documents"] + if len(files) != len(req.Deliveries) { + return fiber.NewError(fiber.StatusBadRequest, + fiber.NewError(fiber.StatusBadRequest, "Jumlah dokumen harus sama dengan jumlah deliveries").Message) + } + result, err := u.TransferService.CreateOne(c, &req, files) if err != nil { return err @@ -97,6 +102,6 @@ func (u *TransferController) CreateOne(c *fiber.Ctx) error { Code: fiber.StatusCreated, Status: "success", Message: "Create transfer successfully", - Data: dto.ToTransferListDTO(*result), + Data: dto.ToTransferDetailDTO(*result), }) } diff --git a/internal/modules/inventory/transfers/dto/transfer.dto.go b/internal/modules/inventory/transfers/dto/transfer.dto.go index d38fb78d..14ca04d2 100644 --- a/internal/modules/inventory/transfers/dto/transfer.dto.go +++ b/internal/modules/inventory/transfers/dto/transfer.dto.go @@ -7,8 +7,6 @@ import ( userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) -// === DTO Structs === - type TransferRelationDTO struct { Id uint64 `json:"id"` TransferReason string `json:"transfer_reason"` @@ -17,7 +15,6 @@ type TransferRelationDTO struct { DestinationWarehouse *WarehouseDetailDTO `json:"destination_warehouse,omitempty"` } -// Only id and name for warehouse simple view type WarehouseSimpleDTO struct { Id uint `json:"id"` Name string `json:"name"` @@ -65,7 +62,6 @@ type TransferListDTO struct { UpdatedAt time.Time `json:"updated_at"` Details []TransferDetailItemDTO `json:"details"` Deliveries []TransferDeliveryDTO `json:"deliveries"` - Documents []DocumentDTO `json:"documents"` } type TransferDetailDTO struct { @@ -74,14 +70,12 @@ type TransferDetailDTO struct { Deliveries []TransferDeliveryDTO `json:"deliveries"` } -// Detail produk type TransferDetailItemDTO struct { Id uint64 `json:"id"` - Proudct ProductSimpleDTO `json:"product"` + Product ProductSimpleDTO `json:"product"` Quantity float64 `json:"quantity"` } -// Delivery ekspedisi type TransferDeliveryDTO struct { Id uint64 `json:"id"` Supplier SupplierSimpleDTO `json:"supplier"` @@ -91,6 +85,7 @@ type TransferDeliveryDTO struct { ShippingCostItem float64 `json:"shipping_cost_item"` ShippingCostTotal float64 `json:"shipping_cost_total"` Items []TransferDeliveryItemDTO `json:"items"` + Documents []DocumentDTO `json:"documents"` } type TransferDeliveryItemDTO struct { @@ -99,10 +94,7 @@ type TransferDeliveryItemDTO struct { Quantity float64 `json:"quantity"` } -// === Mapper Functions === - func ToTransferRelationDTO(e entity.StockTransfer) TransferRelationDTO { - var sourceWarehouse *WarehouseDetailDTO if e.FromWarehouse != nil && e.FromWarehouse.Id != 0 { sourceWarehouse = toWarehouseDetailDTO(e.FromWarehouse) @@ -148,7 +140,7 @@ func toWarehouseDetailDTO(w *entity.Warehouse) *WarehouseDetailDTO { Id: w.Id, Name: w.Name, Location: toLocationDTO(w.Location), - Area: toAreaDTO(&w.Area), // Ambil area langsung dari warehouse (area_id) + Area: toAreaDTO(&w.Area), } } @@ -158,22 +150,21 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { mapped := userDTO.ToUserRelationDTO(*e.CreatedUser) createdUser = &mapped } - // Map details + var details []TransferDetailItemDTO for _, d := range e.Details { details = append(details, TransferDetailItemDTO{ Id: d.Id, - Proudct: ProductSimpleDTO{ + Product: ProductSimpleDTO{ Id: d.Product.Id, Name: d.Product.Name, }, Quantity: d.Quantity, }) } - // Map deliveries + var deliveries []TransferDeliveryDTO for _, del := range e.Deliveries { - // Map delivery items var items []TransferDeliveryItemDTO for _, item := range del.Items { items = append(items, TransferDeliveryItemDTO{ @@ -183,6 +174,17 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { }) } + var documents []DocumentDTO + for _, doc := range del.Documents { + documents = append(documents, DocumentDTO{ + Id: doc.Id, + Path: doc.Path, + Name: doc.Name, + Ext: doc.Ext, + Size: doc.Size, + }) + } + deliveries = append(deliveries, TransferDeliveryDTO{ Id: del.Id, Supplier: SupplierSimpleDTO{ @@ -195,16 +197,7 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { ShippingCostItem: del.ShippingCostItem, ShippingCostTotal: del.ShippingCostTotal, Items: items, - }) - } - var documents []DocumentDTO - for _, doc := range e.Documents { - documents = append(documents, DocumentDTO{ - Id: doc.Id, - Path: doc.Path, - Name: doc.Name, - Ext: doc.Ext, - Size: doc.Size, + Documents: documents, }) } @@ -215,7 +208,6 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { UpdatedAt: e.UpdatedAt, Details: details, Deliveries: deliveries, - Documents: documents, } } @@ -228,21 +220,31 @@ func ToTransferListDTOs(e []entity.StockTransfer) []TransferListDTO { } func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { - // Map details var details []TransferDetailItemDTO for _, d := range e.Details { details = append(details, TransferDetailItemDTO{ Id: d.Id, - Proudct: ProductSimpleDTO{ + Product: ProductSimpleDTO{ Id: d.Product.Id, Name: d.Product.Name, }, Quantity: d.Quantity, }) } - // Map deliveries + var deliveries []TransferDeliveryDTO for _, del := range e.Deliveries { + var documents []DocumentDTO + for _, doc := range del.Documents { + documents = append(documents, DocumentDTO{ + Id: doc.Id, + Path: doc.Path, + Name: doc.Name, + Ext: doc.Ext, + Size: doc.Size, + }) + } + deliveries = append(deliveries, TransferDeliveryDTO{ Id: del.Id, Supplier: SupplierSimpleDTO{ @@ -254,8 +256,10 @@ func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { DocumentNumber: del.DocumentNumber, ShippingCostItem: del.ShippingCostItem, ShippingCostTotal: del.ShippingCostTotal, + Documents: documents, }) } + return TransferDetailDTO{ TransferListDTO: ToTransferListDTO(e), Details: details, diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index 33ca77ff..89e7b271 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -76,8 +76,8 @@ func (s transferService) withRelations(db *gorm.DB) *gorm.DB { Preload("Details.Product"). Preload("Deliveries.Items"). Preload("Deliveries.Supplier"). - Preload("Documents", func(db *gorm.DB) *gorm.DB { - return db.Where("documentable_type = ?", "STOCK_TRANSFER") + Preload("Deliveries.Documents", func(db *gorm.DB) *gorm.DB { + return db.Where("documentable_type = ?", string(utils.DocumentableTypeTransfer)) }) } @@ -258,29 +258,26 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return err } - actorIDCopy := actorID if s.DocumentSvc != nil && len(files) > 0 { - s.Log.Infof("Starting document upload for %d files", len(files)) - documentFiles := make([]commonSvc.DocumentFile, 0, len(files)) + // Upload documents for each delivery for idx, file := range files { - docIndex := idx - documentFiles = append(documentFiles, commonSvc.DocumentFile{ - File: file, - Type: "STOCK_TRANSFER_DOCUMENT", - Index: &docIndex, + documentFiles := []commonSvc.DocumentFile{ + { + File: file, + Type: string(utils.DocumentTypeTransfer), + Index: &idx, + }, + } + _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ + DocumentableType: string(utils.DocumentableTypeTransfer), + DocumentableID: deliveries[idx].Id, + CreatedBy: &actorID, + Files: documentFiles, }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to upload document for delivery %d", idx+1)) + } } - _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ - DocumentableType: "STOCK_TRANSFER", - DocumentableID: entityTransfer.Id, - CreatedBy: &actorIDCopy, - Files: documentFiles, - }) - if err != nil { - s.Log.Errorf("Failed to upload documents for transfer %d: %+v", entityTransfer.Id, err) - return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload transfer documents") - } - s.Log.Infof("Successfully uploaded documents for transfer ID %d", entityTransfer.Id) } for _, product := range req.Products { diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 85b33f9b..02b15102 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -314,6 +314,19 @@ var ExpenseApprovalSteps = map[approvalutils.ApprovalStep]string{ ExpenseStepSelesai: "Selesai", } +// ------------------------------------------------------------------- +// Document +// ------------------------------------------------------------------- + +type DocumentType string +type DocumentableType string + +const ( + DocumentTypeTransfer DocumentType = "STOCK_TRANSFER_DOCUMENT" + + DocumentableTypeTransfer DocumentableType = "STOCK_TRANSFER" +) + // ------------------------------------------------------------------- // Validators // ------------------------------------------------------------------- @@ -448,7 +461,7 @@ func IsValidExpenseCategory(v string) bool { return false } -// example use +// e xample use // Recording helper From 9c3d0a44a67d411ab377ac75a6bb67989128f81f Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 24 Dec 2025 09:24:32 +0700 Subject: [PATCH 05/12] Feat[BE]: enhance chickin stock management with FIFO service integration and fix key naming inconsistencies --- .../modules/production/chickins/module.go | 8 ++-- .../chickins/services/chickin.service.go | 38 +++++++++---------- internal/route/route.go | 4 +- internal/utils/fifo/constants.go | 2 +- 4 files changed, 24 insertions(+), 28 deletions(-) diff --git a/internal/modules/production/chickins/module.go b/internal/modules/production/chickins/module.go index df0ebd26..2cd0ad7e 100644 --- a/internal/modules/production/chickins/module.go +++ b/internal/modules/production/chickins/module.go @@ -39,19 +39,19 @@ func (ChickinModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * projectFlockRepo := rProjectFlock.NewProjectflockRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) - + fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) userRepo := rUser.NewUserRepository(db) - fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) + // Register PROJECT_CHICKIN as usable if err := fifoService.RegisterUsable(fifo.UsableConfig{ - Key: fifo.UsablekeyProjectChickin, + Key: fifo.UsableKeyProjectChickin, Table: "project_chickins", Columns: fifo.UsableColumns{ ID: "id", ProductWarehouseID: "product_warehouse_id", UsageQuantity: "usage_qty", PendingQuantity: "pending_usage_qty", - CreatedAt: "id", + CreatedAt: "created_at", }, }); err != nil { if !strings.Contains(strings.ToLower(err.Error()), "already registered") { diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index fe78080b..965e39ba 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -25,7 +25,7 @@ import ( "gorm.io/gorm" ) -var chickinUsableKey = fifo.UsablekeyProjectChickin +var chickinUsableKey = fifo.UsableKeyProjectChickin type ChickinService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectChickin, int64, error) @@ -135,8 +135,9 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti return nil, err } newChikins := make([]*entity.ProjectChickin, 0) + chickinQtyMap := make(map[uint]float64) // Store desired qty for each chickin index - for _, chickinReq := range req.ChickinRequests { + for idx, chickinReq := range req.ChickinRequests { productWarehouse, err := s.ProductWarehouseRepo.GetByID(c.Context(), chickinReq.ProductWarehouseId, nil) if err != nil { @@ -164,7 +165,7 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti newChickin := &entity.ProjectChickin{ ProjectFlockKandangId: req.ProjectFlockKandangId, ChickInDate: chickinDate, - UsageQty: availableQty, + UsageQty: 0, PendingUsageQty: 0, ProductWarehouseId: chickinReq.ProductWarehouseId, Notes: chickinReq.Note, @@ -172,6 +173,7 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti } newChikins = append(newChikins, newChickin) + chickinQtyMap[uint(idx)] = availableQty } if len(newChikins) == 0 { @@ -193,24 +195,14 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti return fiber.NewError(fiber.StatusInternalServerError, "Failed to create chickins") } - for _, chickin := range newChikins { - if err := s.ConsumeChickinStocks(c.Context(), dbTransaction, chickin); err != nil { + for idx, chickin := range newChikins { + desiredQty := chickinQtyMap[uint(idx)] + if err := s.ConsumeChickinStocks(c.Context(), dbTransaction, chickin, desiredQty); err != nil { return err } - - if chickin.PendingUsageQty > 0 { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot create chickin %d - insufficient stock. Required: %.0f, Available: %.0f, Pending: %.0f", chickin.Id, chickin.UsageQty+chickin.PendingUsageQty, chickin.UsageQty, chickin.PendingUsageQty)) - } } - warehouseDeltas := make(map[uint]float64) - for _, chickin := range newChikins { - warehouseDeltas[chickin.ProductWarehouseId] -= chickin.UsageQty - } - if err := s.adjustProductWarehouseQuantities(c.Context(), dbTransaction, warehouseDeltas); err != nil { - s.Log.Errorf("Failed to adjust product warehouses: %+v", err) - return err - } + // Note: FIFO Consume already adjusts product warehouse quantities, no need to adjust again latest, err := approvalSvcTx.LatestByTarget(c.Context(), utils.ApprovalWorkflowChickin, projectFlockKandang.Id, nil) if err != nil { @@ -599,19 +591,20 @@ func (s *chickinService) convertChickinsToTarget(ctx *fiber.Ctx, chickins []enti return nil } -func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin) error { +func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin, desiredQty float64) error { if chickin == nil || s.FifoSvc == nil { return nil } - var desired float64 = chickin.UsageQty + s.Log.Infof("ConsumeChickinStocks: chickin_id=%d, product_warehouse_id=%d, desired_qty=%.3f", + chickin.Id, chickin.ProductWarehouseId, desiredQty) result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ UsableKey: chickinUsableKey, UsableID: chickin.Id, ProductWarehouseID: chickin.ProductWarehouseId, - Quantity: desired, - AllowPending: false, + Quantity: desiredQty, + AllowPending: true, Tx: tx, }) if err != nil { @@ -619,6 +612,9 @@ func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, return err } + s.Log.Infof("ConsumeChickinStocks result: usage_qty=%.3f, pending_qty=%.3f, allocated_allocations=%d", + result.UsageQuantity, result.PendingQuantity, len(result.AddedAllocations)) + if err := tx.Model(&entity.ProjectChickin{}).Where("id = ?", chickin.Id).Updates(map[string]interface{}{ "usage_qty": result.UsageQuantity, "pending_usage_qty": result.PendingQuantity, diff --git a/internal/route/route.go b/internal/route/route.go index 294fc900..e98b044b 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -17,9 +17,9 @@ import ( master "gitlab.com/mbugroup/lti-api.git/internal/modules/master" production "gitlab.com/mbugroup/lti-api.git/internal/modules/production" purchases "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases" + repports "gitlab.com/mbugroup/lti-api.git/internal/modules/repports" ssoModule "gitlab.com/mbugroup/lti-api.git/internal/modules/sso" users "gitlab.com/mbugroup/lti-api.git/internal/modules/users" - repports "gitlab.com/mbugroup/lti-api.git/internal/modules/repports" // MODULE IMPORTS ) @@ -43,7 +43,7 @@ func Routes(app *fiber.App, db *gorm.DB) { expenses.ExpenseModule{}, ssoModule.Module{}, closings.ClosingModule{}, - repports.RepportModule{}, + repports.RepportModule{}, // MODULE REGISTRY } diff --git a/internal/utils/fifo/constants.go b/internal/utils/fifo/constants.go index c1a79444..fd0bca06 100644 --- a/internal/utils/fifo/constants.go +++ b/internal/utils/fifo/constants.go @@ -2,5 +2,5 @@ package fifo const ( UsableKeyRecordingStock UsableKey = "RECORDING_STOCK" - UsablekeyProjectChickin UsableKey = "PROJECT_CHICKIN" + UsableKeyProjectChickin UsableKey = "PROJECT_CHICKIN" ) From 3e575d96a703c0e8239fd2e7916beb0c7f48af25 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 24 Dec 2025 10:42:27 +0700 Subject: [PATCH 06/12] Feat[BE]: update update dto for transfer document --- .../inventory/transfers/dto/transfer.dto.go | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/internal/modules/inventory/transfers/dto/transfer.dto.go b/internal/modules/inventory/transfers/dto/transfer.dto.go index 14ca04d2..f1286595 100644 --- a/internal/modules/inventory/transfers/dto/transfer.dto.go +++ b/internal/modules/inventory/transfers/dto/transfer.dto.go @@ -85,7 +85,7 @@ type TransferDeliveryDTO struct { ShippingCostItem float64 `json:"shipping_cost_item"` ShippingCostTotal float64 `json:"shipping_cost_total"` Items []TransferDeliveryItemDTO `json:"items"` - Documents []DocumentDTO `json:"documents"` + Document *DocumentDTO `json:"document,omitempty"` } type TransferDeliveryItemDTO struct { @@ -174,15 +174,16 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { }) } - var documents []DocumentDTO - for _, doc := range del.Documents { - documents = append(documents, DocumentDTO{ + var document *DocumentDTO + if len(del.Documents) > 0 { + doc := del.Documents[0] // Take first document + document = &DocumentDTO{ Id: doc.Id, Path: doc.Path, Name: doc.Name, Ext: doc.Ext, Size: doc.Size, - }) + } } deliveries = append(deliveries, TransferDeliveryDTO{ @@ -197,7 +198,7 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { ShippingCostItem: del.ShippingCostItem, ShippingCostTotal: del.ShippingCostTotal, Items: items, - Documents: documents, + Document: document, }) } @@ -234,15 +235,16 @@ func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { var deliveries []TransferDeliveryDTO for _, del := range e.Deliveries { - var documents []DocumentDTO - for _, doc := range del.Documents { - documents = append(documents, DocumentDTO{ + var document *DocumentDTO + if len(del.Documents) > 0 { + doc := del.Documents[0] // Take first document + document = &DocumentDTO{ Id: doc.Id, Path: doc.Path, Name: doc.Name, Ext: doc.Ext, Size: doc.Size, - }) + } } deliveries = append(deliveries, TransferDeliveryDTO{ @@ -256,7 +258,7 @@ func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { DocumentNumber: del.DocumentNumber, ShippingCostItem: del.ShippingCostItem, ShippingCostTotal: del.ShippingCostTotal, - Documents: documents, + Document: document, }) } From 12e5706318005e857f033d27b82f300a76491b04 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 26 Dec 2025 09:19:39 +0700 Subject: [PATCH 07/12] Feat[BE]: refactor stock log handling and introduce new log types for adjustments and transfers --- .../common/service/common.fifo.service.go | 25 ++++++-- internal/entities/stock_log.go | 10 ---- .../repositories/closing.repository.go | 4 +- .../services/adjustment.service.go | 12 ++-- .../transfers/services/transfer.service.go | 6 +- .../modules/production/chickins/module.go | 1 - .../chickins/services/chickin.service.go | 59 ++++++++++++++++--- internal/utils/constant.go | 2 + 8 files changed, 83 insertions(+), 36 deletions(-) diff --git a/internal/common/service/common.fifo.service.go b/internal/common/service/common.fifo.service.go index e3b80268..bf97f831 100644 --- a/internal/common/service/common.fifo.service.go +++ b/internal/common/service/common.fifo.service.go @@ -505,12 +505,25 @@ func (s *fifoService) fetchStockLots(ctx context.Context, tx *gorm.DB, productWa var lots []stockLot for key, cfg := range configs { - selectStmt := fmt.Sprintf( - "%s AS id, %s AS available_qty, %s AS created_at", - cfg.Columns.ID, - fmt.Sprintf("%s - COALESCE(%s,0)", cfg.Columns.TotalQuantity, cfg.Columns.TotalUsedQuantity), - cfg.Columns.CreatedAt, - ) + + usesNumericTime := cfg.Columns.CreatedAt == cfg.Columns.ID + + var selectStmt string + if usesNumericTime { + + selectStmt = fmt.Sprintf( + "%s AS id, %s AS available_qty, '1970-01-01 00:00:00 UTC'::timestamp AS created_at", + cfg.Columns.ID, + fmt.Sprintf("%s - COALESCE(%s,0)", cfg.Columns.TotalQuantity, cfg.Columns.TotalUsedQuantity), + ) + } else { + selectStmt = fmt.Sprintf( + "%s AS id, %s AS available_qty, %s AS created_at", + cfg.Columns.ID, + fmt.Sprintf("%s - COALESCE(%s,0)", cfg.Columns.TotalQuantity, cfg.Columns.TotalUsedQuantity), + cfg.Columns.CreatedAt, + ) + } var rows []struct { ID uint diff --git a/internal/entities/stock_log.go b/internal/entities/stock_log.go index 310d8cf8..d6acafb8 100644 --- a/internal/entities/stock_log.go +++ b/internal/entities/stock_log.go @@ -2,16 +2,6 @@ package entities import "time" -const ( - LogTypeAdjustment = "ADJUSTMENT" - LogTypeTransfer = "TRANSFER" -) - -const ( - TransactionTypeIncrease = "INCREASE" - TransactionTypeDecrease = "DECREASE" -) - type StockLog struct { Id uint `gorm:"primaryKey;column:id"` ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null;index"` diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index 6a59c5f9..cf49826a 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -783,7 +783,7 @@ func splitStockLogs(rows []stockLogSapronakRow, refFn func(stockLogSapronakRow) } func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) { - rows, err := r.fetchStockLogs(ctx, kandangID, entity.LogTypeAdjustment, false) + rows, err := r.fetchStockLogs(ctx, kandangID, string(utils.StockLogTypeAdjustment), false) if err != nil { return nil, nil, err } @@ -792,7 +792,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka } func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) { - rows, err := r.fetchStockLogs(ctx, kandangID, entity.LogTypeTransfer, true) + rows, err := r.fetchStockLogs(ctx, kandangID, string(utils.StockLogTypeTransfer), true) if err != nil { return nil, nil, err } diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index 7bcbca7e..5a634382 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -70,7 +70,7 @@ func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.StockLog, err return nil, err } - if stockLog.LoggableType != entity.LogTypeAdjustment { + if stockLog.LoggableType != string(utils.StockLogTypeAdjustment) { return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found") } @@ -97,7 +97,7 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e return nil, fiber.NewError(fiber.StatusBadRequest, "Quantity must be greater than zero") } transactionType := strings.ToUpper(req.TransactionType) - if transactionType != entity.TransactionTypeIncrease && transactionType != entity.TransactionTypeDecrease { + if transactionType != string(utils.StockLogTransactionTypeIncrease) && transactionType != string(utils.StockLogTransactionTypeDecrease) { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transaction type") } @@ -154,14 +154,14 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e afterQuantity := productWarehouse.Quantity newLog := &entity.StockLog{ - // TransactionType: transactionType, - LoggableType: entity.LogTypeAdjustment, + + LoggableType: string(utils.StockLogTypeAdjustment), LoggableId: 0, Notes: req.Note, ProductWarehouseId: productWarehouse.Id, CreatedBy: actorID, // TODO: should Get from auth middleware } - if transactionType == entity.TransactionTypeIncrease { + if transactionType == string(utils.StockLogTransactionTypeIncrease) { afterQuantity += req.Quantity newLog.Increase = afterQuantity } else { @@ -248,7 +248,7 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu db = s.withRelations(db) - db = db.Where("loggable_type = ?", entity.LogTypeAdjustment) + db = db.Where("loggable_type = ?", string(utils.StockLogTypeAdjustment)) if query.TransactionType != "" { db = db.Where("transaction_type = ?", strings.ToUpper(query.TransactionType)) diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index 89e7b271..a8a8996e 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -259,7 +259,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } if s.DocumentSvc != nil && len(files) > 0 { - // Upload documents for each delivery + for idx, file := range files { documentFiles := []commonSvc.DocumentFile{ { @@ -296,7 +296,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques decreaseLog := &entity.StockLog{ Decrease: product.ProductQty, Notes: "", - LoggableType: entity.LogTypeTransfer, + LoggableType: string(utils.StockLogTypeTransfer), LoggableId: uint(entityTransfer.Id), ProductWarehouseId: sourcePW.Id, CreatedBy: actorID, @@ -335,7 +335,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques increaseLog := &entity.StockLog{ Increase: product.ProductQty, - LoggableType: entity.LogTypeTransfer, + LoggableType: string(utils.StockLogTypeTransfer), LoggableId: uint(entityTransfer.Id), Notes: "", ProductWarehouseId: destPW.Id, diff --git a/internal/modules/production/chickins/module.go b/internal/modules/production/chickins/module.go index 2cd0ad7e..6c9b8984 100644 --- a/internal/modules/production/chickins/module.go +++ b/internal/modules/production/chickins/module.go @@ -42,7 +42,6 @@ func (ChickinModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) userRepo := rUser.NewUserRepository(db) - // Register PROJECT_CHICKIN as usable if err := fifoService.RegisterUsable(fifo.UsableConfig{ Key: fifo.UsableKeyProjectChickin, Table: "project_chickins", diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 965e39ba..871c8fce 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -16,6 +16,7 @@ import ( repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/validations" rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" @@ -48,6 +49,7 @@ type chickinService struct { ProjectflockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository ProjectChickinDetailRepo repository.ProjectChickinDetailRepository FifoSvc commonSvc.FifoService + StockLogRepo rStockLogs.StockLogRepository } func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo KandangRepo.KandangRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, projectFlockRepo rProjectFlock.ProjectflockRepository, projectflockkandangRepo rProjectFlock.ProjectFlockKandangRepository, projectflockpopulationRepo rProjectFlock.ProjectFlockPopulationRepository, projectChickinDetailRepo repository.ProjectChickinDetailRepository, validate *validator.Validate, fifoSvc commonSvc.FifoService) ChickinService { @@ -63,6 +65,7 @@ func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo Kan ProjectflockPopulationRepo: projectflockpopulationRepo, ProjectChickinDetailRepo: projectChickinDetailRepo, FifoSvc: fifoSvc, + StockLogRepo: rStockLogs.NewStockLogRepository(repo.DB()), } } @@ -135,7 +138,7 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti return nil, err } newChikins := make([]*entity.ProjectChickin, 0) - chickinQtyMap := make(map[uint]float64) // Store desired qty for each chickin index + chickinQtyMap := make(map[uint]float64) for idx, chickinReq := range req.ChickinRequests { @@ -197,13 +200,11 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti for idx, chickin := range newChikins { desiredQty := chickinQtyMap[uint(idx)] - if err := s.ConsumeChickinStocks(c.Context(), dbTransaction, chickin, desiredQty); err != nil { + if err := s.ConsumeChickinStocks(c.Context(), dbTransaction, chickin, desiredQty, actorID); err != nil { return err } } - // Note: FIFO Consume already adjusts product warehouse quantities, no need to adjust again - latest, err := approvalSvcTx.LatestByTarget(c.Context(), utils.ApprovalWorkflowChickin, projectFlockKandang.Id, nil) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get latest approval") @@ -306,8 +307,13 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { return err } + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return err + } + if chickin.UsageQty > 0 { - if err := s.ReleaseChickinStocks(c.Context(), s.Repository.DB(), chickin); err != nil { + if err := s.ReleaseChickinStocks(c.Context(), s.Repository.DB(), chickin, actorID); err != nil { return err } @@ -461,7 +467,7 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit for _, chickin := range chickins { - if err := s.ReleaseChickinStocks(c.Context(), dbTransaction, &chickin); err != nil { + if err := s.ReleaseChickinStocks(c.Context(), dbTransaction, &chickin, actorID); err != nil { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to release stock for rejected chickin %d: %v", chickin.Id, err)) } @@ -591,7 +597,7 @@ func (s *chickinService) convertChickinsToTarget(ctx *fiber.Ctx, chickins []enti return nil } -func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin, desiredQty float64) error { +func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin, desiredQty float64, actorID uint) error { if chickin == nil || s.FifoSvc == nil { return nil } @@ -622,14 +628,35 @@ func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, return err } + if result.UsageQuantity > 0 { + decreaseLog := &entity.StockLog{ + Decrease: result.UsageQuantity, + LoggableType: string(utils.StockLogTypeChikin), + LoggableId: chickin.Id, + ProductWarehouseId: chickin.ProductWarehouseId, + CreatedBy: actorID, + Notes: fmt.Sprintf("Chickin #%d", chickin.Id), + } + if err := s.StockLogRepo.CreateOne(ctx, decreaseLog, nil); err != nil { + s.Log.Errorf("Failed to create stock log for chickin %d: %+v", chickin.Id, err) + + } + } + return nil } -func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin) error { +func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin, actorID uint) error { if chickin == nil || s.FifoSvc == nil { return nil } + var currentUsage float64 + if err := tx.Model(&entity.ProjectChickin{}).Where("id = ?", chickin.Id).Select("usage_qty").Scan(¤tUsage).Error; err != nil { + s.Log.Warnf("Failed to get current usage for chickin %d: %+v", chickin.Id, err) + currentUsage = 0 + } + if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ UsableKey: chickinUsableKey, UsableID: chickin.Id, @@ -646,6 +673,22 @@ func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, return err } + // Create stock log for the restoration + if currentUsage > 0 { + increaseLog := &entity.StockLog{ + Increase: currentUsage, + LoggableType: string(utils.StockLogTypeChikin), + LoggableId: chickin.Id, + ProductWarehouseId: chickin.ProductWarehouseId, + CreatedBy: actorID, + Notes: fmt.Sprintf("Chickin #%d - Stock released", chickin.Id), + } + if err := s.StockLogRepo.CreateOne(ctx, increaseLog, nil); err != nil { + s.Log.Errorf("Failed to create stock log for chickin %d: %+v", chickin.Id, err) + // Don't return error here, stock already released + } + } + return nil } diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 02b15102..19711c47 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -111,6 +111,8 @@ type StockLogType string const ( StockLogTypeAdjustment StockLogType = "ADJUSTMENT" StockLogTypeTransfer StockLogType = "TRANSFER" + StockLogTypeMarketing StockLogType = "MARKETING" + StockLogTypeChikin StockLogType = "CHICKIN" ) // ------------------------------------------------------------------- From a9037991efc4275310616b6e7bb51d20743b68fd Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 26 Dec 2025 11:20:57 +0700 Subject: [PATCH 08/12] Feat[BE]: integrate document service into expense module and update related DTOs for document handling --- internal/entities/expense.go | 13 +- internal/modules/expenses/dto/expense.dto.go | 21 +- internal/modules/expenses/module.go | 8 +- .../expenses/services/expense.service.go | 252 ++++++++---------- internal/modules/purchases/module.go | 7 + internal/utils/constant.go | 8 +- 6 files changed, 150 insertions(+), 159 deletions(-) diff --git a/internal/entities/expense.go b/internal/entities/expense.go index e6ab1d77..83a6031b 100644 --- a/internal/entities/expense.go +++ b/internal/entities/expense.go @@ -1,7 +1,6 @@ package entities import ( - "database/sql" "time" "gorm.io/gorm" @@ -13,8 +12,6 @@ type Expense struct { SupplierId uint64 `gorm:""` Category string `gorm:"type:varchar(50);not null"` PoNumber string `gorm:"type:varchar(50)"` - DocumentPath sql.NullString `gorm:"type:json"` - RealizationDocumentPath sql.NullString `gorm:"type:json;column:realization_document_path"` RealizationDate time.Time `gorm:"type:date;column:realization_date"` TransactionDate time.Time `gorm:"type:date;not null"` Notes string `gorm:"type:text;column:notes"` @@ -23,8 +20,10 @@ type Expense struct { UpdatedAt time.Time `gorm:"autoUpdateTime"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - Supplier *Supplier `gorm:"foreignKey:SupplierId;references:Id"` - CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` - Nonstocks []ExpenseNonstock `gorm:"foreignKey:ExpenseId;references:Id"` - LatestApproval *Approval `gorm:"-" json:"latest_approval,omitempty"` + Supplier *Supplier `gorm:"foreignKey:SupplierId;references:Id"` + CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` + Nonstocks []ExpenseNonstock `gorm:"foreignKey:ExpenseId;references:Id"` + Documents []Document `gorm:"foreignKey:DocumentableId;references:Id"` + RealizationDocuments []Document `gorm:"foreignKey:DocumentableId;references:Id"` + LatestApproval *Approval `gorm:"-" json:"latest_approval,omitempty"` } diff --git a/internal/modules/expenses/dto/expense.dto.go b/internal/modules/expenses/dto/expense.dto.go index c55dba2c..4bb9ebe1 100644 --- a/internal/modules/expenses/dto/expense.dto.go +++ b/internal/modules/expenses/dto/expense.dto.go @@ -1,7 +1,6 @@ package dto import ( - "encoding/json" "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -41,8 +40,8 @@ type ExpenseListDTO struct { type ExpenseDetailDTO struct { ExpenseBaseDTO - Documents []DocumentDTO `json:"documents,omitempty"` - RealizationDocs []DocumentDTO `json:"realization_docs,omitempty"` + Documents []DocumentDTO `json:"documents"` + RealizationDocs []DocumentDTO `json:"realization_docs"` Kandangs []KandangGroupDTO `json:"kandangs,omitempty"` TotalPengajuan float64 `json:"total_pengajuan"` TotalRealisasi float64 `json:"total_realisasi"` @@ -179,12 +178,20 @@ func ToExpenseDetailDTO(e *entity.Expense) ExpenseDetailDTO { var pengajuans []ExpenseNonstockDTO var realisasi []ExpenseRealizationDTO - if e.DocumentPath.Valid && e.DocumentPath.String != "" { - json.Unmarshal([]byte(e.DocumentPath.String), &documents) + // Map documents from Document service + for _, doc := range e.Documents { + documents = append(documents, DocumentDTO{ + ID: uint64(doc.Id), + Path: doc.Path, + }) } - if e.RealizationDocumentPath.Valid && e.RealizationDocumentPath.String != "" { - json.Unmarshal([]byte(e.RealizationDocumentPath.String), &realizationDocs) + // Map realization documents from Document service + for _, doc := range e.RealizationDocuments { + realizationDocs = append(realizationDocs, DocumentDTO{ + ID: uint64(doc.Id), + Path: doc.Path, + }) } if len(e.Nonstocks) > 0 { diff --git a/internal/modules/expenses/module.go b/internal/modules/expenses/module.go index 6d276b5d..b495b5b9 100644 --- a/internal/modules/expenses/module.go +++ b/internal/modules/expenses/module.go @@ -1,6 +1,7 @@ package expenses import ( + "context" "fmt" "github.com/go-playground/validator/v10" @@ -31,15 +32,20 @@ func (ExpenseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * approvalRepo := commonRepo.NewApprovalRepository(db) realizationRepo := rExpense.NewExpenseRealizationRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) + documentRepo := commonRepo.NewDocumentRepository(db) approvalSvc := commonSvc.NewApprovalService(approvalRepo) + documentSvc, err := commonSvc.NewDocumentServiceFromConfig(context.Background(), documentRepo) + if err != nil { + panic(fmt.Sprintf("failed to create document service: %v", err)) + } // Register workflow steps for EXPENSES approval if err := approvalSvc.RegisterWorkflowSteps(utils.ApprovalWorkflowExpense, utils.ExpenseApprovalSteps); err != nil { panic(fmt.Sprintf("failed to register expense approval workflow: %v", err)) } - expenseService := sExpense.NewExpenseService(expenseRepo, supplierRepo, nonstockRepo, approvalSvc, realizationRepo, projectFlockKandangRepo, validate) + expenseService := sExpense.NewExpenseService(expenseRepo, supplierRepo, nonstockRepo, approvalSvc, realizationRepo, projectFlockKandangRepo, documentSvc, validate) userService := sUser.NewUserService(userRepo, validate) ExpenseRoutes(router, userService, expenseService) diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index 24ba4f2e..728c689f 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -2,11 +2,8 @@ package service import ( "context" - "database/sql" - "encoding/json" "errors" "fmt" - "mime/multipart" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" @@ -49,9 +46,10 @@ type expenseService struct { ApprovalSvc commonSvc.ApprovalService RealizationRepository repository.ExpenseRealizationRepository ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository + DocumentSvc commonSvc.DocumentService } -func NewExpenseService(repo repository.ExpenseRepository, supplierRepo supplierRepo.SupplierRepository, nonstockRepo nonstockRepo.NonstockRepository, approvalSvc commonSvc.ApprovalService, realizationRepo repository.ExpenseRealizationRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, validate *validator.Validate) ExpenseService { +func NewExpenseService(repo repository.ExpenseRepository, supplierRepo supplierRepo.SupplierRepository, nonstockRepo nonstockRepo.NonstockRepository, approvalSvc commonSvc.ApprovalService, realizationRepo repository.ExpenseRealizationRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, documentSvc commonSvc.DocumentService, validate *validator.Validate) ExpenseService { return &expenseService{ Log: utils.Log, Validate: validate, @@ -61,6 +59,7 @@ func NewExpenseService(repo repository.ExpenseRepository, supplierRepo supplierR ApprovalSvc: approvalSvc, RealizationRepository: realizationRepo, ProjectFlockKandangRepo: projectFlockKandangRepo, + DocumentSvc: documentSvc, } } @@ -72,7 +71,13 @@ func (s expenseService) withRelations(db *gorm.DB) *gorm.DB { Preload("Nonstocks.Realization"). Preload("Nonstocks.ProjectFlockKandang.Kandang.Location"). Preload("Nonstocks.Kandang"). - Preload("Nonstocks.Kandang.Location") + Preload("Nonstocks.Kandang.Location"). + Preload("Documents", func(db *gorm.DB) *gorm.DB { + return db.Where("documentable_type = ?", string(utils.DocumentableTypeExpense)) + }). + Preload("RealizationDocuments", func(db *gorm.DB) *gorm.DB { + return db.Where("documentable_type = ?", string(utils.DocumentableTypeExpenseRealization)) + }) } func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expenseDto.ExpenseListDTO, int64, error) { @@ -269,9 +274,23 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen return fiber.NewError(fiber.StatusInternalServerError, "Failed to create initial approval") } - if len(req.Documents) > 0 { - if err := s.processDocuments(c, expenseRepoTx, uint(expense.Id), req.Documents, false); err != nil { - return err + if s.DocumentSvc != nil && len(req.Documents) > 0 { + documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents)) + for idx, file := range req.Documents { + documentFiles = append(documentFiles, commonSvc.DocumentFile{ + File: file, + Type: string(utils.DocumentTypeExpense), + Index: &idx, + }) + } + _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ + DocumentableType: string(utils.DocumentableTypeExpense), + DocumentableID: expense.Id, + CreatedBy: &createdByUint, + Files: documentFiles, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload expense documents") } } @@ -527,9 +546,23 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) } } - if len(req.Documents) > 0 { - if err := s.processDocuments(c, expenseRepoTx, id, req.Documents, false); err != nil { - return err + if s.DocumentSvc != nil && len(req.Documents) > 0 { + documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents)) + for idx, file := range req.Documents { + documentFiles = append(documentFiles, commonSvc.DocumentFile{ + File: file, + Type: string(utils.DocumentTypeExpense), + Index: &idx, + }) + } + _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ + DocumentableType: string(utils.DocumentableTypeExpense), + DocumentableID: uint64(id), + CreatedBy: &actorID, + Files: documentFiles, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload expense documents") } } @@ -658,9 +691,24 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va return fiber.NewError(fiber.StatusInternalServerError, "Failed to update realization date") } - if len(req.Documents) > 0 { - if err := s.processDocuments(c, expenseRepoTx, expenseID, req.Documents, true); err != nil { - return err + if s.DocumentSvc != nil && len(req.Documents) > 0 { + documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents)) + for idx, file := range req.Documents { + documentFiles = append(documentFiles, commonSvc.DocumentFile{ + File: file, + Type: string(utils.DocumentTypeExpenseRealization), + Index: &idx, + }) + } + actorID := uint(1) // TODO: replace with authenticated user id + _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ + DocumentableType: string(utils.DocumentableTypeExpenseRealization), + DocumentableID: uint64(expenseID), + CreatedBy: &actorID, + Files: documentFiles, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload expense realization documents") } } @@ -833,9 +881,24 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va } } - if len(req.Documents) > 0 { - if err := s.processDocuments(c, expenseRepoTx, expenseID, req.Documents, true); err != nil { - return err + if s.DocumentSvc != nil && len(req.Documents) > 0 { + documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents)) + for idx, file := range req.Documents { + documentFiles = append(documentFiles, commonSvc.DocumentFile{ + File: file, + Type: string(utils.DocumentTypeExpenseRealization), + Index: &idx, + }) + } + actorID := uint(1) // TODO: replace with authenticated user id + _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ + DocumentableType: string(utils.DocumentableTypeExpenseRealization), + DocumentableID: uint64(expenseID), + CreatedBy: &actorID, + Files: documentFiles, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload expense realization documents") } } @@ -870,79 +933,6 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va return responseDTO, nil } -func (s *expenseService) processDocuments(ctx *fiber.Ctx, expenseRepoTx repository.ExpenseRepository, expenseID uint, documents []*multipart.FileHeader, isRealization bool) error { - - if len(documents) == 0 { - return nil - } - - var existingDocuments []expenseDto.DocumentDTO - var fieldName string - - if isRealization { - fieldName = "realization_document_path" - } else { - fieldName = "document_path" - } - - expense, err := expenseRepoTx.GetByID(ctx.Context(), expenseID, nil) - if err != nil { - - if !errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense for document processing") - } - } else { - - var documentField sql.NullString - if isRealization { - documentField = expense.RealizationDocumentPath - } else { - documentField = expense.DocumentPath - } - - if documentField.Valid && documentField.String != "" { - if err := json.Unmarshal([]byte(documentField.String), &existingDocuments); err != nil { - existingDocuments = []expenseDto.DocumentDTO{} - } - } - } - - var startID uint64 = 1 - if len(existingDocuments) > 0 { - - maxID := uint64(0) - for _, doc := range existingDocuments { - if doc.ID > maxID { - maxID = doc.ID - } - } - startID = maxID + 1 - } - - for i, doc := range documents { - documentPath := doc.Filename - - document := expenseDto.DocumentDTO{ - ID: startID + uint64(i), - Path: documentPath, - } - existingDocuments = append(existingDocuments, document) - } - - documentJSON, err := json.Marshal(existingDocuments) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to save documents") - } - - if err := expenseRepoTx.PatchOne(ctx.Context(), expenseID, map[string]interface{}{ - fieldName: string(documentJSON), - }, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to save documents") - } - - return nil -} - func (s *expenseService) DeleteDocument(ctx *fiber.Ctx, expenseID uint, documentID uint64, isRealization bool) error { if err := commonSvc.EnsureRelations(ctx.Context(), @@ -951,62 +941,40 @@ func (s *expenseService) DeleteDocument(ctx *fiber.Ctx, expenseID uint, document return err } - if err := s.Repository.DB().WithContext(ctx.Context()).Transaction(func(tx *gorm.DB) error { - expenseRepoTx := repository.NewExpenseRepository(tx) + if s.DocumentSvc == nil { + return fiber.NewError(fiber.StatusInternalServerError, "Document service not available") + } - expense, err := expenseRepoTx.GetByID(ctx.Context(), expenseID, nil) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense for document deletion") + // Verify document exists and belongs to the expense + var documentableType string + if isRealization { + documentableType = string(utils.DocumentableTypeExpenseRealization) + } else { + documentableType = string(utils.DocumentableTypeExpense) + } + + documents, err := s.DocumentSvc.ListByTarget(ctx.Context(), documentableType, uint64(expenseID)) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve documents") + } + + documentFound := false + var documentIDsToDelete []uint + for _, doc := range documents { + if uint64(doc.Id) == documentID { + documentFound = true + documentIDsToDelete = append(documentIDsToDelete, doc.Id) + break } + } - var existingDocuments []expenseDto.DocumentDTO - var fieldName string + if !documentFound { + return fiber.NewError(fiber.StatusNotFound, "Document not found") + } - if isRealization { - fieldName = "realization_document_path" - if expense.RealizationDocumentPath.Valid && expense.RealizationDocumentPath.String != "" { - if err := json.Unmarshal([]byte(expense.RealizationDocumentPath.String), &existingDocuments); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to parse existing realization documents") - } - } - } else { - fieldName = "document_path" - if expense.DocumentPath.Valid && expense.DocumentPath.String != "" { - if err := json.Unmarshal([]byte(expense.DocumentPath.String), &existingDocuments); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to parse existing documents") - } - } - } - - var updatedDocuments []expenseDto.DocumentDTO - documentFound := false - - for _, doc := range existingDocuments { - if doc.ID == documentID { - documentFound = true - continue - } - updatedDocuments = append(updatedDocuments, doc) - } - - if !documentFound { - return fiber.NewError(fiber.StatusNotFound, "Document not found") - } - - documentJSON, err := json.Marshal(updatedDocuments) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to save documents") - } - - if err := expenseRepoTx.PatchOne(ctx.Context(), expenseID, map[string]interface{}{ - fieldName: string(documentJSON), - }, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to save documents") - } - - return nil - }); err != nil { - return err + // Delete document from database and storage + if err := s.DocumentSvc.DeleteDocuments(ctx.Context(), documentIDsToDelete, true); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete document") } return nil diff --git a/internal/modules/purchases/module.go b/internal/modules/purchases/module.go index ec1b24f7..6daf2a39 100644 --- a/internal/modules/purchases/module.go +++ b/internal/modules/purchases/module.go @@ -1,6 +1,7 @@ package purchases import ( + "context" "fmt" "github.com/go-playground/validator/v10" @@ -41,6 +42,11 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) + documentRepo := commonRepo.NewDocumentRepository(db) + documentSvc, err := commonSvc.NewDocumentServiceFromConfig(context.Background(), documentRepo) + if err != nil { + panic(fmt.Sprintf("failed to create document service: %v", err)) + } if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowPurchase, utils.PurchaseApprovalSteps); err != nil { panic(fmt.Sprintf("failed to register purchase approval workflow: %v", err)) } @@ -54,6 +60,7 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate approvalService, expenseRealizationRepo, projectFlockKandangRepository, + documentSvc, validate, ) expenseBridge := service.NewExpenseBridge( diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 19711c47..354c9042 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -324,9 +324,13 @@ type DocumentType string type DocumentableType string const ( - DocumentTypeTransfer DocumentType = "STOCK_TRANSFER_DOCUMENT" + DocumentTypeTransfer DocumentType = "STOCK_TRANSFER_DOCUMENT" + DocumentTypeExpense DocumentType = "EXPENSE_DOCUMENT" + DocumentTypeExpenseRealization DocumentType = "EXPENSE_REALIZATION_DOCUMENT" - DocumentableTypeTransfer DocumentableType = "STOCK_TRANSFER" + DocumentableTypeTransfer DocumentableType = "STOCK_TRANSFER" + DocumentableTypeExpense DocumentableType = "EXPENSE" + DocumentableTypeExpenseRealization DocumentableType = "EXPENSE_REALIZATION" ) // ------------------------------------------------------------------- From 54487b0fcfeb79c609887f21b8c25d9f130ad853 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 26 Dec 2025 11:21:23 +0700 Subject: [PATCH 09/12] Feat[BE]: add migration scripts to manage document columns in expenses table for Document service integration --- ...20251226031727_alter_table_expense_delete_document.down.sql | 3 +++ .../20251226031727_alter_table_expense_delete_document.up.sql | 3 +++ 2 files changed, 6 insertions(+) create mode 100644 internal/database/migrations/20251226031727_alter_table_expense_delete_document.down.sql create mode 100644 internal/database/migrations/20251226031727_alter_table_expense_delete_document.up.sql diff --git a/internal/database/migrations/20251226031727_alter_table_expense_delete_document.down.sql b/internal/database/migrations/20251226031727_alter_table_expense_delete_document.down.sql new file mode 100644 index 00000000..59e54379 --- /dev/null +++ b/internal/database/migrations/20251226031727_alter_table_expense_delete_document.down.sql @@ -0,0 +1,3 @@ +-- Rollback: restore document columns to expenses table +ALTER TABLE expenses ADD COLUMN IF NOT EXISTS document_path JSON; +ALTER TABLE expenses ADD COLUMN IF NOT EXISTS realization_document_path JSON; diff --git a/internal/database/migrations/20251226031727_alter_table_expense_delete_document.up.sql b/internal/database/migrations/20251226031727_alter_table_expense_delete_document.up.sql new file mode 100644 index 00000000..c75bc307 --- /dev/null +++ b/internal/database/migrations/20251226031727_alter_table_expense_delete_document.up.sql @@ -0,0 +1,3 @@ +-- Delete document columns from expenses table since we now use Document service with polymorphic relations +ALTER TABLE expenses DROP COLUMN IF EXISTS document_path; +ALTER TABLE expenses DROP COLUMN IF EXISTS realization_document_path; From cd14de4dd29a59a8d6c727411a22f587669ecc3f Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 26 Dec 2025 19:02:50 +0700 Subject: [PATCH 10/12] Feat[BE]: implement FIFO stock management for marketing delivery products, including migration scripts and updates to related services and DTOs --- ...ds_to_marketing_delivery_products.down.sql | 28 +++++ ...elds_to_marketing_delivery_products.up.sql | 58 +++++++++ .../migrations/20251226114218_add.down.sql | 7 ++ .../migrations/20251226114218_add.up.sql | 19 +++ .../entities/marketing_delivery_product.go | 23 ++-- internal/middleware/permissions.go | 1 + .../closings/dto/closingMarketing.dto.go | 2 +- .../closings/services/closing.service.go | 6 +- .../marketing/dto/deliveryorder.dto.go | 2 +- internal/modules/marketing/module.go | 33 ++++- .../salesorder_delivery_product.repository.go | 33 +++++ internal/modules/marketing/route.go | 15 ++- .../services/deliveryorder.service.go | 113 ++++++++++++------ .../marketing/services/salesorder.service.go | 9 +- .../repports/dto/repportMarketing.dto.go | 8 +- internal/utils/fifo/constants.go | 5 +- 16 files changed, 290 insertions(+), 72 deletions(-) create mode 100644 internal/database/migrations/20251226054852_add_fifo_fields_to_marketing_delivery_products.down.sql create mode 100644 internal/database/migrations/20251226054852_add_fifo_fields_to_marketing_delivery_products.up.sql create mode 100644 internal/database/migrations/20251226114218_add.down.sql create mode 100644 internal/database/migrations/20251226114218_add.up.sql diff --git a/internal/database/migrations/20251226054852_add_fifo_fields_to_marketing_delivery_products.down.sql b/internal/database/migrations/20251226054852_add_fifo_fields_to_marketing_delivery_products.down.sql new file mode 100644 index 00000000..b9637077 --- /dev/null +++ b/internal/database/migrations/20251226054852_add_fifo_fields_to_marketing_delivery_products.down.sql @@ -0,0 +1,28 @@ +-- ============================================ +-- Rollback: Remove FIFO fields and restore qty column +-- ============================================ + +-- STEP 1: Drop indexes +DROP INDEX IF EXISTS idx_marketing_delivery_products_fifo_lookup; +DROP INDEX IF EXISTS idx_marketing_delivery_products_pending_qty; +DROP INDEX IF EXISTS idx_marketing_delivery_products_usage_qty; +DROP INDEX IF EXISTS idx_marketing_delivery_products_created_at; + +-- STEP 2: Drop constraints +ALTER TABLE marketing_delivery_products +DROP CONSTRAINT IF EXISTS chk_marketing_delivery_products_fifo_nonneg; + +-- STEP 3: Restore qty column from usage_qty data +ALTER TABLE marketing_delivery_products +ADD COLUMN IF NOT EXISTS qty NUMERIC(15, 3) DEFAULT 0 NOT NULL; + +-- Migrate data back from usage_qty to qty +UPDATE marketing_delivery_products +SET qty = usage_qty +WHERE qty = 0; + +-- STEP 4: Drop FIFO columns +ALTER TABLE marketing_delivery_products +DROP COLUMN IF EXISTS usage_qty, +DROP COLUMN IF EXISTS pending_qty, +DROP COLUMN IF EXISTS created_at; diff --git a/internal/database/migrations/20251226054852_add_fifo_fields_to_marketing_delivery_products.up.sql b/internal/database/migrations/20251226054852_add_fifo_fields_to_marketing_delivery_products.up.sql new file mode 100644 index 00000000..160c1f05 --- /dev/null +++ b/internal/database/migrations/20251226054852_add_fifo_fields_to_marketing_delivery_products.up.sql @@ -0,0 +1,58 @@ +-- ============================================ +-- Add FIFO fields to marketing_delivery_products +-- This migration adds fields needed for FIFO stock management +-- and removes the old qty field in favor of FIFO-based allocation +-- ============================================ + +-- STEP 0: Drop orphan indexes from previous migration +DROP INDEX IF EXISTS idx_marketing_delivery_products_deleted_at; + +-- STEP 1: Add created_at column (required for FIFO ordering) +ALTER TABLE marketing_delivery_products +ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT NOW(); + +-- STEP 2: Add FIFO tracking fields +ALTER TABLE marketing_delivery_products +ADD COLUMN IF NOT EXISTS usage_qty NUMERIC(15, 3) DEFAULT 0, +ADD COLUMN IF NOT EXISTS pending_qty NUMERIC(15, 3) DEFAULT 0; + +-- STEP 3: Migrate data from old qty to usage_qty for existing records +-- This preserves existing quantity data as allocated quantity +UPDATE marketing_delivery_products +SET + usage_qty = COALESCE(qty, 0), + pending_qty = 0 +WHERE usage_qty = 0; + +-- STEP 4: Drop the old qty column (replaced by usage_qty + pending_qty) +ALTER TABLE marketing_delivery_products +DROP COLUMN IF EXISTS qty; + +-- STEP 5: Make FIFO fields NOT NULL +ALTER TABLE marketing_delivery_products +ALTER COLUMN usage_qty SET NOT NULL, +ALTER COLUMN pending_qty SET NOT NULL, +ALTER COLUMN created_at SET NOT NULL; + +-- STEP 6: Add constraints to ensure non-negative values +ALTER TABLE marketing_delivery_products +ADD CONSTRAINT chk_marketing_delivery_products_fifo_nonneg CHECK ( + usage_qty >= 0 AND + pending_qty >= 0 +); + +-- STEP 7: Create indexes for FIFO operations +CREATE INDEX IF NOT EXISTS idx_marketing_delivery_products_created_at +ON marketing_delivery_products(created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_marketing_delivery_products_usage_qty +ON marketing_delivery_products(usage_qty) +WHERE usage_qty > 0; + +CREATE INDEX IF NOT EXISTS idx_marketing_delivery_products_pending_qty +ON marketing_delivery_products(pending_qty) +WHERE pending_qty > 0; + +-- Composite index for FIFO lookups +CREATE INDEX IF NOT EXISTS idx_marketing_delivery_products_fifo_lookup +ON marketing_delivery_products(marketing_product_id, created_at DESC); diff --git a/internal/database/migrations/20251226114218_add.down.sql b/internal/database/migrations/20251226114218_add.down.sql new file mode 100644 index 00000000..15f3368a --- /dev/null +++ b/internal/database/migrations/20251226114218_add.down.sql @@ -0,0 +1,7 @@ +-- Remove foreign key constraint +ALTER TABLE marketing_delivery_products +DROP CONSTRAINT IF EXISTS fk_marketing_delivery_products_product_warehouse; + +-- Drop product_warehouse_id column +ALTER TABLE marketing_delivery_products +DROP COLUMN IF EXISTS product_warehouse_id; diff --git a/internal/database/migrations/20251226114218_add.up.sql b/internal/database/migrations/20251226114218_add.up.sql new file mode 100644 index 00000000..97e5b4ee --- /dev/null +++ b/internal/database/migrations/20251226114218_add.up.sql @@ -0,0 +1,19 @@ +-- Add product_warehouse_id column to marketing_delivery_products +ALTER TABLE marketing_delivery_products +ADD COLUMN IF NOT EXISTS product_warehouse_id INT NOT NULL DEFAULT 0; + +-- Fill product_warehouse_id from marketing_products +UPDATE marketing_delivery_products mdp +SET product_warehouse_id = mp.product_warehouse_id +FROM marketing_products mp +WHERE mdp.marketing_product_id = mp.id + AND mdp.product_warehouse_id = 0; + +-- Set NOT NULL constraint +ALTER TABLE marketing_delivery_products +ALTER COLUMN product_warehouse_id SET NOT NULL; + +-- Add foreign key constraint +ALTER TABLE marketing_delivery_products +ADD CONSTRAINT fk_marketing_delivery_products_product_warehouse +FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses(id); diff --git a/internal/entities/marketing_delivery_product.go b/internal/entities/marketing_delivery_product.go index 47f6e89d..7ac3d967 100644 --- a/internal/entities/marketing_delivery_product.go +++ b/internal/entities/marketing_delivery_product.go @@ -5,15 +5,20 @@ import ( ) type MarketingDeliveryProduct struct { - Id uint `gorm:"primaryKey;autoIncrement"` - MarketingProductId uint `gorm:"uniqueIndex;not null"` - Qty float64 `gorm:"type:numeric(15,3)"` - UnitPrice float64 `gorm:"type:numeric(15,3)"` - TotalWeight float64 `gorm:"type:numeric(15,3)"` - AvgWeight float64 `gorm:"type:numeric(15,3)"` - TotalPrice float64 `gorm:"type:numeric(15,3)"` - DeliveryDate *time.Time `gorm:"type:timestamptz"` - VehicleNumber string `gorm:"type:varchar(50)"` + Id uint `gorm:"primaryKey;autoIncrement"` + MarketingProductId uint `gorm:"uniqueIndex;not null"` + ProductWarehouseId uint `gorm:"not null"` + UnitPrice float64 `gorm:"type:numeric(15,3)"` + TotalWeight float64 `gorm:"type:numeric(15,3)"` + AvgWeight float64 `gorm:"type:numeric(15,3)"` + TotalPrice float64 `gorm:"type:numeric(15,3)"` + DeliveryDate *time.Time `gorm:"type:timestamptz"` + VehicleNumber string `gorm:"type:varchar(50)"` + + // FIFO Fields + UsageQty float64 `gorm:"type:numeric(15,3);default:0;not null"` + PendingQty float64 `gorm:"type:numeric(15,3);default:0;not null"` + CreatedAt *time.Time `gorm:"type:timestamptz;not null"` MarketingProduct MarketingProduct `gorm:"foreignKey:MarketingProductId;references:Id"` } diff --git a/internal/middleware/permissions.go b/internal/middleware/permissions.go index f46c25a9..d384fee7 100644 --- a/internal/middleware/permissions.go +++ b/internal/middleware/permissions.go @@ -77,6 +77,7 @@ const ( P_DeliveryGetAll = "lti.marketing.delivery_order.list" P_DeliveryGetOne = "lti.marketing.delivery_order.detail" P_DeliveryUpdateOne = "lti.marketing.delivery_order.update" + P_DeliveryCreateOne = "lti.marketing.delivery_order.Create" P_SalesOrderDelete = "lti.marketing.sales_order.delete" P_SalesOrderApproval = "lti.marketing.sales_order.approve" P_SalesOrderCreateOne = "lti.marketing.sales_order.create" diff --git a/internal/modules/closings/dto/closingMarketing.dto.go b/internal/modules/closings/dto/closingMarketing.dto.go index 8c904561..4c7b4d35 100644 --- a/internal/modules/closings/dto/closingMarketing.dto.go +++ b/internal/modules/closings/dto/closingMarketing.dto.go @@ -64,7 +64,7 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO { DoNumber: doNumber, Product: product, Customer: customer, - Qty: e.Qty, + Qty: e.UsageQty, // Show allocated quantity from FIFO Weight: e.TotalWeight, AvgWeight: e.AvgWeight, Price: e.UnitPrice, diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 48728195..ab8e6f7b 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -712,13 +712,13 @@ func (s closingService) calculateAverageSalesAge(ctx context.Context, projectFlo ) for _, product := range deliveryProducts { - if product.Qty == 0 { + if product.UsageQty == 0 { continue } projectFlockKandang := product.MarketingProduct.ProductWarehouse.ProjectFlockKandang ageWeeks := dto.CalculateAgeFromChickinDataProduksi(projectFlockKandang, product.DeliveryDate) - totalAgeWeeks += float64(ageWeeks) * product.Qty - totalQty += product.Qty + totalAgeWeeks += float64(ageWeeks) * product.UsageQty + totalQty += product.UsageQty } if totalQty == 0 { diff --git a/internal/modules/marketing/dto/deliveryorder.dto.go b/internal/modules/marketing/dto/deliveryorder.dto.go index b2bb70d7..a6eea180 100644 --- a/internal/modules/marketing/dto/deliveryorder.dto.go +++ b/internal/modules/marketing/dto/deliveryorder.dto.go @@ -121,7 +121,7 @@ func ToMarketingDeliveryProductDTO(e entity.MarketingDeliveryProduct) MarketingD return MarketingDeliveryProductDTO{ Id: e.Id, MarketingProductId: e.MarketingProductId, - Qty: e.Qty, + Qty: e.UsageQty, UnitPrice: e.UnitPrice, TotalWeight: e.TotalWeight, AvgWeight: e.AvgWeight, diff --git a/internal/modules/marketing/module.go b/internal/modules/marketing/module.go index 586e7961..d8c8fc6a 100644 --- a/internal/modules/marketing/module.go +++ b/internal/modules/marketing/module.go @@ -2,6 +2,7 @@ package marketing import ( "fmt" + "strings" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -13,11 +14,12 @@ import ( repository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" service "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/services" rCustomer "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/repositories" - rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" - sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" ) type MarketingModule struct{} @@ -31,6 +33,28 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate customerRepo := rCustomer.NewCustomerRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + // Initialize FIFO service + stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) + fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) + + // Register marketing_delivery_products as FIFO Usable + // Note: ProductWarehouseID comes from marketing_products table via preload + if err := fifoService.RegisterUsable(fifo.UsableConfig{ + Key: fifo.UsableKeyMarketingDelivery, + Table: "marketing_delivery_products", + Columns: fifo.UsableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", // Resolved from marketing_products via preload + UsageQuantity: "usage_qty", + PendingQuantity: "pending_qty", + CreatedAt: "created_at", + }, + }); err != nil { + if !strings.Contains(strings.ToLower(err.Error()), "already registered") { + panic(fmt.Sprintf("failed to register marketing delivery usable workflow: %v", err)) + } + } + // Initialize approval service approvalRepo := commonRepo.NewApprovalRepository(db) approvalSvc := commonSvc.NewApprovalService(approvalRepo) @@ -42,9 +66,10 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate warehouseRepo := rWarehouse.NewWarehouseRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) + // Initialize services - salesOrdersService := service.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc,warehouseRepo,projectFlockKandangRepo, validate) - deliveryOrdersService := service.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, approvalSvc, validate) + salesOrdersService := service.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc, warehouseRepo, projectFlockKandangRepo, validate) + deliveryOrdersService := service.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, approvalSvc, fifoService, validate) userService := sUser.NewUserService(userRepo, validate) // Register routes diff --git a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go index ba2c1133..04051009 100644 --- a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go +++ b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go @@ -17,6 +17,9 @@ type MarketingDeliveryProductRepository interface { GetByMarketingId(ctx context.Context, marketingId uint) ([]entity.MarketingDeliveryProduct, error) GetByMarketingProductID(ctx context.Context, marketingProductID uint) (*entity.MarketingDeliveryProduct, error) GetAllWithFilters(ctx context.Context, offset, limit int, filters *validation.MarketingQuery) ([]entity.MarketingDeliveryProduct, int64, error) + UpdateFifoFields(ctx context.Context, id uint, usageQty, pendingQty float64) error + GetUsageQty(ctx context.Context, id uint) (float64, error) + ResetFifoFields(ctx context.Context, id uint) error } type MarketingDeliveryProductRepositoryImpl struct { @@ -241,3 +244,33 @@ func containsJoin(db *gorm.DB, tableName string) bool { joinSQL := statement.SQL.String() return strings.Contains(joinSQL, "JOIN "+tableName) } + +func (r *MarketingDeliveryProductRepositoryImpl) UpdateFifoFields(ctx context.Context, id uint, usageQty, pendingQty float64) error { + return r.DB().WithContext(ctx). + Model(&entity.MarketingDeliveryProduct{}). + Where("id = ?", id). + Updates(map[string]interface{}{ + "usage_qty": usageQty, + "pending_qty": pendingQty, + }).Error +} + +func (r *MarketingDeliveryProductRepositoryImpl) GetUsageQty(ctx context.Context, id uint) (float64, error) { + var usageQty float64 + err := r.DB().WithContext(ctx). + Model(&entity.MarketingDeliveryProduct{}). + Where("id = ?", id). + Select("usage_qty"). + Scan(&usageQty).Error + return usageQty, err +} + +func (r *MarketingDeliveryProductRepositoryImpl) ResetFifoFields(ctx context.Context, id uint) error { + return r.DB().WithContext(ctx). + Model(&entity.MarketingDeliveryProduct{}). + Where("id = ?", id). + Updates(map[string]interface{}{ + "usage_qty": 0, + "pending_qty": 0, + }).Error +} diff --git a/internal/modules/marketing/route.go b/internal/modules/marketing/route.go index 139d1ee9..81402c7c 100644 --- a/internal/modules/marketing/route.go +++ b/internal/modules/marketing/route.go @@ -16,12 +16,15 @@ func RegisterRoutes(router fiber.Router, userService user.UserService, salesOrde route := router.Group("/marketing") route.Use(m.Auth(userService)) - route.Get("/",m.RequirePermissions(m.P_DeliveryGetAll), deliveryOrdersCtrl.GetAll) - route.Get("/:id",m.RequirePermissions(m.P_DeliveryGetOne), deliveryOrdersCtrl.GetOne) - route.Delete("/:id",m.RequirePermissions(m.P_SalesOrderDelete), salesOrdersCtrl.DeleteOne) + route.Get("/", m.RequirePermissions(m.P_DeliveryGetAll), deliveryOrdersCtrl.GetAll) + route.Get("/:id", m.RequirePermissions(m.P_DeliveryGetOne), deliveryOrdersCtrl.GetOne) + route.Delete("/:id", m.RequirePermissions(m.P_SalesOrderDelete), salesOrdersCtrl.DeleteOne) - route.Post("/sales-orders",m.RequirePermissions(m.P_SalesOrderCreateOne), salesOrdersCtrl.CreateOne) - route.Patch("/sales-orders/:id",m.RequirePermissions(m.P_SalesOrderUpdateOne), salesOrdersCtrl.UpdateOne) - route.Post("/sales-orders/approvals",m.RequirePermissions(m.P_SalesOrderApproval), salesOrdersCtrl.Approval) + route.Post("/sales-orders", m.RequirePermissions(m.P_SalesOrderCreateOne), salesOrdersCtrl.CreateOne) + route.Patch("/sales-orders/:id", m.RequirePermissions(m.P_SalesOrderUpdateOne), salesOrdersCtrl.UpdateOne) + route.Post("/sales-orders/approvals", m.RequirePermissions(m.P_SalesOrderApproval), salesOrdersCtrl.Approval) + + route.Post("/delivery-orders", m.RequirePermissions(m.P_DeliveryCreateOne), deliveryOrdersCtrl.CreateOne) + route.Patch("/delivery-orders/:id", m.RequirePermissions(m.P_DeliveryUpdateOne), deliveryOrdersCtrl.UpdateOne) } diff --git a/internal/modules/marketing/services/deliveryorder.service.go b/internal/modules/marketing/services/deliveryorder.service.go index 793ed716..e864a778 100644 --- a/internal/modules/marketing/services/deliveryorder.service.go +++ b/internal/modules/marketing/services/deliveryorder.service.go @@ -15,10 +15,10 @@ import ( marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" - "github.com/sirupsen/logrus" "gorm.io/gorm" ) @@ -30,12 +30,12 @@ type DeliveryOrdersService interface { } type deliveryOrdersService struct { - Log *logrus.Logger Validate *validator.Validate MarketingRepo marketingRepo.MarketingRepository MarketingProductRepo marketingRepo.MarketingProductRepository MarketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository ApprovalSvc commonSvc.ApprovalService + FifoSvc commonSvc.FifoService } func NewDeliveryOrdersService( @@ -43,15 +43,16 @@ func NewDeliveryOrdersService( marketingProductRepo marketingRepo.MarketingProductRepository, marketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository, approvalSvc commonSvc.ApprovalService, + fifoSvc commonSvc.FifoService, validate *validator.Validate, ) DeliveryOrdersService { return &deliveryOrdersService{ - Log: utils.Log, Validate: validate, MarketingRepo: marketingRepo, MarketingProductRepo: marketingProductRepo, MarketingDeliveryProductRepo: marketingDeliveryProductRepo, ApprovalSvc: approvalSvc, + FifoSvc: fifoSvc, } } @@ -108,7 +109,6 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO }) if err != nil { - s.Log.Errorf("Failed to get marketings: %+v", err) return nil, 0, err } for i := range marketings { @@ -116,7 +116,7 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO return db.Preload("ActionUser") }) if err != nil { - s.Log.Warnf("Failed to load approval for marketing %d: %+v", marketings[i].Id, err) + continue } marketings[i].LatestApproval = latestApproval } @@ -247,7 +247,7 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery itemDeliveryDate = &parsedDate } - deliveryProduct.Qty = requestedProduct.Qty + deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId deliveryProduct.UnitPrice = requestedProduct.UnitPrice deliveryProduct.AvgWeight = requestedProduct.AvgWeight deliveryProduct.TotalWeight = requestedProduct.TotalWeight @@ -256,7 +256,8 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery deliveryProduct.VehicleNumber = requestedProduct.VehicleNumber if requestedProduct.Qty > 0 { - if err := s.validateAndReduceProductWarehouse(c.Context(), dbTransaction, foundMarketingProduct, requestedProduct.Qty); err != nil { + + if err := s.consumeDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct, requestedProduct.Qty); err != nil { return err } } @@ -354,8 +355,9 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO itemDeliveryDate = deliveryProduct.DeliveryDate } - oldQty := deliveryProduct.Qty - deliveryProduct.Qty = requestedProduct.Qty + oldRequestedQty := deliveryProduct.UsageQty + deliveryProduct.PendingQty + + deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId deliveryProduct.UnitPrice = requestedProduct.UnitPrice deliveryProduct.AvgWeight = requestedProduct.AvgWeight deliveryProduct.TotalWeight = requestedProduct.TotalWeight @@ -363,14 +365,18 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO deliveryProduct.DeliveryDate = itemDeliveryDate deliveryProduct.VehicleNumber = requestedProduct.VehicleNumber - qtyChange := requestedProduct.Qty - oldQty - if qtyChange > 0 { - if err := s.validateAndReduceProductWarehouse(c.Context(), dbTransaction, foundMarketingProduct, qtyChange); err != nil { - return err + if requestedProduct.Qty != oldRequestedQty { + + if oldRequestedQty > 0 { + if err := s.releaseDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct); err != nil { + return err + } } - } else if qtyChange < 0 { - if err := s.restoreProductWarehouseStock(c.Context(), dbTransaction, foundMarketingProduct, -qtyChange); err != nil { - return err + + if requestedProduct.Qty > 0 { + if err := s.consumeDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct, requestedProduct.Qty); err != nil { + return err + } } } @@ -393,50 +399,79 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO return s.getMarketingWithDeliveries(c, id) } -func (s deliveryOrdersService) validateAndReduceProductWarehouse(ctx context.Context, tx *gorm.DB, marketingProduct *entity.MarketingProduct, qtyDeliver float64) error { +func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gorm.DB, deliveryProduct *entity.MarketingDeliveryProduct, marketingProduct *entity.MarketingProduct, requestedQty float64) error { if marketingProduct == nil || marketingProduct.ProductWarehouseId == 0 { return fiber.NewError(fiber.StatusInternalServerError, "Product warehouse not found") } - pwRepo := productWarehouseRepo.NewProductWarehouseRepository(tx) + if deliveryProduct == nil || deliveryProduct.Id == 0 { + return fiber.NewError(fiber.StatusInternalServerError, "Delivery product not found") + } + + result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ + UsableKey: fifo.UsableKeyMarketingDelivery, + UsableID: deliveryProduct.Id, + ProductWarehouseID: marketingProduct.ProductWarehouseId, + Quantity: requestedQty, + AllowPending: false, + Tx: tx, + }) + + deliveryProductRepo := marketingRepo.NewMarketingDeliveryProductRepository(tx) - pw, err := pwRepo.GetByID(ctx, marketingProduct.ProductWarehouseId, nil) if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "Product warehouse not found") + pwRepo := productWarehouseRepo.NewProductWarehouseRepository(tx) + pw, err2 := pwRepo.GetByID(ctx, marketingProduct.ProductWarehouseId, nil) + if err2 != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to check product warehouse stock") } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to check stock") + + if pw == nil || pw.Quantity < requestedQty { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock. Available: %.2f, Requested: %.2f", func() float64 { if pw != nil { return pw.Quantity } else { return 0 } }(), requestedQty)) + } + + if err := deliveryProductRepo.UpdateFifoFields(ctx, deliveryProduct.Id, requestedQty, 0); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product") + } + return nil } - if pw.Quantity < qtyDeliver { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock for warehouse - available: %.2f, requested: %.2f", pw.Quantity, qtyDeliver)) + if err := deliveryProductRepo.UpdateFifoFields(ctx, deliveryProduct.Id, result.UsageQuantity, result.PendingQuantity); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product") } - pw.Quantity = pw.Quantity - qtyDeliver - if err := pwRepo.UpdateOne(ctx, pw.Id, pw, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to update stock") - } return nil } -func (s deliveryOrdersService) restoreProductWarehouseStock(ctx context.Context, tx *gorm.DB, marketingProduct *entity.MarketingProduct, qtyRestore float64) error { +func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gorm.DB, deliveryProduct *entity.MarketingDeliveryProduct, marketingProduct *entity.MarketingProduct) error { + if deliveryProduct == nil || deliveryProduct.Id == 0 { + return nil + } + if marketingProduct == nil || marketingProduct.ProductWarehouseId == 0 { return fiber.NewError(fiber.StatusInternalServerError, "Product warehouse not found") } - pwRepo := productWarehouseRepo.NewProductWarehouseRepository(tx) - pw, err := pwRepo.GetByID(ctx, marketingProduct.ProductWarehouseId, nil) + deliveryProductRepo := marketingRepo.NewMarketingDeliveryProductRepository(tx) + currentUsage, err := deliveryProductRepo.GetUsageQty(ctx, deliveryProduct.Id) if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "Product warehouse not found") - } - - return fiber.NewError(fiber.StatusInternalServerError, "Failed to check stock") + currentUsage = 0 } - pw.Quantity = pw.Quantity + qtyRestore - if err := pwRepo.UpdateOne(ctx, pw.Id, pw, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to update stock") + if currentUsage == 0 { + return nil + } + + if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ + UsableKey: fifo.UsableKeyMarketingDelivery, + UsableID: deliveryProduct.Id, + Tx: tx, + }); err != nil { + return err + } + + if err := deliveryProductRepo.ResetFifoFields(ctx, deliveryProduct.Id); err != nil { + return err } return nil diff --git a/internal/modules/marketing/services/salesorder.service.go b/internal/modules/marketing/services/salesorder.service.go index 02cd2e42..bef2a477 100644 --- a/internal/modules/marketing/services/salesorder.service.go +++ b/internal/modules/marketing/services/salesorder.service.go @@ -307,15 +307,17 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u if _, err := invDeliveryRepoTx.GetByMarketingProductID(c.Context(), old.Id); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { + mdp := &entity.MarketingDeliveryProduct{ MarketingProductId: old.Id, - Qty: 0, UnitPrice: 0, TotalWeight: 0, AvgWeight: 0, TotalPrice: 0, DeliveryDate: nil, VehicleNumber: rp.VehicleNumber, + UsageQty: 0, + PendingQty: 0, } if err := invDeliveryRepoTx.CreateOne(c.Context(), mdp, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create marketing delivery product") @@ -340,7 +342,7 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u } if err == nil { - if deliveryProduct.DeliveryDate != nil || deliveryProduct.Qty > 0 { + if deliveryProduct.DeliveryDate != nil || deliveryProduct.UsageQty > 0 || deliveryProduct.PendingQty > 0 { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot delete marketing product %d because it has delivery records", old.Id)) } @@ -602,13 +604,14 @@ func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Cont marketingDeliveryProduct := &entity.MarketingDeliveryProduct{ MarketingProductId: marketingProduct.Id, - Qty: 0, UnitPrice: 0, TotalWeight: 0, AvgWeight: 0, TotalPrice: 0, DeliveryDate: nil, VehicleNumber: rp.VehicleNumber, + UsageQty: 0, + PendingQty: 0, } if err := invDeliveryRepo.CreateOne(ctx, marketingDeliveryProduct, nil); err != nil { return err diff --git a/internal/modules/repports/dto/repportMarketing.dto.go b/internal/modules/repports/dto/repportMarketing.dto.go index 9c026590..90c2fe50 100644 --- a/internal/modules/repports/dto/repportMarketing.dto.go +++ b/internal/modules/repports/dto/repportMarketing.dto.go @@ -61,7 +61,7 @@ func ToRepportMarketingItemDTO(mdp entity.MarketingDeliveryProduct, hppPricePerK doNumber := marketingDTO.GenerateDeliveryOrderNumber(mdp.MarketingProduct.Marketing.SoNumber, mdp.DeliveryDate, mdp.MarketingProduct.ProductWarehouse.WarehouseId) - totalWeightKg := mdp.Qty * mdp.AvgWeight + totalWeightKg := mdp.UsageQty * mdp.AvgWeight salesAmount := totalWeightKg * mdp.UnitPrice var hpp float64 @@ -78,7 +78,7 @@ func ToRepportMarketingItemDTO(mdp entity.MarketingDeliveryProduct, hppPricePerK AgingDays: agingDays, DoNumber: doNumber, MarketingType: getMarketingType(mdp), - Qty: mdp.Qty, + Qty: mdp.UsageQty, AverageWeightKg: mdp.AvgWeight, TotalWeightKg: totalWeightKg, SalesPricePerKg: mdp.UnitPrice, @@ -194,8 +194,8 @@ func ToSummary(mdps []entity.MarketingDeliveryProduct, hppPricePerKg float64, ca totalHppAmount := int64(0) for _, mdp := range mdps { - calculatedTotalWeight := mdp.Qty * mdp.AvgWeight - totalQty += int(mdp.Qty) + calculatedTotalWeight := mdp.UsageQty * mdp.AvgWeight + totalQty += int(mdp.UsageQty) totalWeightKg += calculatedTotalWeight totalSalesAmount += int64(calculatedTotalWeight * mdp.UnitPrice) diff --git a/internal/utils/fifo/constants.go b/internal/utils/fifo/constants.go index fd0bca06..ea6f96c0 100644 --- a/internal/utils/fifo/constants.go +++ b/internal/utils/fifo/constants.go @@ -1,6 +1,7 @@ package fifo const ( - UsableKeyRecordingStock UsableKey = "RECORDING_STOCK" - UsableKeyProjectChickin UsableKey = "PROJECT_CHICKIN" + UsableKeyRecordingStock UsableKey = "RECORDING_STOCK" + UsableKeyProjectChickin UsableKey = "PROJECT_CHICKIN" + UsableKeyMarketingDelivery UsableKey = "MARKETING_DELIVERY" ) From b156e06cee6ecaebe617e53afdc9686d23a67afe Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 26 Dec 2025 23:36:53 +0700 Subject: [PATCH 11/12] Feat[BE]: add migration scripts for product warehouse ID management and create production standards tables with constraints and indexes --- ...d_to_marketing_delivery_products.down.sql} | 0 ..._id_to_marketing_delivery_products.up.sql} | 0 ...reate_production_standards_tables.down.sql | 10 ++++ ..._create_production_standards_tables.up.sql | 54 +++++++++++++++++++ 4 files changed, 64 insertions(+) rename internal/database/migrations/{20251226114218_add.down.sql => 20251226114218_add_product_warehouse_id_to_marketing_delivery_products.down.sql} (100%) rename internal/database/migrations/{20251226114218_add.up.sql => 20251226114218_add_product_warehouse_id_to_marketing_delivery_products.up.sql} (100%) create mode 100644 internal/database/migrations/20251226161036_create_production_standards_tables.down.sql create mode 100644 internal/database/migrations/20251226161036_create_production_standards_tables.up.sql diff --git a/internal/database/migrations/20251226114218_add.down.sql b/internal/database/migrations/20251226114218_add_product_warehouse_id_to_marketing_delivery_products.down.sql similarity index 100% rename from internal/database/migrations/20251226114218_add.down.sql rename to internal/database/migrations/20251226114218_add_product_warehouse_id_to_marketing_delivery_products.down.sql diff --git a/internal/database/migrations/20251226114218_add.up.sql b/internal/database/migrations/20251226114218_add_product_warehouse_id_to_marketing_delivery_products.up.sql similarity index 100% rename from internal/database/migrations/20251226114218_add.up.sql rename to internal/database/migrations/20251226114218_add_product_warehouse_id_to_marketing_delivery_products.up.sql diff --git a/internal/database/migrations/20251226161036_create_production_standards_tables.down.sql b/internal/database/migrations/20251226161036_create_production_standards_tables.down.sql new file mode 100644 index 00000000..f5cc2237 --- /dev/null +++ b/internal/database/migrations/20251226161036_create_production_standards_tables.down.sql @@ -0,0 +1,10 @@ +-- Drop indexes +DROP INDEX IF EXISTS idx_standard_growth_details_standard_week; +DROP INDEX IF EXISTS idx_production_standard_details_standard_week; +DROP INDEX IF EXISTS idx_production_standards_project_category; +DROP INDEX IF EXISTS idx_production_standards_deleted_at; + +-- Drop tables (in reverse order due to foreign keys) +DROP TABLE IF EXISTS standard_growth_details; +DROP TABLE IF EXISTS production_standard_details; +DROP TABLE IF EXISTS production_standards; diff --git a/internal/database/migrations/20251226161036_create_production_standards_tables.up.sql b/internal/database/migrations/20251226161036_create_production_standards_tables.up.sql new file mode 100644 index 00000000..61aa3071 --- /dev/null +++ b/internal/database/migrations/20251226161036_create_production_standards_tables.up.sql @@ -0,0 +1,54 @@ +-- Create production_standards table +CREATE TABLE IF NOT EXISTS production_standards ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(100) UNIQUE NOT NULL, + project_category VARCHAR(20) NOT NULL CHECK (project_category IN ('GROWING', 'LAYING')), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + created_by BIGINT NOT NULL +); + +-- Create index for deleted_at (soft delete) +CREATE INDEX idx_production_standards_deleted_at ON production_standards(deleted_at); + +-- Create production_standard_details table +CREATE TABLE IF NOT EXISTS production_standard_details ( + id BIGSERIAL PRIMARY KEY, + production_standard_id BIGINT NOT NULL, + week INT NOT NULL, + target_hen_day_production NUMERIC(15, 3), + target_hen_house_production NUMERIC(15, 3), + target_egg_weight NUMERIC(15, 3), + target_egg_mass NUMERIC(15, 3), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + CONSTRAINT fk_production_standard_details_standard FOREIGN KEY (production_standard_id) + REFERENCES production_standards(id) ON DELETE CASCADE +); + +-- Create unique constraint for standard_id + week +CREATE UNIQUE INDEX idx_production_standard_details_standard_week + ON production_standard_details(production_standard_id, week); + +-- Create standard_growth_details table +CREATE TABLE IF NOT EXISTS standard_growth_details ( + id BIGSERIAL PRIMARY KEY, + production_standard_id BIGINT NOT NULL, + target_mean_bw INT, + max_depletion NUMERIC(15, 3), + min_uniformity NUMERIC(15, 3) NOT NULL, + week INT NOT NULL, + feed_intake INT, + created_at TIMESTAMPTZ DEFAULT NOW(), + created_by BIGINT NOT NULL, + CONSTRAINT fk_standard_growth_details_standard FOREIGN KEY (production_standard_id) + REFERENCES production_standards(id) ON DELETE CASCADE +); + +-- Create unique constraint for standard_id + week +CREATE UNIQUE INDEX idx_standard_growth_details_standard_week + ON standard_growth_details(production_standard_id, week); + +-- Create index for project_category +CREATE INDEX idx_production_standards_project_category ON production_standards(project_category); From c9633d1308ddffc6d6a5100122d8b16c87d645ec Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Sat, 27 Dec 2025 09:02:16 +0700 Subject: [PATCH 12/12] feat[BE#US386]: add production standards module with CRUD operations - Created database migration for production standards and related tables. - Implemented entities for ProductionStandard, ProductionStandardDetail, and StandardGrowthDetail. - Developed controller for handling production standard requests. - Added DTOs for data transfer between layers. - Implemented service layer for business logic related to production standards. - Created repository interfaces and implementations for data access. - Added validation for production standard requests. - Registered routes for production standards in the main application. --- ..._create_production_standards_tables.up.sql | 60 +++- internal/entities/production_standard.go | 19 ++ .../entities/production_standard_detail.go | 19 ++ internal/entities/standard_growth_detail.go | 19 ++ .../production-standard.controller.go | 145 +++++++++ .../dto/production-standard.dto.go | 155 +++++++++ .../master/production-standards/module.go | 33 ++ .../production_standard.repository.go | 103 ++++++ .../production_standard_detail.repository.go | 63 ++++ .../standard_growth_detail.repository.go | 63 ++++ .../master/production-standards/route.go | 23 ++ .../services/production-standard.service.go | 302 ++++++++++++++++++ .../production-standard.validation.go | 41 +++ internal/modules/master/route.go | 2 + internal/route/route.go | 1 + 15 files changed, 1039 insertions(+), 9 deletions(-) create mode 100644 internal/entities/production_standard.go create mode 100644 internal/entities/production_standard_detail.go create mode 100644 internal/entities/standard_growth_detail.go create mode 100644 internal/modules/master/production-standards/controllers/production-standard.controller.go create mode 100644 internal/modules/master/production-standards/dto/production-standard.dto.go create mode 100644 internal/modules/master/production-standards/module.go create mode 100644 internal/modules/master/production-standards/repositories/production_standard.repository.go create mode 100644 internal/modules/master/production-standards/repositories/production_standard_detail.repository.go create mode 100644 internal/modules/master/production-standards/repositories/standard_growth_detail.repository.go create mode 100644 internal/modules/master/production-standards/route.go create mode 100644 internal/modules/master/production-standards/services/production-standard.service.go create mode 100644 internal/modules/master/production-standards/validations/production-standard.validation.go diff --git a/internal/database/migrations/20251226161036_create_production_standards_tables.up.sql b/internal/database/migrations/20251226161036_create_production_standards_tables.up.sql index 61aa3071..2af43d20 100644 --- a/internal/database/migrations/20251226161036_create_production_standards_tables.up.sql +++ b/internal/database/migrations/20251226161036_create_production_standards_tables.up.sql @@ -6,12 +6,25 @@ CREATE TABLE IF NOT EXISTS production_standards ( created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), deleted_at TIMESTAMPTZ, - created_by BIGINT NOT NULL + created_by BIGINT ); -- Create index for deleted_at (soft delete) CREATE INDEX idx_production_standards_deleted_at ON production_standards(deleted_at); +-- Tambahkan Foreign Key ke users +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN + ALTER TABLE production_standards + ADD CONSTRAINT fk_production_standards_created_by + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE; + END IF; +END $$; + +-- Index +CREATE INDEX idx_production_standards_created_by ON production_standards(created_by); + -- Create production_standard_details table CREATE TABLE IF NOT EXISTS production_standard_details ( id BIGSERIAL PRIMARY KEY, @@ -22,11 +35,19 @@ CREATE TABLE IF NOT EXISTS production_standard_details ( target_egg_weight NUMERIC(15, 3), target_egg_mass NUMERIC(15, 3), created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), - CONSTRAINT fk_production_standard_details_standard FOREIGN KEY (production_standard_id) - REFERENCES production_standards(id) ON DELETE CASCADE + updated_at TIMESTAMPTZ DEFAULT NOW() ); +-- Tambahkan Foreign Key ke production_standards +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'production_standards') THEN + ALTER TABLE production_standard_details + ADD CONSTRAINT fk_production_standard_details_standard + FOREIGN KEY (production_standard_id) REFERENCES production_standards(id) ON DELETE CASCADE; + END IF; +END $$; + -- Create unique constraint for standard_id + week CREATE UNIQUE INDEX idx_production_standard_details_standard_week ON production_standard_details(production_standard_id, week); @@ -35,20 +56,41 @@ CREATE UNIQUE INDEX idx_production_standard_details_standard_week CREATE TABLE IF NOT EXISTS standard_growth_details ( id BIGSERIAL PRIMARY KEY, production_standard_id BIGINT NOT NULL, - target_mean_bw INT, + target_mean_bw NUMERIC(15, 3), max_depletion NUMERIC(15, 3), min_uniformity NUMERIC(15, 3) NOT NULL, week INT NOT NULL, - feed_intake INT, + feed_intake NUMERIC(15, 3), created_at TIMESTAMPTZ DEFAULT NOW(), - created_by BIGINT NOT NULL, - CONSTRAINT fk_standard_growth_details_standard FOREIGN KEY (production_standard_id) - REFERENCES production_standards(id) ON DELETE CASCADE + created_by BIGINT ); +-- Tambahkan Foreign Key ke production_standards +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'production_standards') THEN + ALTER TABLE standard_growth_details + ADD CONSTRAINT fk_standard_growth_details_standard + FOREIGN KEY (production_standard_id) REFERENCES production_standards(id) ON DELETE CASCADE; + END IF; +END $$; + +-- Tambahkan Foreign Key ke users +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN + ALTER TABLE standard_growth_details + ADD CONSTRAINT fk_standard_growth_details_created_by + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE; + END IF; +END $$; + -- Create unique constraint for standard_id + week CREATE UNIQUE INDEX idx_standard_growth_details_standard_week ON standard_growth_details(production_standard_id, week); +-- Index +CREATE INDEX idx_standard_growth_details_created_by ON standard_growth_details(created_by); + -- Create index for project_category CREATE INDEX idx_production_standards_project_category ON production_standards(project_category); diff --git a/internal/entities/production_standard.go b/internal/entities/production_standard.go new file mode 100644 index 00000000..1214f6cf --- /dev/null +++ b/internal/entities/production_standard.go @@ -0,0 +1,19 @@ +package entities + +import ( + "time" +) + +type ProductionStandard struct { + Id uint `gorm:"primaryKey;autoIncrement"` + Name string `gorm:"type:varchar(100);uniqueIndex;not null"` + ProjectCategory string `gorm:"type:varchar(20);not null"` + CreatedAt time.Time `gorm:"type:timestamptz;not null"` + UpdatedAt time.Time `gorm:"type:timestamptz;not null"` + DeletedAt *time.Time `gorm:"type:timestamptz"` + CreatedBy uint `gorm:"not null"` + + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + ProductionStandardDetails []ProductionStandardDetail `gorm:"foreignKey:ProductionStandardId;references:Id"` + StandardGrowthDetails []StandardGrowthDetail `gorm:"foreignKey:ProductionStandardId;references:Id"` +} diff --git a/internal/entities/production_standard_detail.go b/internal/entities/production_standard_detail.go new file mode 100644 index 00000000..cd50a572 --- /dev/null +++ b/internal/entities/production_standard_detail.go @@ -0,0 +1,19 @@ +package entities + +import ( + "time" +) + +type ProductionStandardDetail struct { + Id uint `gorm:"primaryKey;autoIncrement"` + ProductionStandardId uint `gorm:"not null"` + Week int `gorm:"not null"` + TargetHenDayProduction *float64 `gorm:"type:numeric(15,3)"` + TargetHenHouseProduction *float64 `gorm:"type:numeric(15,3)"` + TargetEggWeight *float64 `gorm:"type:numeric(15,3)"` + TargetEggMass *float64 `gorm:"type:numeric(15,3)"` + CreatedAt time.Time `gorm:"type:timestamptz;not null"` + UpdatedAt time.Time `gorm:"type:timestamptz;not null"` + + ProductionStandard ProductionStandard `gorm:"foreignKey:ProductionStandardId;references:Id"` +} diff --git a/internal/entities/standard_growth_detail.go b/internal/entities/standard_growth_detail.go new file mode 100644 index 00000000..a3d19fb8 --- /dev/null +++ b/internal/entities/standard_growth_detail.go @@ -0,0 +1,19 @@ +package entities + +import ( + "time" +) + +type StandardGrowthDetail struct { + Id uint `gorm:"primaryKey;autoIncrement"` + ProductionStandardId uint `gorm:"not null"` + TargetMeanBw *float64 `gorm:"type:numeric(15,3)"` + MaxDepletion *float64 `gorm:"type:numeric(15,3)"` + MinUniformity float64 `gorm:"type:numeric(15,3);not null"` + Week int `gorm:"not null"` + FeedIntake *float64 `gorm:"type:numeric(15,3)"` + CreatedAt time.Time `gorm:"type:timestamptz;not null"` + CreatedBy uint `gorm:"not null"` + + ProductionStandard ProductionStandard `gorm:"foreignKey:ProductionStandardId;references:Id"` +} diff --git a/internal/modules/master/production-standards/controllers/production-standard.controller.go b/internal/modules/master/production-standards/controllers/production-standard.controller.go new file mode 100644 index 00000000..1635385d --- /dev/null +++ b/internal/modules/master/production-standards/controllers/production-standard.controller.go @@ -0,0 +1,145 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type ProductionStandardController struct { + ProductionStandardService service.ProductionStandardService +} + +func NewProductionStandardController(productionStandardService service.ProductionStandardService) *ProductionStandardController { + return &ProductionStandardController{ + ProductionStandardService: productionStandardService, + } +} + +func (u *ProductionStandardController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + ProjectCategory: c.Query("project_category", ""), + } + + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + result, totalResults, err := u.ProductionStandardService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.ProductionStandardListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all productionStandards successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToProductionStandardListDTOs(result), + }) +} + +func (u *ProductionStandardController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := u.ProductionStandardService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get productionStandard successfully", + Data: dto.ToProductionStandardDetailDTO(*result, result.StandardGrowthDetails, result.ProductionStandardDetails), + }) +} + +func (u *ProductionStandardController) CreateOne(c *fiber.Ctx) error { + req := new(validation.Create) + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.ProductionStandardService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create productionStandard successfully", + Data: dto.ToProductionStandardDetailDTO(*result, result.StandardGrowthDetails, result.ProductionStandardDetails), + }) +} + +func (u *ProductionStandardController) UpdateOne(c *fiber.Ctx) error { + req := new(validation.Update) + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.ProductionStandardService.UpdateOne(c, req, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Update productionStandard successfully", + Data: dto.ToProductionStandardDetailDTO(*result, result.StandardGrowthDetails, result.ProductionStandardDetails), + }) +} + +func (u *ProductionStandardController) DeleteOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := u.ProductionStandardService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete productionStandard successfully", + }) +} diff --git a/internal/modules/master/production-standards/dto/production-standard.dto.go b/internal/modules/master/production-standards/dto/production-standard.dto.go new file mode 100644 index 00000000..9544732a --- /dev/null +++ b/internal/modules/master/production-standards/dto/production-standard.dto.go @@ -0,0 +1,155 @@ +package dto + +import ( + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" +) + +// === DTO Structs === + +type ProductionStandardListDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + ProjectCategory string `json:"project_category"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user"` +} + +type ProductionStandardDetailDTO struct { + ProductionStandardListDTO + Details []WeeklyProductionStandardDTO `json:"details"` +} + +type GrowthStandardDetailDTO struct { + Id uint `json:"id"` + TargetMeanBW *float64 `json:"target_mean_bw"` + MaxDepletion *float64 `json:"max_depletion"` + MinUniformity float64 `json:"min_uniformity"` + FeedIntake *float64 `json:"feed_intake"` +} + +type EggProductionStandardDetailDTO struct { + Id uint `json:"id"` + TargetHenDayProduction *float64 `json:"target_hen_day_production"` + TargetHenHouseProduction *float64 `json:"target_hen_house_production"` + TargetEggWeight *float64 `json:"target_egg_weight"` + TargetEggMass *float64 `json:"target_egg_mass"` +} + +type WeeklyProductionStandardDTO struct { + Week int `json:"week"` + GrowthStandardDetail GrowthStandardDetailDTO `json:"growth_standard_detail"` + EggProductionStandardDetailDTO *EggProductionStandardDetailDTO `json:"egg_production_standard_detail,omitempty"` +} + +// === Mapper Functions === + +func ToProductionStandardListDTO(e entity.ProductionStandard) ProductionStandardListDTO { + var createdUser *userDTO.UserRelationDTO + if e.CreatedUser.Id != 0 { + mapped := userDTO.ToUserRelationDTO(e.CreatedUser) + createdUser = &mapped + } + + return ProductionStandardListDTO{ + Id: e.Id, + Name: e.Name, + ProjectCategory: e.ProjectCategory, + CreatedUser: createdUser, + } +} + +func ToProductionStandardListDTOs(e []entity.ProductionStandard) []ProductionStandardListDTO { + result := make([]ProductionStandardListDTO, len(e)) + for i, r := range e { + result[i] = ToProductionStandardListDTO(r) + } + return result +} + +func ToWeeklyProductionStandardDTO(e entity.StandardGrowthDetail) WeeklyProductionStandardDTO { + return WeeklyProductionStandardDTO{ + Week: e.Week, + GrowthStandardDetail: GrowthStandardDetailDTO{ + Id: e.Id, + TargetMeanBW: e.TargetMeanBw, + MaxDepletion: e.MaxDepletion, + MinUniformity: e.MinUniformity, + FeedIntake: e.FeedIntake, + }, + EggProductionStandardDetailDTO: nil, // GROWING category - no egg production details + } +} + +func ToWeeklyProductionStandardDTOWithDetails(growth entity.StandardGrowthDetail, detail entity.ProductionStandardDetail) WeeklyProductionStandardDTO { + eggDetail := &EggProductionStandardDetailDTO{ + Id: detail.Id, + TargetHenDayProduction: detail.TargetHenDayProduction, + TargetHenHouseProduction: detail.TargetHenHouseProduction, + TargetEggWeight: detail.TargetEggWeight, + TargetEggMass: detail.TargetEggMass, + } + + return WeeklyProductionStandardDTO{ + Week: growth.Week, + GrowthStandardDetail: GrowthStandardDetailDTO{ + Id: growth.Id, + TargetMeanBW: growth.TargetMeanBw, + MaxDepletion: growth.MaxDepletion, + MinUniformity: growth.MinUniformity, + FeedIntake: growth.FeedIntake, + }, + EggProductionStandardDetailDTO: eggDetail, // LAYING category - with egg production details + } +} + +func ToWeeklyProductionStandardDTOs(e []entity.StandardGrowthDetail) []WeeklyProductionStandardDTO { + result := make([]WeeklyProductionStandardDTO, len(e)) + for i, r := range e { + result[i] = ToWeeklyProductionStandardDTO(r) + } + return result +} + +func ToWeeklyProductionStandardDTOsWithDetails( + growthDetails []entity.StandardGrowthDetail, + productionStandardDetails []entity.ProductionStandardDetail, +) []WeeklyProductionStandardDTO { + result := make([]WeeklyProductionStandardDTO, len(growthDetails)) + + // Create map for production standard details by week + prodDetailMap := make(map[int]entity.ProductionStandardDetail) + for _, detail := range productionStandardDetails { + prodDetailMap[detail.Week] = detail + } + + // Map growth details and combine with production standard details + for i, growth := range growthDetails { + if prodDetail, exists := prodDetailMap[growth.Week]; exists { + result[i] = ToWeeklyProductionStandardDTOWithDetails(growth, prodDetail) + } else { + result[i] = ToWeeklyProductionStandardDTO(growth) + } + } + + return result +} + +func ToEggProductionStandardDetailDTO(e entity.ProductionStandardDetail) EggProductionStandardDetailDTO { + return EggProductionStandardDetailDTO{ + TargetHenDayProduction: e.TargetHenDayProduction, + TargetHenHouseProduction: e.TargetHenHouseProduction, + TargetEggWeight: e.TargetEggWeight, + TargetEggMass: e.TargetEggMass, + } +} + +func ToProductionStandardDetailDTO( + standard entity.ProductionStandard, + growthDetails []entity.StandardGrowthDetail, + productionStandardDetails []entity.ProductionStandardDetail, +) ProductionStandardDetailDTO { + return ProductionStandardDetailDTO{ + ProductionStandardListDTO: ToProductionStandardListDTO(standard), + Details: ToWeeklyProductionStandardDTOsWithDetails(growthDetails, productionStandardDetails), + } +} diff --git a/internal/modules/master/production-standards/module.go b/internal/modules/master/production-standards/module.go new file mode 100644 index 00000000..bd08ee88 --- /dev/null +++ b/internal/modules/master/production-standards/module.go @@ -0,0 +1,33 @@ +package productionstandards + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" + sProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type ProductionStandardModule struct{} + +func (ProductionStandardModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + productionStandardRepo := rProductionStandard.NewProductionStandardRepository(db) + productionStandardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db) + standardGrowthDetailRepo := rProductionStandard.NewStandardGrowthDetailRepository(db) + userRepo := rUser.NewUserRepository(db) + + productionStandardService := sProductionStandard.NewProductionStandardService( + productionStandardRepo, + productionStandardDetailRepo, + standardGrowthDetailRepo, + validate, + ) + userService := sUser.NewUserService(userRepo, validate) + + ProductionStandardRoutes(router, userService, productionStandardService) +} + diff --git a/internal/modules/master/production-standards/repositories/production_standard.repository.go b/internal/modules/master/production-standards/repositories/production_standard.repository.go new file mode 100644 index 00000000..26330d63 --- /dev/null +++ b/internal/modules/master/production-standards/repositories/production_standard.repository.go @@ -0,0 +1,103 @@ +package repository + +import ( + "context" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type ProductionStandardRepository interface { + repository.BaseRepository[entity.ProductionStandard] + GetAll(ctx context.Context, offset, limit int, modifier func(*gorm.DB) *gorm.DB) ([]entity.ProductionStandard, int64, error) + GetByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.ProductionStandard, error) + NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) + IdExists(ctx context.Context, id uint) (bool, error) + GetByProjectCategory(ctx context.Context, projectCategory string) ([]entity.ProductionStandard, error) +} + +type ProductionStandardRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.ProductionStandard] + db *gorm.DB +} + +func NewProductionStandardRepository(db *gorm.DB) ProductionStandardRepository { + return &ProductionStandardRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.ProductionStandard](db), + db: db, + } +} + +func (r *ProductionStandardRepositoryImpl) GetAll(ctx context.Context, offset, limit int, modifier func(*gorm.DB) *gorm.DB) ([]entity.ProductionStandard, int64, error) { + var standards []entity.ProductionStandard + var total int64 + + // Build base query + q := r.db.WithContext(ctx).Model(&entity.ProductionStandard{}) + + // Apply modifier for filters + if modifier != nil { + q = modifier(q) + } + + // Count total + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + + // Re-apply modifier and add preloads for Find + q = r.db.WithContext(ctx).Model(&entity.ProductionStandard{}) + if modifier != nil { + q = modifier(q) + } + q = q.Preload("CreatedUser") + + // Find with offset and limit + if err := q.Offset(offset).Limit(limit).Find(&standards).Error; err != nil { + return nil, 0, err + } + + return standards, total, nil +} + +func (r *ProductionStandardRepositoryImpl) GetByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.ProductionStandard, error) { + var standard entity.ProductionStandard + + q := r.db.WithContext(ctx).Model(&entity.ProductionStandard{}) + + // Apply modifier + if modifier != nil { + q = modifier(q) + } + + // Ensure CreatedUser is preloaded + q = q.Preload("CreatedUser") + + if err := q.First(&standard, id).Error; err != nil { + return nil, err + } + + return &standard, nil +} + +func (r *ProductionStandardRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) { + return repository.ExistsByName[entity.ProductionStandard](ctx, r.db, name, excludeID) +} + +func (r *ProductionStandardRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.ProductionStandard](ctx, r.db, id) +} + +func (r *ProductionStandardRepositoryImpl) GetByProjectCategory(ctx context.Context, projectCategory string) ([]entity.ProductionStandard, error) { + var standards []entity.ProductionStandard + err := r.db.WithContext(ctx). + Preload("CreatedUser"). + Where("project_category = ?", projectCategory). + Where("deleted_at IS NULL"). + Find(&standards).Error + if err != nil { + return nil, err + } + return standards, nil +} diff --git a/internal/modules/master/production-standards/repositories/production_standard_detail.repository.go b/internal/modules/master/production-standards/repositories/production_standard_detail.repository.go new file mode 100644 index 00000000..ad680c01 --- /dev/null +++ b/internal/modules/master/production-standards/repositories/production_standard_detail.repository.go @@ -0,0 +1,63 @@ +package repository + +import ( + "context" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type ProductionStandardDetailRepository interface { + repository.BaseRepository[entity.ProductionStandardDetail] + IdExists(ctx context.Context, id uint) (bool, error) + GetByProductionStandardID(ctx context.Context, productionStandardId uint) ([]entity.ProductionStandardDetail, error) + GetByStandardIDAndWeek(ctx context.Context, standardId uint, week int) (*entity.ProductionStandardDetail, error) + DeleteByProductionStandardID(ctx context.Context, productionStandardId uint) error +} + +type ProductionStandardDetailRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.ProductionStandardDetail] + db *gorm.DB +} + +func NewProductionStandardDetailRepository(db *gorm.DB) ProductionStandardDetailRepository { + return &ProductionStandardDetailRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.ProductionStandardDetail](db), + db: db, + } +} + +func (r *ProductionStandardDetailRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.ProductionStandardDetail](ctx, r.db, id) +} + +func (r *ProductionStandardDetailRepositoryImpl) GetByProductionStandardID(ctx context.Context, productionStandardId uint) ([]entity.ProductionStandardDetail, error) { + var details []entity.ProductionStandardDetail + err := r.db.WithContext(ctx). + Where("production_standard_id = ?", productionStandardId). + Order("week ASC"). + Find(&details).Error + if err != nil { + return nil, err + } + return details, nil +} + +func (r *ProductionStandardDetailRepositoryImpl) GetByStandardIDAndWeek(ctx context.Context, standardId uint, week int) (*entity.ProductionStandardDetail, error) { + var detail entity.ProductionStandardDetail + err := r.db.WithContext(ctx). + Where("production_standard_id = ?", standardId). + Where("week = ?", week). + First(&detail).Error + if err != nil { + return nil, err + } + return &detail, nil +} + +func (r *ProductionStandardDetailRepositoryImpl) DeleteByProductionStandardID(ctx context.Context, productionStandardId uint) error { + return r.db.WithContext(ctx). + Where("production_standard_id = ?", productionStandardId). + Delete(&entity.ProductionStandardDetail{}).Error +} diff --git a/internal/modules/master/production-standards/repositories/standard_growth_detail.repository.go b/internal/modules/master/production-standards/repositories/standard_growth_detail.repository.go new file mode 100644 index 00000000..afe93d81 --- /dev/null +++ b/internal/modules/master/production-standards/repositories/standard_growth_detail.repository.go @@ -0,0 +1,63 @@ +package repository + +import ( + "context" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type StandardGrowthDetailRepository interface { + repository.BaseRepository[entity.StandardGrowthDetail] + IdExists(ctx context.Context, id uint) (bool, error) + GetByProductionStandardID(ctx context.Context, productionStandardId uint) ([]entity.StandardGrowthDetail, error) + GetByStandardIDAndWeek(ctx context.Context, standardId uint, week int) (*entity.StandardGrowthDetail, error) + DeleteByProductionStandardID(ctx context.Context, productionStandardId uint) error +} + +type StandardGrowthDetailRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.StandardGrowthDetail] + db *gorm.DB +} + +func NewStandardGrowthDetailRepository(db *gorm.DB) StandardGrowthDetailRepository { + return &StandardGrowthDetailRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.StandardGrowthDetail](db), + db: db, + } +} + +func (r *StandardGrowthDetailRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.StandardGrowthDetail](ctx, r.db, id) +} + +func (r *StandardGrowthDetailRepositoryImpl) GetByProductionStandardID(ctx context.Context, productionStandardId uint) ([]entity.StandardGrowthDetail, error) { + var details []entity.StandardGrowthDetail + err := r.db.WithContext(ctx). + Where("production_standard_id = ?", productionStandardId). + Order("week ASC"). + Find(&details).Error + if err != nil { + return nil, err + } + return details, nil +} + +func (r *StandardGrowthDetailRepositoryImpl) GetByStandardIDAndWeek(ctx context.Context, standardId uint, week int) (*entity.StandardGrowthDetail, error) { + var detail entity.StandardGrowthDetail + err := r.db.WithContext(ctx). + Where("production_standard_id = ?", standardId). + Where("week = ?", week). + First(&detail).Error + if err != nil { + return nil, err + } + return &detail, nil +} + +func (r *StandardGrowthDetailRepositoryImpl) DeleteByProductionStandardID(ctx context.Context, productionStandardId uint) error { + return r.db.WithContext(ctx). + Where("production_standard_id = ?", productionStandardId). + Delete(&entity.StandardGrowthDetail{}).Error +} diff --git a/internal/modules/master/production-standards/route.go b/internal/modules/master/production-standards/route.go new file mode 100644 index 00000000..d2035bea --- /dev/null +++ b/internal/modules/master/production-standards/route.go @@ -0,0 +1,23 @@ +package productionstandards + +import ( + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/controllers" + productionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func ProductionStandardRoutes(v1 fiber.Router, u user.UserService, s productionStandard.ProductionStandardService) { + ctrl := controller.NewProductionStandardController(s) + + route := v1.Group("/production-standards") + route.Use(m.Auth(u)) + + route.Get("/", ctrl.GetAll) + route.Post("/", ctrl.CreateOne) + route.Get("/:id", ctrl.GetOne) + route.Patch("/:id", ctrl.UpdateOne) + route.Delete("/:id", ctrl.DeleteOne) +} diff --git a/internal/modules/master/production-standards/services/production-standard.service.go b/internal/modules/master/production-standards/services/production-standard.service.go new file mode 100644 index 00000000..b81faf8b --- /dev/null +++ b/internal/modules/master/production-standards/services/production-standard.service.go @@ -0,0 +1,302 @@ +package service + +import ( + "errors" + "fmt" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type ProductionStandardService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProductionStandard, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.ProductionStandard, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.ProductionStandard, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.ProductionStandard, error) + DeleteOne(ctx *fiber.Ctx, id uint) error +} + +type productionStandardService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.ProductionStandardRepository + ProductionStandardDetailRepo repository.ProductionStandardDetailRepository + StandardGrowthDetailRepo repository.StandardGrowthDetailRepository +} + +func NewProductionStandardService( + repo repository.ProductionStandardRepository, + productionStandardDetailRepo repository.ProductionStandardDetailRepository, + standardGrowthDetailRepo repository.StandardGrowthDetailRepository, + validate *validator.Validate, +) ProductionStandardService { + return &productionStandardService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + ProductionStandardDetailRepo: productionStandardDetailRepo, + StandardGrowthDetailRepo: standardGrowthDetailRepo, + } +} + +func (s productionStandardService) withRelations(db *gorm.DB) *gorm.DB { + return db. + Preload("CreatedUser"). + Preload("ProductionStandardDetails"). + Preload("StandardGrowthDetails") +} + +func (s productionStandardService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProductionStandard, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + productionStandards, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + if params.Search != "" { + return db.Where("name LIKE ?", "%"+params.Search+"%") + } + if params.ProjectCategory != "" { + return db.Where("project_category = ?", params.ProjectCategory) + } + return db.Order("created_at DESC").Order("updated_at DESC") + }) + + if err != nil { + s.Log.Errorf("Failed to get productionStandards: %+v", err) + return nil, 0, err + } + return productionStandards, total, nil +} + +func (s productionStandardService) GetOne(c *fiber.Ctx, id uint) (*entity.ProductionStandard, error) { + productionStandard, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "ProductionStandard not found") + } + if err != nil { + s.Log.Errorf("Failed get productionStandard by id: %+v", err) + return nil, err + } + return productionStandard, nil +} + +func (s *productionStandardService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.ProductionStandard, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + nameExists, err := s.Repository.NameExists(c.Context(), req.Name, nil) + if err != nil { + return nil, err + } + if nameExists { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Production standard with name '%s' already exists", req.Name)) + } + + var createdStandard *entity.ProductionStandard + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + standardRepoTx := repository.NewProductionStandardRepository(tx) + productionStandardDetailRepoTx := repository.NewProductionStandardDetailRepository(tx) + standardGrowthDetailRepoTx := repository.NewStandardGrowthDetailRepository(tx) + + newStandard := &entity.ProductionStandard{ + Name: req.Name, + ProjectCategory: req.ProjectCategory, + CreatedBy: actorID, + } + + if err := standardRepoTx.CreateOne(c.Context(), newStandard, nil); err != nil { + return fmt.Errorf("failed to create production standard: %w", err) + } + + for _, detailReq := range req.Details { + if detailReq.ProductionStandardUniformityDetails == nil { + return fmt.Errorf("production_standard_uniformity_details is required in week %d", detailReq.Week) + } + + if req.ProjectCategory == string(utils.ProjectFlockCategoryLaying) { + if detailReq.ProductionStandardDetails == nil { + return fmt.Errorf("production_standard_details is required for LAYING category in week %d", detailReq.Week) + } + + productionStandardDetail := &entity.ProductionStandardDetail{ + ProductionStandardId: newStandard.Id, + Week: detailReq.Week, + TargetHenDayProduction: detailReq.ProductionStandardDetails.TargetHenDayProduction, + TargetHenHouseProduction: detailReq.ProductionStandardDetails.TargetHenHouseProduction, + TargetEggWeight: detailReq.ProductionStandardDetails.TargetEggWeight, + TargetEggMass: detailReq.ProductionStandardDetails.TargetEggMass, + } + + if err := productionStandardDetailRepoTx.CreateOne(c.Context(), productionStandardDetail, nil); err != nil { + return fmt.Errorf("failed to create production standard detail for week %d: %w", detailReq.Week, err) + } + } + + standardGrowthDetail := &entity.StandardGrowthDetail{ + ProductionStandardId: newStandard.Id, + Week: detailReq.Week, + TargetMeanBw: detailReq.ProductionStandardUniformityDetails.TargetMeanBw, + MaxDepletion: detailReq.ProductionStandardUniformityDetails.MaxDepletion, + MinUniformity: detailReq.ProductionStandardUniformityDetails.MinUniformity, + FeedIntake: detailReq.ProductionStandardUniformityDetails.FeedIntake, + CreatedBy: actorID, + } + + if err := standardGrowthDetailRepoTx.CreateOne(c.Context(), standardGrowthDetail, nil); err != nil { + return fmt.Errorf("failed to create standard growth detail for week %d: %w", detailReq.Week, err) + } + } + + createdStandard = newStandard + return nil + }) + + if err != nil { + s.Log.Errorf("Failed to create production standard: %+v", err) + return nil, err + } + + return s.GetOne(c, createdStandard.Id) +} + +func (s productionStandardService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.ProductionStandard, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + var updatedStandard *entity.ProductionStandard + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + standardRepoTx := repository.NewProductionStandardRepository(tx) + productionStandardDetailRepoTx := repository.NewProductionStandardDetailRepository(tx) + standardGrowthDetailRepoTx := repository.NewStandardGrowthDetailRepository(tx) + + existingStandard, err := standardRepoTx.GetByID(c.Context(), id, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "ProductionStandard not found") + } + return fmt.Errorf("failed to get production standard: %w", err) + } + + updateBody := make(map[string]any) + if req.Name != nil { + + nameExists, err := s.Repository.NameExists(c.Context(), *req.Name, &id) + if err != nil { + s.Log.Errorf("Failed to check existing production standard: %+v", err) + return err + } + if nameExists { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Production standard with name '%s' already exists", *req.Name)) + } + updateBody["name"] = *req.Name + } + if req.ProjectCategory != nil { + updateBody["project_category"] = *req.ProjectCategory + } + + if len(updateBody) > 0 { + if err := standardRepoTx.PatchOne(c.Context(), id, updateBody, nil); err != nil { + return fmt.Errorf("failed to update production standard: %w", err) + } + } + + if req.Details != nil && len(req.Details) > 0 { + + projectCategory := existingStandard.ProjectCategory + if req.ProjectCategory != nil { + projectCategory = *req.ProjectCategory + } + + if err := productionStandardDetailRepoTx.DeleteByProductionStandardID(c.Context(), id); err != nil { + return fmt.Errorf("failed to delete old production standard details: %w", err) + } + if err := standardGrowthDetailRepoTx.DeleteByProductionStandardID(c.Context(), id); err != nil { + return fmt.Errorf("failed to delete old standard growth details: %w", err) + } + + for _, detailReq := range req.Details { + if detailReq.ProductionStandardUniformityDetails == nil { + return fmt.Errorf("production_standard_uniformity_details is required in week %d", detailReq.Week) + } + + if projectCategory == "LAYING" { + if detailReq.ProductionStandardDetails == nil { + return fmt.Errorf("production_standard_details is required for LAYING category in week %d", detailReq.Week) + } + + productionStandardDetail := &entity.ProductionStandardDetail{ + ProductionStandardId: id, + Week: detailReq.Week, + TargetHenDayProduction: detailReq.ProductionStandardDetails.TargetHenDayProduction, + TargetHenHouseProduction: detailReq.ProductionStandardDetails.TargetHenHouseProduction, + TargetEggWeight: detailReq.ProductionStandardDetails.TargetEggWeight, + TargetEggMass: detailReq.ProductionStandardDetails.TargetEggMass, + } + + if err := productionStandardDetailRepoTx.CreateOne(c.Context(), productionStandardDetail, nil); err != nil { + return fmt.Errorf("failed to create production standard detail for week %d: %w", detailReq.Week, err) + } + } + + standardGrowthDetail := &entity.StandardGrowthDetail{ + ProductionStandardId: id, + Week: detailReq.Week, + TargetMeanBw: detailReq.ProductionStandardUniformityDetails.TargetMeanBw, + MaxDepletion: detailReq.ProductionStandardUniformityDetails.MaxDepletion, + MinUniformity: detailReq.ProductionStandardUniformityDetails.MinUniformity, + FeedIntake: detailReq.ProductionStandardUniformityDetails.FeedIntake, + CreatedBy: actorID, + } + + if err := standardGrowthDetailRepoTx.CreateOne(c.Context(), standardGrowthDetail, nil); err != nil { + return fmt.Errorf("failed to create standard growth detail for week %d: %w", detailReq.Week, err) + } + } + } + + updatedStandard = existingStandard + return nil + }) + + if err != nil { + s.Log.Errorf("Failed to update production standard: %+v", err) + return nil, err + } + + return s.GetOne(c, updatedStandard.Id) +} + +func (s productionStandardService) DeleteOne(c *fiber.Ctx, id uint) error { + if err := s.Repository.DeleteOne(c.Context(), id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "ProductionStandard not found") + } + s.Log.Errorf("Failed to delete productionStandard: %+v", err) + return err + } + return nil +} diff --git a/internal/modules/master/production-standards/validations/production-standard.validation.go b/internal/modules/master/production-standards/validations/production-standard.validation.go new file mode 100644 index 00000000..51aeecc7 --- /dev/null +++ b/internal/modules/master/production-standards/validations/production-standard.validation.go @@ -0,0 +1,41 @@ +package validation + +type ProductionStandardDetailItem struct { + TargetHenDayProduction *float64 `json:"target_hen_day_production" validate:"omitempty,gte=0"` + TargetHenHouseProduction *float64 `json:"target_hen_house_production" validate:"omitempty,gte=0"` + TargetEggWeight *float64 `json:"target_egg_weight" validate:"omitempty,gte=0"` + TargetEggMass *float64 `json:"target_egg_mass" validate:"omitempty,gte=0"` +} + +type StandardGrowthDetailItem struct { + TargetMeanBw *float64 `json:"target_mean_bw" validate:"omitempty,gte=0"` + MaxDepletion *float64 `json:"max_depletion" validate:"omitempty,gte=0,lte=100"` + MinUniformity float64 `json:"min_uniformity" validate:"required,gte=0,lte=100"` + FeedIntake *float64 `json:"feed_intake" validate:"omitempty,gte=0"` +} + +type DetailItem struct { + Week int `json:"week" validate:"required,gte=1"` + ProductionStandardDetails *ProductionStandardDetailItem `json:"production_standard_details,omitempty"` + ProductionStandardUniformityDetails *StandardGrowthDetailItem `json:"production_standard_uniformity_details" validate:"required"` +} + + +type Create struct { + Name string `json:"name" validate:"required,min=3"` + ProjectCategory string `json:"project_category" validate:"required,oneof=GROWING LAYING"` + Details []DetailItem `json:"details" validate:"required,min=1,dive"` +} + +type Update struct { + Name *string `json:"name,omitempty" validate:"omitempty,min=3"` + ProjectCategory *string `json:"project_category,omitempty" validate:"omitempty,oneof=GROWING LAYING"` + Details []DetailItem `json:"details,omitempty"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` + ProjectCategory string `query:"project_category" validate:"omitempty,oneof=GROWING LAYING"` +} diff --git a/internal/modules/master/route.go b/internal/modules/master/route.go index 44702e1a..26ae28ee 100644 --- a/internal/modules/master/route.go +++ b/internal/modules/master/route.go @@ -20,6 +20,7 @@ import ( suppliers "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers" uoms "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms" warehouses "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses" + productionStandards "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards" // MODULE IMPORTS ) @@ -40,6 +41,7 @@ func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Valida products.ProductModule{}, banks.BankModule{}, flocks.FlockModule{}, + productionStandards.ProductionStandardModule{}, // MODULE REGISTRY } diff --git a/internal/route/route.go b/internal/route/route.go index e98b044b..4eb224ac 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -44,6 +44,7 @@ func Routes(app *fiber.App, db *gorm.DB) { ssoModule.Module{}, closings.ClosingModule{}, repports.RepportModule{}, + // MODULE REGISTRY }