package service import ( "context" "errors" "fmt" "strings" "time" "github.com/gofiber/fiber/v2" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" 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" "gorm.io/gorm" "gorm.io/gorm/clause" ) func (s *transferService) CreateSystemTransfer(ctx context.Context, req *SystemTransferRequest) (*entity.StockTransfer, error) { if req == nil { return nil, fmt.Errorf("system transfer request is required") } if strings.TrimSpace(req.TransferReason) == "" { return nil, fmt.Errorf("transfer reason is required") } if req.TransferDate.IsZero() { return nil, fmt.Errorf("transfer date is required") } if req.SourceWarehouseID == 0 || req.DestinationWarehouseID == 0 { return nil, fmt.Errorf("source and destination warehouse are required") } if req.SourceWarehouseID == req.DestinationWarehouseID { return nil, fmt.Errorf("source and destination warehouse must be different") } if req.ActorID == 0 { return nil, fmt.Errorf("actor id is required") } if err := s.validateTransferWarehousesAndProducts(ctx, req.SourceWarehouseID, req.DestinationWarehouseID, req.Products); err != nil { return nil, err } var result *entity.StockTransfer err := s.StockTransferRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { movementResult, err := s.createTransferMovement(ctx, tx, req) if err != nil { return err } result = movementResult.Transfer return nil }) if err != nil { return nil, err } return result, nil } func (s *transferService) DeleteSystemTransfer(ctx context.Context, id uint, actorID uint) error { if id == 0 { return fmt.Errorf("transfer id is required") } if actorID == 0 { return fmt.Errorf("actor id is required") } var deletedDetails []entity.StockTransferDetail err := s.StockTransferRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { var err error deletedDetails, err = s.deleteTransferCore(ctx, tx, uint64(id), actorID) return err }) if err != nil { return err } if len(deletedDetails) > 0 && s.ExpenseBridge != nil { if err := s.ExpenseBridge.OnItemsDeleted(ctx, uint64(id), deletedDetails); err != nil { s.Log.Errorf("Failed to cleanup transfer expense link for transfer_id=%d: %+v", id, err) return fiber.NewError(fiber.StatusInternalServerError, "Transfer berhasil dihapus, namun sinkronisasi expense gagal. Silakan cek modul expense") } } return nil } func (s *transferService) validateTransferWarehousesAndProducts( ctx context.Context, sourceWarehouseID uint, destinationWarehouseID uint, products []SystemTransferProduct, ) error { if len(products) == 0 { return fmt.Errorf("transfer products are required") } pwIDs := make([]uint, 0, len(products)) for _, product := range products { if product.ProductID == 0 { return fmt.Errorf("product id is required") } if product.ProductQty <= 0 { return fmt.Errorf("product qty must be greater than 0 for product %d", product.ProductID) } sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID( ctx, product.ProductID, sourceWarehouseID, ) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Produk dengan ID %d tidak ditemukan di gudang asal (ID: %d)", product.ProductID, sourceWarehouseID)) } s.Log.Errorf("Failed to fetch product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, sourceWarehouseID, err) return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengecek stok produk") } if sourcePW.Quantity < product.ProductQty { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok produk %d di gudang asal tidak mencukupi. Tersedia: %.2f, Diminta: %.2f", product.ProductID, sourcePW.Quantity, product.ProductQty)) } pwIDs = append(pwIDs, sourcePW.Id) } if err := commonSvc.EnsureProjectFlockNotClosedForProductWarehouses(ctx, s.StockTransferRepo.DB(), pwIDs); err != nil { return err } destPfkID, err := s.getActiveProjectFlockKandangID(ctx, destinationWarehouseID) if err != nil { return err } if destPfkID == 0 { return nil } projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(ctx, destPfkID) if err != nil { s.Log.Errorf("Failed to fetch project flock kandang by ID %d: %+v", destPfkID, err) return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock") } if projectFlockKandang.ClosedAt != nil { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Project flock untuk gudang tujuan sudah ditutup (closing) pada %s", projectFlockKandang.ClosedAt.Format("2006-01-02"))) } return nil } func (s *transferService) createTransferMovement( ctx context.Context, tx *gorm.DB, req *SystemTransferRequest, ) (*transferMovementResult, error) { if tx == nil { return nil, fmt.Errorf("transaction is required") } stockTransferRepoTX := s.StockTransferRepo.WithTx(tx) stockTransferDetailRepoTX := s.StockTransferDetailRepo.WithTx(tx) productWarehouseRepoTX := rProductWarehouse.NewProductWarehouseRepository(tx) stockLogsRepoTX := rStockLogs.NewStockLogRepository(tx) movementNumber := strings.TrimSpace(req.MovementNumber) if movementNumber == "" { var err error movementNumber, err = s.StockTransferRepo.GenerateMovementNumber(ctx) if err != nil { s.Log.Errorf("Failed to generate movement number: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat nomor transfer") } } entityTransfer := &entity.StockTransfer{ FromWarehouseId: uint64(req.SourceWarehouseID), ToWarehouseId: uint64(req.DestinationWarehouseID), Reason: req.TransferReason, TransferDate: req.TransferDate, MovementNumber: movementNumber, CreatedBy: uint64(req.ActorID), } if err := stockTransferRepoTX.CreateOne(ctx, entityTransfer, nil); err != nil { return nil, err } details := make([]*entity.StockTransferDetail, 0, len(req.Products)) detailMap := make(map[uint64]*entity.StockTransferDetail, len(req.Products)) for _, product := range req.Products { sourcePW, err := productWarehouseRepoTX.GetProductWarehouseByProductAndWarehouseID( ctx, product.ProductID, req.SourceWarehouseID, ) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Produk %d tidak ditemukan di gudang asal (ID: %d)", product.ProductID, req.SourceWarehouseID)) } s.Log.Errorf("Failed to fetch source product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.SourceWarehouseID, err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data stok gudang asal") } destPW, err := productWarehouseRepoTX.GetProductWarehouseByProductAndWarehouseID( ctx, product.ProductID, req.DestinationWarehouseID, ) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { s.Log.Errorf("Failed to fetch dest product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.DestinationWarehouseID, err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data stok gudang tujuan") } if errors.Is(err, gorm.ErrRecordNotFound) { projectFlockKandangID, err := s.getActiveProjectFlockKandangID(ctx, req.DestinationWarehouseID) if err != nil { return nil, err } var pfkID *uint if projectFlockKandangID > 0 { pfkID = &projectFlockKandangID } destPW = &entity.ProductWarehouse{ ProductId: product.ProductID, WarehouseId: req.DestinationWarehouseID, Quantity: 0, ProjectFlockKandangId: pfkID, } if err := productWarehouseRepoTX.CreateOne(ctx, destPW, nil); err != nil { s.Log.Errorf("Failed to create product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.DestinationWarehouseID, err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat data stok gudang tujuan") } } detail := &entity.StockTransferDetail{ StockTransferId: entityTransfer.Id, ProductId: uint64(product.ProductID), SourceProductWarehouseID: func() *uint64 { id := uint64(sourcePW.Id) return &id }(), UsageQty: 0, PendingQty: 0, DestProductWarehouseID: func() *uint64 { id := uint64(destPW.Id) return &id }(), TotalQty: 0, TotalUsed: 0, } details = append(details, detail) detailMap[uint64(product.ProductID)] = detail } if err := stockTransferDetailRepoTX.CreateMany(ctx, details, nil); err != nil { return nil, err } flagGroupByProduct := make(map[uint]string, len(req.Products)) for _, product := range req.Products { detail := detailMap[uint64(product.ProductID)] if detail == nil || detail.SourceProductWarehouseID == nil || detail.DestProductWarehouseID == nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Data transfer detail tidak valid") } flagGroupCode, ok := flagGroupByProduct[product.ProductID] if !ok { var err error flagGroupCode, err = s.resolveTransferFlagGroup(ctx, tx, product.ProductID) if err != nil { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("FIFO v2 route tidak ditemukan untuk produk %d: %v", product.ProductID, err)) } flagGroupByProduct[product.ProductID] = flagGroupCode } if err := tx.Model(&entity.StockTransferDetail{}). Where("id = ?", detail.Id). Updates(map[string]interface{}{ "usage_qty": product.ProductQty, "pending_qty": 0, "total_qty": product.ProductQty, }).Error; err != nil { s.Log.Errorf("Failed to update transfer detail seed fields for detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal memperbarui data tracking") } asOf := req.TransferDate if _, err := s.FifoStockV2Svc.Reflow(ctx, commonSvc.FifoStockV2ReflowRequest{ FlagGroupCode: flagGroupCode, ProductWarehouseID: uint(*detail.SourceProductWarehouseID), AsOf: &asOf, Tx: tx, }); err != nil { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal. Error: %v", product.ProductID, err)) } if _, err := s.FifoStockV2Svc.Reflow(ctx, commonSvc.FifoStockV2ReflowRequest{ FlagGroupCode: flagGroupCode, ProductWarehouseID: uint(*detail.DestProductWarehouseID), AsOf: &asOf, Tx: tx, }); err != nil { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal reflow stok tujuan untuk produk %d. Error: %v", product.ProductID, err)) } type usageSnapshot struct { UsageQty float64 `gorm:"column:usage_qty"` PendingQty float64 `gorm:"column:pending_qty"` } var usage usageSnapshot if err := tx.WithContext(ctx). Table("stock_transfer_details"). Select("usage_qty, pending_qty"). Where("id = ?", detail.Id). Take(&usage).Error; err != nil { s.Log.Errorf("Failed to read transfer usage snapshot detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data tracking") } outUsageQty := usage.UsageQty outPendingQty := usage.PendingQty if outPendingQty > 1e-6 { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal", product.ProductID)) } stockLogDecrease := &entity.StockLog{ ProductWarehouseId: uint(*detail.SourceProductWarehouseID), CreatedBy: req.ActorID, Increase: 0, Decrease: outUsageQty, LoggableType: string(utils.StockLogTypeTransfer), LoggableId: uint(detail.Id), Notes: req.StockLogNotes, } stockLogs, err := stockLogsRepoTX.GetByProductWarehouse(ctx, uint(*detail.SourceProductWarehouseID), 1) if err != nil { s.Log.Errorf("Failed to get stock logs: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") } if len(stockLogs) > 0 { latestStockLog := stockLogs[0] stockLogDecrease.Stock = latestStockLog.Stock - stockLogDecrease.Decrease } else { stockLogDecrease.Stock -= stockLogDecrease.Decrease } if err := stockLogsRepoTX.CreateOne(ctx, stockLogDecrease, nil); err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar") } stockLogIncrease := &entity.StockLog{ ProductWarehouseId: uint(*detail.DestProductWarehouseID), CreatedBy: req.ActorID, Increase: outUsageQty, Decrease: 0, LoggableType: string(utils.StockLogTypeTransfer), LoggableId: uint(detail.Id), Notes: req.StockLogNotes, } stockLogs, err = stockLogsRepoTX.GetByProductWarehouse(ctx, uint(*detail.DestProductWarehouseID), 1) if err != nil { s.Log.Errorf("Failed to get stock logs: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") } if len(stockLogs) > 0 { latestStockLog := stockLogs[0] stockLogIncrease.Stock = latestStockLog.Stock + stockLogIncrease.Increase } else { stockLogIncrease.Stock += stockLogIncrease.Increase } if err := stockLogsRepoTX.CreateOne(ctx, stockLogIncrease, nil); err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok masuk") } } return &transferMovementResult{ Transfer: entityTransfer, DetailByPID: detailMap, }, nil } func (s *transferService) deleteTransferCore( ctx context.Context, tx *gorm.DB, transferID uint64, actorID uint, ) ([]entity.StockTransferDetail, error) { stockLogRepoTx := rStockLogs.NewStockLogRepository(tx) var transfer entity.StockTransfer if err := tx.WithContext(ctx). Clauses(clause.Locking{Strength: "UPDATE"}). Where("id = ?", transferID). Where("deleted_at IS NULL"). Take(&transfer).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Transfer dengan ID %d tidak ditemukan", transferID)) } return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data transfer") } var details []entity.StockTransferDetail if err := tx.WithContext(ctx). Clauses(clause.Locking{Strength: "UPDATE"}). Where("stock_transfer_id = ?", transfer.Id). Where("deleted_at IS NULL"). Order("id ASC"). Find(&details).Error; err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil detail transfer") } if len(details) == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Transfer tidak memiliki detail produk") } detailIDs := make([]uint64, 0, len(details)) for _, detail := range details { detailIDs = append(detailIDs, detail.Id) } if err := s.ensureDeletePolicyForDownstreamConsumption(ctx, tx, detailIDs); err != nil { return nil, err } type reflowKey struct { flagGroupCode string productWarehouseID uint } destReflows := make(map[reflowKey]struct{}) for _, detail := range details { if detail.SourceProductWarehouseID == nil || *detail.SourceProductWarehouseID == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Detail transfer %d tidak memiliki source product warehouse valid", detail.Id)) } if detail.DestProductWarehouseID == nil || *detail.DestProductWarehouseID == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Detail transfer %d tidak memiliki destination product warehouse valid", detail.Id)) } flagGroupCode, err := s.resolveTransferFlagGroup(ctx, tx, uint(detail.ProductId)) if err != nil { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("FIFO v2 route tidak ditemukan untuk produk %d: %v", detail.ProductId, err)) } rollbackRes, err := s.FifoStockV2Svc.Rollback(ctx, commonSvc.FifoStockV2RollbackRequest{ ProductWarehouseID: uint(*detail.SourceProductWarehouseID), Usable: commonSvc.FifoStockV2Ref{ ID: uint(detail.Id), LegacyTypeKey: fifo.UsableKeyStockTransferOut.String(), FunctionCode: "STOCK_TRANSFER_OUT", }, Reason: fmt.Sprintf("transfer delete #%s", transfer.MovementNumber), Tx: tx, }) if err != nil { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal rollback FIFO v2 transfer detail %d: %v", detail.Id, err)) } releasedQty := 0.0 if rollbackRes != nil { releasedQty = rollbackRes.ReleasedQty } if detail.UsageQty > 1e-6 && releasedQty < detail.UsageQty-1e-6 { return nil, fiber.NewError( fiber.StatusBadRequest, fmt.Sprintf("Rollback FIFO v2 source transfer detail %d tidak lengkap. Dibutuhkan %.3f, terlepas %.3f", detail.Id, detail.UsageQty, releasedQty), ) } if releasedQty > 1e-6 { if err := s.appendStockLog( ctx, stockLogRepoTx, uint(*detail.SourceProductWarehouseID), actorID, releasedQty, 0, uint(detail.Id), fmt.Sprintf("TRANSFER DELETE #%s", transfer.MovementNumber), ); err != nil { return nil, err } } destDecreaseQty := detail.TotalQty if destDecreaseQty <= 1e-6 { destDecreaseQty = detail.UsageQty } if destDecreaseQty > 1e-6 { if err := s.appendStockLog( ctx, stockLogRepoTx, uint(*detail.DestProductWarehouseID), actorID, 0, destDecreaseQty, uint(detail.Id), fmt.Sprintf("TRANSFER DELETE #%s", transfer.MovementNumber), ); err != nil { return nil, err } } destReflows[reflowKey{ flagGroupCode: flagGroupCode, productWarehouseID: uint(*detail.DestProductWarehouseID), }] = struct{}{} } now := time.Now().UTC() if err := tx.WithContext(ctx). Where("stock_transfer_detail_id IN ?", detailIDs). Delete(&entity.StockTransferDeliveryItem{}).Error; err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus item delivery transfer") } if err := tx.WithContext(ctx). Model(&entity.StockTransferDelivery{}). Where("stock_transfer_id = ?", transfer.Id). Where("deleted_at IS NULL"). Updates(map[string]any{ "deleted_at": now, "updated_at": now, }).Error; err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus delivery transfer") } if err := tx.WithContext(ctx). Model(&entity.StockTransferDetail{}). Where("id IN ?", detailIDs). Where("deleted_at IS NULL"). Updates(map[string]any{ "deleted_at": now, "updated_at": now, }).Error; err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus detail transfer") } asOf := transfer.TransferDate for key := range destReflows { if _, err := s.FifoStockV2Svc.Reflow(ctx, commonSvc.FifoStockV2ReflowRequest{ FlagGroupCode: key.flagGroupCode, ProductWarehouseID: key.productWarehouseID, AsOf: &asOf, Tx: tx, }); err != nil { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal reflow stok tujuan saat delete transfer: %v", err)) } } if err := tx.WithContext(ctx). Model(&entity.StockTransfer{}). Where("id = ?", transfer.Id). Where("deleted_at IS NULL"). Updates(map[string]any{ "deleted_at": now, "updated_at": now, }).Error; err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus transfer") } return details, nil }