package service import ( "context" "errors" "fmt" "math" "sort" "strings" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "github.com/sirupsen/logrus" common "gitlab.com/mbugroup/lti-api.git/internal/common/service" fifoV2 "gitlab.com/mbugroup/lti-api.git/internal/common/service/fifo_stock_v2" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" m "gitlab.com/mbugroup/lti-api.git/internal/middleware" adjustmentStockRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/validations" ProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" productRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" warehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" stockLogsRepo "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" "gorm.io/gorm" ) type AdjustmentService interface { Adjustment(ctx *fiber.Ctx, req *validation.Create) (*entity.AdjustmentStock, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.AdjustmentStock, error) DeleteOne(ctx *fiber.Ctx, id uint) error AdjustmentHistory(ctx *fiber.Ctx, query *validation.Query) ([]*entity.AdjustmentStock, int64, error) } type adjustmentService struct { Log *logrus.Logger Validate *validator.Validate StockLogsRepository stockLogsRepo.StockLogRepository WarehouseRepo warehouseRepo.WarehouseRepository ProductWarehouseRepo ProductWarehouse.ProductWarehouseRepository ProductRepo productRepo.ProductRepository ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository ProjectFlockPopulationRepo projectFlockKandangRepo.ProjectFlockPopulationRepository AdjustmentStockRepository adjustmentStockRepo.AdjustmentStockRepository FifoStockV2Svc common.FifoStockV2Service } const ( adjustmentLaneStockable = "STOCKABLE" adjustmentLaneUsable = "USABLE" ) func NewAdjustmentService( productRepo productRepo.ProductRepository, stockLogsRepo stockLogsRepo.StockLogRepository, warehouseRepo warehouseRepo.WarehouseRepository, productWarehouseRepo ProductWarehouse.ProductWarehouseRepository, adjustmentStockRepo adjustmentStockRepo.AdjustmentStockRepository, fifoStockV2Svc common.FifoStockV2Service, validate *validator.Validate, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, projectFlockPopulationRepo projectFlockKandangRepo.ProjectFlockPopulationRepository, ) AdjustmentService { return &adjustmentService{ Log: utils.Log, Validate: validate, StockLogsRepository: stockLogsRepo, WarehouseRepo: warehouseRepo, ProductWarehouseRepo: productWarehouseRepo, ProductRepo: productRepo, ProjectFlockKandangRepo: projectFlockKandangRepo, ProjectFlockPopulationRepo: projectFlockPopulationRepo, AdjustmentStockRepository: adjustmentStockRepo, FifoStockV2Svc: fifoStockV2Svc, } } func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.AdjustmentStock, error) { if err := m.EnsureStockLogAccess(c, s.StockLogsRepository.DB(), id); err != nil { return nil, err } adjustmentStock, err := s.AdjustmentStockRepository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { return db. Preload("ProductWarehouse"). Preload("ProductWarehouse.Product"). Preload("ProductWarehouse.Warehouse"). Preload("ProductWarehouse.Warehouse.Location"). Preload("ProductWarehouse.ProjectFlockKandang"). Preload("ProductWarehouse.ProjectFlockKandang.ProjectFlock"). Preload("StockLog.CreatedUser") }) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found") } s.Log.Errorf("Failed to get adjustment by id: %+v", err) return nil, err } return adjustmentStock, nil } func (s *adjustmentService) DeleteOne(c *fiber.Ctx, id uint) error { if id == 0 { return fiber.NewError(fiber.StatusBadRequest, "Invalid adjustment id") } if s.FifoStockV2Svc == nil { return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available") } if err := m.EnsureStockLogAccess(c, s.StockLogsRepository.DB(), id); err != nil { return err } ctx := c.Context() actorID, err := m.ActorIDFromContext(c) if err != nil { return err } return s.StockLogsRepository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { adjustments, err := s.collectAdjustmentsForDelete(ctx, tx, id) if err != nil { return err } for _, item := range adjustments { if err := s.deleteSingleAdjustmentInTx(ctx, tx, item, actorID); err != nil { return err } } return nil }) } func (s *adjustmentService) collectAdjustmentsForDelete(ctx context.Context, tx *gorm.DB, id uint) ([]entity.AdjustmentStock, error) { repoTx := s.AdjustmentStockRepository.WithTx(tx) adjustment, err := repoTx.GetByIDForUpdate(ctx, id) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found") } return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load adjustment") } adjustments := []entity.AdjustmentStock{*adjustment} leftPairCode := utils.NormalizeUpper(adjustment.FunctionCode) isDepletionCode := leftPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionIn) || leftPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionOut) if !isDepletionCode { return adjustments, nil } if adjustment.PairedAdjustmentId == nil || *adjustment.PairedAdjustmentId == 0 { return nil, fiber.NewError( fiber.StatusBadRequest, "Adjustment depletion tidak memiliki pasangan valid. Data harus diperbaiki terlebih dahulu untuk mencegah orphan.", ) } pair, err := repoTx.GetByIDForUpdate(ctx, *adjustment.PairedAdjustmentId) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError( fiber.StatusBadRequest, fmt.Sprintf("Pasangan adjustment depletion (%d) tidak ditemukan. Data harus diperbaiki terlebih dahulu untuk mencegah orphan.", *adjustment.PairedAdjustmentId), ) } return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load paired adjustment") } rightPairCode := utils.NormalizeUpper(pair.FunctionCode) isPairDepletionCode := rightPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionIn) || rightPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionOut) if !isPairDepletionCode { return nil, fiber.NewError( fiber.StatusBadRequest, fmt.Sprintf("Pasangan adjustment %d bukan depletion pair yang valid", pair.Id), ) } if pair.PairedAdjustmentId == nil || *pair.PairedAdjustmentId != adjustment.Id { return nil, fiber.NewError( fiber.StatusBadRequest, fmt.Sprintf("Pasangan adjustment depletion tidak konsisten (%d <-> %d). Perbaiki pairing terlebih dahulu.", adjustment.Id, pair.Id), ) } isValidPair := (leftPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionIn) && rightPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionOut)) || (leftPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionOut) && rightPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionIn)) if !isValidPair { return nil, fiber.NewError( fiber.StatusBadRequest, fmt.Sprintf("Pasangan function_code depletion tidak valid (%s <-> %s)", adjustment.FunctionCode, pair.FunctionCode), ) } adjustments = append(adjustments, *pair) sort.Slice(adjustments, func(i, j int) bool { return adjustments[i].Id < adjustments[j].Id }) return adjustments, nil } func (s *adjustmentService) deleteSingleAdjustmentInTx( ctx context.Context, tx *gorm.DB, adjustment entity.AdjustmentStock, actorID uint, ) error { repoTx := s.AdjustmentStockRepository.WithTx(tx) productID, err := repoTx.FindProductIDByProductWarehouseID(ctx, adjustment.ProductWarehouseId) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to load product warehouse context") } routeMeta, err := s.resolveRouteByFunctionCode(ctx, productID, adjustment.FunctionCode) if err != nil { return err } isAyamProduct, err := repoTx.IsAyamProduct(ctx, productID) if err != nil { s.Log.Errorf("Failed to resolve AYAM flag for product %d: %+v", productID, err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product flag") } stockLogRepoTx := stockLogsRepo.NewStockLogRepository(tx) notes := fmt.Sprintf("ADJUSTMENT DELETE#%s", utils.NormalizeTrim(adjustment.AdjNumber)) switch routeMeta.Lane { case adjustmentLaneStockable: deps, allowPending, err := s.resolveAdjustmentDependenciesAndPolicy( ctx, tx, fifo.StockableKeyAdjustmentIn.String(), []uint{adjustment.Id}, ) if err != nil { return err } if len(deps) > 0 && isAyamProduct { return fiber.NewError( fiber.StatusBadRequest, fmt.Sprintf( "Adjustment tidak dapat dihapus karena produk AYAM sudah dipakai transaksi turunan. Dependensi aktif: %s. Alasan block: produk AYAM yang sudah terpakai tidak dapat dihapus.", formatAdjustmentDependencySummary(deps), ), ) } if len(deps) > 0 && !allowPending { return fiber.NewError( fiber.StatusBadRequest, fmt.Sprintf( "Adjustment tidak dapat dihapus karena stok adjustment sudah dipakai transaksi turunan. Dependensi aktif: %s. Alasan block: pending disabled by config.", formatAdjustmentDependencySummary(deps), ), ) } oldQty := adjustment.TotalQty if oldQty > 0 { if err := repoTx.UpdateTotalQty(ctx, adjustment.Id, 0); err != nil { return err } asOf := adjustment.CreatedAt if _, err := s.FifoStockV2Svc.Reflow(ctx, common.FifoStockV2ReflowRequest{ FlagGroupCode: routeMeta.FlagGroupCode, ProductWarehouseID: adjustment.ProductWarehouseId, AsOf: &asOf, Tx: tx, }); err != nil { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to reflow stock via FIFO v2: %v", err)) } if err := s.createAdjustmentStockLog( ctx, stockLogRepoTx, adjustment.Id, adjustment.ProductWarehouseId, notes, actorID, 0, oldQty, ); err != nil { return err } } case adjustmentLaneUsable: activeBeforeRollback, err := repoTx.CountActiveConsumeAllocationsByUsable(ctx, fifo.UsableKeyAdjustmentOut.String(), adjustment.Id) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate adjustment allocations before rollback") } rollbackRes, err := s.FifoStockV2Svc.Rollback(ctx, common.FifoStockV2RollbackRequest{ ProductWarehouseID: adjustment.ProductWarehouseId, Usable: common.FifoStockV2Ref{ ID: adjustment.Id, LegacyTypeKey: fifo.UsableKeyAdjustmentOut.String(), }, Reason: notes, Tx: tx, }) if err != nil { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to rollback FIFO v2 adjustment: %v", err)) } activeAfterRollback, err := repoTx.CountActiveConsumeAllocationsByUsable(ctx, fifo.UsableKeyAdjustmentOut.String(), adjustment.Id) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate adjustment allocations after rollback") } if activeAfterRollback > 0 { return fiber.NewError( fiber.StatusBadRequest, fmt.Sprintf( "Adjustment tidak dapat dihapus karena masih ada alokasi aktif ADJUSTMENT_OUT=%d (sebelum rollback=%d, sesudah rollback=%d).", adjustment.Id, activeBeforeRollback, activeAfterRollback, ), ) } releasedQty := 0.0 if rollbackRes != nil { releasedQty = rollbackRes.ReleasedQty } if releasedQty > 0 { if err := s.createAdjustmentStockLog( ctx, stockLogRepoTx, adjustment.Id, adjustment.ProductWarehouseId, notes, actorID, releasedQty, 0, ); err != nil { return err } } default: return fiber.NewError(fiber.StatusBadRequest, "Unsupported adjustment lane") } if err := repoTx.DeleteStockLogsByAdjustmentID(ctx, adjustment.Id); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete adjustment stock logs") } if err := repoTx.DeleteAdjustmentByID(ctx, adjustment.Id); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete adjustment") } return nil } func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*entity.AdjustmentStock, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } productID := req.ProductID if productID == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Product is required") } qty := req.Qty if qty <= 0 { qty = req.Quantity } if qty <= 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Quantity must be greater than zero") } functionCode := utils.NormalizeUpper(req.TransactionSubtype) if functionCode == "" { functionCode = utils.NormalizeUpper(req.TransactionSubType) } if functionCode == "" { functionCode = utils.NormalizeUpper(req.FunctionCode) } if functionCode == "" { return nil, fiber.NewError(fiber.StatusBadRequest, "Transaction subtype is required") } if functionCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionOut) { return nil, fiber.NewError( fiber.StatusBadRequest, "RECORDING_DEPLETION_OUT tidak boleh diinput manual. Gunakan RECORDING_DEPLETION_IN, sistem akan otomatis membuat depletion-out AYAM", ) } warehouseID, err := s.resolveWarehouseID(c.Context(), req) if err != nil { return nil, err } note := utils.NormalizeTrim(req.Notes) if note == "" { note = utils.NormalizeTrim(req.Note) } grandTotal := math.Round((qty*req.Price)*1000) / 1000 ctx := c.Context() actorID, err := m.ActorIDFromContext(c) if err != nil { return nil, err } if err := m.EnsureWarehouseAccess(c, s.WarehouseRepo.DB(), warehouseID); err != nil { return nil, err } if err := common.EnsureRelations(ctx, common.RelationCheck{Name: "Product", ID: &productID, Exists: s.ProductRepo.IdExists}, common.RelationCheck{Name: "Warehouse", ID: &warehouseID, Exists: s.WarehouseRepo.IdExists}, ); err != nil { return nil, err } routeMeta, err := s.resolveRouteByFunctionCode(ctx, productID, functionCode) if err != nil { return nil, err } transactionType := utils.ResolveAdjustmentTransactionType(routeMeta.FunctionCode) var createdAdjustmentStockId uint var projectFlockKandangID *uint pfkID, err := s.getActiveProjectFlockKandangID(ctx, warehouseID) if err == nil && pfkID > 0 { projectFlockKandangID = &pfkID } pw, err := s.ProductWarehouseRepo.FindByProductWarehouseAndPfk(ctx, productID, warehouseID, projectFlockKandangID) if err != nil { if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse") } newPW := &entity.ProductWarehouse{ ProductId: productID, WarehouseId: warehouseID, Quantity: 0, ProjectFlockKandangId: projectFlockKandangID, } if err := s.ProductWarehouseRepo.CreateOne(ctx, newPW, nil); err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create product warehouse") } pw = newPW } if err := common.EnsureProjectFlockNotClosedForProductWarehouses( ctx, s.StockLogsRepository.DB(), []uint{pw.Id}, ); err != nil { return nil, err } err = s.StockLogsRepository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { productWarehouseRepoTX := ProductWarehouse.NewProductWarehouseRepository(tx) stockLogRepoTX := stockLogsRepo.NewStockLogRepository(tx) adjustmentStockRepoTX := s.AdjustmentStockRepository.WithTx(tx) productWarehouse, err := productWarehouseRepoTX.FindByProductWarehouseAndPfk(ctx, productID, warehouseID, projectFlockKandangID) if err != nil { s.Log.Errorf("Failed to get product warehouse: %+v", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse") } if functionCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionIn) { if routeMeta.Lane != adjustmentLaneStockable { return fiber.NewError(fiber.StatusBadRequest, "Transaction subtype depletion in harus lane STOCKABLE") } if projectFlockKandangID == nil || *projectFlockKandangID == 0 { return fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id aktif wajib tersedia untuk depletion conversion") } if s.FifoStockV2Svc == nil { return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available") } sourcePW, err := adjustmentStockRepoTX.FindAyamSourceProductWarehouse(ctx, warehouseID, *projectFlockKandangID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusBadRequest, "Produk sumber AYAM pada project flock kandang yang sama tidak ditemukan") } return err } if err := common.EnsureProjectFlockNotClosedForProductWarehouses( ctx, tx, []uint{productWarehouse.Id, sourcePW.Id}, ); err != nil { return err } sourceRoute, err := s.resolveRouteByFunctionCode( ctx, sourcePW.ProductId, string(utils.AdjustmentTransactionSubtypeRecordingDepletionOut), ) if err != nil { return err } if sourceRoute.Lane != adjustmentLaneUsable { return fiber.NewError(fiber.StatusBadRequest, "Route depletion out untuk produk AYAM tidak valid") } sourceCode, err := adjustmentStockRepoTX.GenerateSequentialNumber(ctx, utils.AdjustmentStockNumberPrefix) if err != nil { return err } sourceAdjustment := &entity.AdjustmentStock{ ProductWarehouseId: sourcePW.Id, TransactionType: transactionType, FunctionCode: sourceRoute.FunctionCode, UsageQty: qty, Price: req.Price, GrandTotal: grandTotal, AdjNumber: sourceCode, } if err := adjustmentStockRepoTX.CreateOne(ctx, sourceAdjustment, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create depletion source adjustment stock record") } destCode, err := adjustmentStockRepoTX.GenerateSequentialNumber(ctx, utils.AdjustmentStockNumberPrefix) if err != nil { return err } destinationAdjustment := &entity.AdjustmentStock{ ProductWarehouseId: productWarehouse.Id, TransactionType: transactionType, FunctionCode: routeMeta.FunctionCode, TotalQty: qty, Price: req.Price, GrandTotal: grandTotal, AdjNumber: destCode, } if err := adjustmentStockRepoTX.CreateOne(ctx, destinationAdjustment, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create depletion destination adjustment stock record") } if err := adjustmentStockRepoTX.UpdatePairedAdjustmentID(ctx, sourceAdjustment.Id, destinationAdjustment.Id); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to link depletion source adjustment pair") } if err := adjustmentStockRepoTX.UpdatePairedAdjustmentID(ctx, destinationAdjustment.Id, sourceAdjustment.Id); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to link depletion destination adjustment pair") } sourceAdjustment.PairedAdjustmentId = &destinationAdjustment.Id destinationAdjustment.PairedAdjustmentId = &sourceAdjustment.Id sourceAsOf := sourceAdjustment.CreatedAt if _, err := s.FifoStockV2Svc.Reflow(ctx, common.FifoStockV2ReflowRequest{ FlagGroupCode: sourceRoute.FlagGroupCode, ProductWarehouseID: sourcePW.Id, AsOf: &sourceAsOf, Tx: tx, }); err != nil { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to auto depletion-out AYAM via FIFO v2: %v", err)) } destinationAsOf := destinationAdjustment.CreatedAt if _, err := s.FifoStockV2Svc.Reflow(ctx, common.FifoStockV2ReflowRequest{ FlagGroupCode: routeMeta.FlagGroupCode, ProductWarehouseID: destinationAdjustment.ProductWarehouseId, AsOf: &destinationAsOf, Tx: tx, }); err != nil { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to auto depletion-in destination via FIFO v2: %v", err)) } refreshedSource, err := adjustmentStockRepoTX.GetByID(ctx, sourceAdjustment.Id, nil) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to refresh depletion source adjustment stock") } refreshedDestination, err := adjustmentStockRepoTX.GetByID(ctx, destinationAdjustment.Id, nil) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to refresh depletion destination adjustment stock") } consumedPopulationQty := refreshedSource.UsageQty + refreshedSource.PendingQty if consumedPopulationQty > 0 { if err := s.allocatePopulationForDepletionAdjustment( ctx, tx, *projectFlockKandangID, sourcePW.Id, refreshedSource.Id, consumedPopulationQty, ); err != nil { return err } if err := adjustmentStockRepoTX.ResyncProjectFlockPopulationUsage(ctx, *projectFlockKandangID); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to resync project flock population usage") } } if err := s.createAdjustmentStockLog( ctx, stockLogRepoTX, refreshedSource.Id, refreshedSource.ProductWarehouseId, note, actorID, 0, refreshedSource.UsageQty+refreshedSource.PendingQty, ); err != nil { return err } if err := s.createAdjustmentStockLog( ctx, stockLogRepoTX, refreshedDestination.Id, refreshedDestination.ProductWarehouseId, note, actorID, refreshedDestination.TotalQty, 0, ); err != nil { return err } createdAdjustmentStockId = destinationAdjustment.Id return nil } adjustmentStock := &entity.AdjustmentStock{ ProductWarehouseId: productWarehouse.Id, TransactionType: transactionType, FunctionCode: routeMeta.FunctionCode, Price: req.Price, GrandTotal: grandTotal, } switch routeMeta.Lane { case adjustmentLaneStockable: adjustmentStock.TotalQty = qty case adjustmentLaneUsable: adjustmentStock.UsageQty = qty } code, err := adjustmentStockRepoTX.GenerateSequentialNumber(ctx, utils.AdjustmentStockNumberPrefix) if err != nil { return err } adjustmentStock.AdjNumber = code if err := adjustmentStockRepoTX.CreateOne(ctx, adjustmentStock, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create adjustment stock record") } var increaseQty float64 var decreaseQty float64 if routeMeta.Lane != adjustmentLaneStockable && routeMeta.Lane != adjustmentLaneUsable { return fiber.NewError(fiber.StatusBadRequest, "Unsupported transaction subtype lane") } if s.FifoStockV2Svc == nil { return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available") } asOf := adjustmentStock.CreatedAt if _, err := s.FifoStockV2Svc.Reflow(ctx, common.FifoStockV2ReflowRequest{ FlagGroupCode: routeMeta.FlagGroupCode, ProductWarehouseID: productWarehouse.Id, AsOf: &asOf, Tx: tx, }); err != nil { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to reflow stock via FIFO v2: %v", err)) } refreshedAdjustment, err := adjustmentStockRepoTX.GetByID(ctx, adjustmentStock.Id, nil) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to refresh adjustment stock") } switch routeMeta.Lane { case adjustmentLaneStockable: increaseQty = refreshedAdjustment.TotalQty case adjustmentLaneUsable: decreaseQty = refreshedAdjustment.UsageQty } if err := s.createAdjustmentStockLog( ctx, stockLogRepoTX, adjustmentStock.Id, productWarehouse.Id, note, actorID, increaseQty, decreaseQty, ); err != nil { return err } createdAdjustmentStockId = adjustmentStock.Id return nil }) if err != nil { s.Log.Errorf("Transaction failed in CreateOne: %+v", err) var fiberErr *fiber.Error if errors.As(err, &fiberErr) { return nil, err } return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to process adjustment transaction") } return s.GetOne(c, createdAdjustmentStockId) } func (s *adjustmentService) resolveWarehouseID(ctx context.Context, req *validation.Create) (uint, error) { if req == nil { return 0, fiber.NewError(fiber.StatusBadRequest, "Invalid request") } if req.WarehouseID > 0 { return req.WarehouseID, nil } if req.ProjectFlockKandangID != nil && *req.ProjectFlockKandangID > 0 { kandangID, err := s.AdjustmentStockRepository.FindKandangIDByProjectFlockKandangID(ctx, *req.ProjectFlockKandangID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return 0, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id tidak valid atau tidak aktif") } return 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate project_flock_kandang_id context") } warehouse, err := s.WarehouseRepo.GetLatestByKandangID(ctx, kandangID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return 0, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Warehouse untuk project_flock_kandang_id %d tidak ditemukan", *req.ProjectFlockKandangID)) } return 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve warehouse by project_flock_kandang_id") } return warehouse.Id, nil } return 0, fiber.NewError(fiber.StatusBadRequest, "warehouse_id atau project_flock_kandang_id wajib diisi") } func (s *adjustmentService) resolveRouteByFunctionCode( ctx context.Context, productID uint, functionCode string, ) (*adjustmentStockRepo.AdjustmentRouteResolution, error) { rows, err := s.AdjustmentStockRepository.FindRoutesByFunctionCode(ctx, productID, functionCode) if err != nil { return nil, err } if len(rows) == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Transaction subtype tidak kompatibel dengan konfigurasi FIFO v2 produk") } selected := rows[0] for _, row := range rows { if row.Lane != selected.Lane { return nil, fiber.NewError(fiber.StatusBadRequest, "Transaction subtype ambigu: lane FIFO v2 lebih dari satu") } } selected.FunctionCode = functionCode switch selected.Lane { case adjustmentLaneStockable, adjustmentLaneUsable: return &selected, nil default: return nil, fiber.NewError(fiber.StatusBadRequest, "Transaction subtype memiliki lane FIFO v2 yang tidak didukung") } } func (s *adjustmentService) resolveAdjustmentDependenciesAndPolicy( ctx context.Context, tx *gorm.DB, stockableType string, stockableIDs []uint, ) ([]adjustmentStockRepo.AdjustmentDownstreamDependency, bool, error) { deps, err := s.AdjustmentStockRepository.WithTx(tx).LoadDownstreamDependencies(ctx, stockableType, stockableIDs) if err != nil { s.Log.Errorf("Failed to load downstream adjustment dependencies: %+v", err) return nil, false, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate downstream adjustment dependencies") } if len(deps) == 0 { return nil, true, nil } allowPending := true for _, dep := range deps { policy, policyErr := common.ResolveFifoPendingPolicy(ctx, tx, common.FifoPendingPolicyInput{ Lane: adjustmentLaneUsable, FlagGroupCode: dep.FlagGroupCode, FunctionCode: dep.FunctionCode, LegacyTypeKey: dep.UsableType, }) if policyErr != nil { s.Log.Errorf("Failed to resolve FIFO pending policy for adjustment dependency: %+v", policyErr) return nil, false, fiber.NewError(fiber.StatusInternalServerError, "Failed to read FIFO v2 configuration") } if !policy.Found || !policy.AllowPending { allowPending = false break } } return deps, allowPending, nil } func formatAdjustmentDependencySummary(rows []adjustmentStockRepo.AdjustmentDownstreamDependency) string { if len(rows) == 0 { return "-" } grouped := make(map[string]map[uint64]struct{}) for _, row := range rows { label := utils.NormalizeUpper(row.UsableType) if label == "" { label = "UNKNOWN" } if _, ok := grouped[label]; !ok { grouped[label] = make(map[uint64]struct{}) } grouped[label][row.UsableID] = struct{}{} } labels := make([]string, 0, len(grouped)) for label := range grouped { labels = append(labels, label) } sort.Strings(labels) parts := make([]string, 0, len(labels)) for _, label := range labels { ids := make([]uint64, 0, len(grouped[label])) for id := range grouped[label] { ids = append(ids, id) } sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) idParts := make([]string, 0, len(ids)) for _, id := range ids { idParts = append(idParts, fmt.Sprintf("%d", id)) } parts = append(parts, fmt.Sprintf("%s=%s", label, strings.Join(idParts, "|"))) } return strings.Join(parts, ", ") } func (s *adjustmentService) getActiveProjectFlockKandangID(ctx context.Context, warehouseID uint) (uint, error) { warehouse, err := s.WarehouseRepo.GetByID(ctx, warehouseID, nil) if err != nil { 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") } if warehouse.KandangId == nil || *warehouse.KandangId == 0 { return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gudang %d belum terhubung ke kandang", warehouseID)) } projectFlockKandang, err := s.ProjectFlockKandangRepo.GetActiveByKandangID(ctx, uint(*warehouse.KandangId)) if err != nil { 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") } return uint(projectFlockKandang.Id), nil } func (s *adjustmentService) createAdjustmentStockLog( ctx context.Context, stockLogRepo stockLogsRepo.StockLogRepository, adjustmentID uint, productWarehouseID uint, note string, actorID uint, increaseQty float64, decreaseQty float64, ) error { if stockLogRepo == nil || adjustmentID == 0 || productWarehouseID == 0 { return nil } if increaseQty == 0 && decreaseQty == 0 { return nil } stockLogs, err := stockLogRepo.GetByProductWarehouse(ctx, productWarehouseID, 1) if err != nil { s.Log.Errorf("Failed to get stock logs: %+v", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") } currentStock := 0.0 if len(stockLogs) > 0 { currentStock = stockLogs[0].Stock } newLog := &entity.StockLog{ LoggableType: string(utils.StockLogTypeAdjustment), LoggableId: adjustmentID, Notes: note, ProductWarehouseId: productWarehouseID, CreatedBy: actorID, Increase: increaseQty, Decrease: decreaseQty, Stock: currentStock + increaseQty - decreaseQty, } return stockLogRepo.CreateOne(ctx, newLog, nil) } func (s *adjustmentService) allocatePopulationForDepletionAdjustment( ctx context.Context, tx *gorm.DB, projectFlockKandangID uint, sourceProductWarehouseID uint, adjustmentID uint, consumeQty float64, ) error { if consumeQty <= 0 { return nil } if tx == nil { return errors.New("transaction is required") } if projectFlockKandangID == 0 || sourceProductWarehouseID == 0 || adjustmentID == 0 { return fiber.NewError(fiber.StatusBadRequest, "Invalid depletion adjustment population context") } if s.ProjectFlockPopulationRepo == nil { return fiber.NewError(fiber.StatusInternalServerError, "Project flock population repository is not available") } popRepoTx := s.ProjectFlockPopulationRepo.WithTx(tx) populations, err := popRepoTx.GetByProjectFlockKandangIDAndProductWarehouseID(ctx, projectFlockKandangID, sourceProductWarehouseID) if err != nil { return err } if len(populations) == 0 { return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak ditemukan untuk depletion adjustment") } return fifoV2.AllocatePopulationConsumption( ctx, tx, populations, sourceProductWarehouseID, fifo.UsableKeyAdjustmentOut.String(), adjustmentID, consumeQty, ) } func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Query) ([]*entity.AdjustmentStock, int64, error) { if err := s.Validate.Struct(query); err != nil { return nil, 0, err } if query.Page <= 0 { query.Page = 1 } if query.Limit <= 0 { query.Limit = 10 } offset := (query.Page - 1) * query.Limit var isProductsExist bool isWarehousesExist, err := s.WarehouseRepo.IdExists(c.Context(), uint(query.WarehouseID)) if err != nil { return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate warehouse") } if query.WarehouseID > 0 && !isWarehousesExist { return nil, 0, fiber.NewError(fiber.StatusNotFound, "Warehouse not found") } isProductsExist, err = s.ProductRepo.IdExists(c.Context(), uint(query.ProductID)) if err != nil { s.Log.Errorf("Failed to check product existence: %+v", err) return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product") } if query.ProductID > 0 && !isProductsExist { return nil, 0, fiber.NewError(fiber.StatusNotFound, "Product not found") } scope, scopeErr := m.ResolveLocationScope(c, s.AdjustmentStockRepository.DB()) if scopeErr != nil { return nil, 0, scopeErr } if scope.Restrict { if len(scope.IDs) == 0 { return []*entity.AdjustmentStock{}, 0, nil } } functionCode := utils.NormalizeUpper(query.TransactionSubtype) if functionCode == "" { functionCode = utils.NormalizeUpper(query.FunctionCode) } transactionType := utils.NormalizeUpper(query.TransactionType) adjustmentStocks, total, err := s.AdjustmentStockRepository.FindHistory( c.Context(), adjustmentStockRepo.AdjustmentHistoryFilter{ ProductID: query.ProductID, WarehouseID: query.WarehouseID, TransactionType: transactionType, FunctionCode: functionCode, ScopeRestrict: scope.Restrict, ScopeIDs: scope.IDs, Offset: offset, Limit: query.Limit, }, nil, ) if err != nil { s.Log.Errorf("Failed to get adjustments: %+v", err) return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to get adjustment history") } return adjustmentStocks, total, nil }