package service import ( "context" "errors" "fmt" "strconv" "strings" "time" "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" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" expenseDto "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/dto" expenseSvc "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/services" expenseValidation "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/validations" rStockTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/repositories" kandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" ) type TransferExpenseBridge interface { OnItemsDeleted(ctx context.Context, transferID uint64, items []entity.StockTransferDetail) error OnItemsDelivered(c *fiber.Ctx, transferID uint64, updates []TransferExpenseReceivingPayload) error } type TransferExpenseReceivingPayload struct { TransferDetailID uint64 ProductID uint64 WarehouseID uint64 SupplierID uint64 TransportPerItem *float64 DeliveredQty float64 DeliveredDate *time.Time } type groupedTransferItem struct { detail *entity.StockTransferDetail payload TransferExpenseReceivingPayload projectFK *uint kandangID *uint totalPrice float64 shippingCostTotal float64 } func groupingKey(supplierID uint, date time.Time, warehouseID uint) string { return fmt.Sprintf("%d:%s:%d", supplierID, utils.FormatDate(date), warehouseID) } type transferExpenseBridge struct { db *gorm.DB transferRepo rStockTransfer.StockTransferRepository projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository kandangRepo kandangRepo.KandangRepository expenseSvc expenseSvc.ExpenseService } func NewTransferExpenseBridge( db *gorm.DB, transferRepo rStockTransfer.StockTransferRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, kandangRepo kandangRepo.KandangRepository, expenseSvc expenseSvc.ExpenseService, ) TransferExpenseBridge { return &transferExpenseBridge{ db: db, transferRepo: transferRepo, projectFlockKandangRepo: projectFlockKandangRepo, kandangRepo: kandangRepo, expenseSvc: expenseSvc, } } func (b *transferExpenseBridge) OnItemsDeleted(ctx context.Context, _ uint64, items []entity.StockTransferDetail) error { if len(items) == 0 { return nil } return b.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { expenseIDs := make(map[uint64]struct{}) expenseNonstockIDs := make([]uint64, 0) for _, item := range items { if item.ExpenseNonstockId != nil && *item.ExpenseNonstockId != 0 { expenseNonstockIDs = append(expenseNonstockIDs, *item.ExpenseNonstockId) } } if len(expenseNonstockIDs) > 0 { for _, nsID := range expenseNonstockIDs { var expenseID uint64 if err := tx.Model(&entity.ExpenseNonstock{}). Select("expense_id"). Where("id = ?", nsID). Scan(&expenseID).Error; err != nil { return err } if expenseID != 0 { expenseIDs[expenseID] = struct{}{} } } if err := tx.Delete(&entity.ExpenseNonstock{}, expenseNonstockIDs).Error; err != nil { return err } } approvalRepoTx := commonRepo.NewApprovalRepository(tx) for expenseID := range expenseIDs { var count int64 if err := tx.Model(&entity.ExpenseNonstock{}). Where("expense_id = ?", expenseID). Count(&count).Error; err != nil { return err } if count == 0 { if err := approvalRepoTx.DeleteByTarget(ctx, utils.ApprovalWorkflowExpense.String(), uint(expenseID)); err != nil { return err } if err := tx.Delete(&entity.Expense{}, expenseID).Error; err != nil { return err } } } return nil }) } func (b *transferExpenseBridge) markExpensesUpdated(ctx context.Context, expenseIDs map[uint64]struct{}, actorID uint) error { if len(expenseIDs) == 0 { return nil } if actorID == 0 { actorID = 1 } svc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(b.db)) action := entity.ApprovalActionUpdated for id := range expenseIDs { if _, err := svc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(id), utils.ExpenseStepFinance, &action, actorID, nil); err != nil { return err } } return nil } func (b *transferExpenseBridge) findExpeditionNonstockID(ctx context.Context, supplierID uint) (uint64, error) { var id uint64 err := b.db.WithContext(ctx). Table("nonstocks AS ns"). Select("ns.id"). Joins("JOIN nonstock_suppliers nss ON nss.nonstock_id = ns.id"). Joins("JOIN flags f ON f.flagable_id = ns.id AND f.flagable_type = ?", entity.FlagableTypeNonstock). Where("UPPER(f.name) = ?", strings.ToUpper(string(utils.FlagEkspedisi))). Where("nss.supplier_id = ?", supplierID). Order("ns.id"). Limit(1). Scan(&id).Error if err != nil { return 0, err } if id == 0 { return 0, fiber.NewError(fiber.StatusBadRequest, "supplier id tidak sesuai dengan expedisi") } return id, nil } func (b *transferExpenseBridge) createExpenseViaService( c *fiber.Ctx, transfer *entity.StockTransfer, items []groupedTransferItem, expenseDate time.Time, expeditionNonstockID uint64, movementNumber string, supplierID uint, ) (*expenseDto.ExpenseDetailDTO, error) { ctx := c.Context() if b.expenseSvc == nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "expense service not available") } if len(items) == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "no items to create expense") } kandangID := items[0].kandangID var locationID uint64 var expenseKandangID *uint64 if kandangID != nil && *kandangID != 0 { kandang, err := b.kandangRepo.GetByID(ctx, *kandangID, func(db *gorm.DB) *gorm.DB { return db.Select("id, location_id") }) if err != nil { return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Kandang not found: %d", *kandangID)) } if kandang == nil { return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Kandang not found: %d", *kandangID)) } locationID = uint64(kandang.LocationId) id := uint64(*kandangID) expenseKandangID = &id } else { if transfer.ToWarehouse == nil || transfer.ToWarehouse.LocationId == nil || *transfer.ToWarehouse.LocationId == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Destination warehouse location is required for expense") } locationID = uint64(*transfer.ToWarehouse.LocationId) } costItems := make([]expenseValidation.CostItem, 0, len(items)) for _, gi := range items { note := fmt.Sprintf("stock_transfer_detail:%d", gi.detail.Id) price := gi.shippingCostTotal if gi.payload.TransportPerItem != nil { price = *gi.payload.TransportPerItem * gi.payload.DeliveredQty } costItems = append(costItems, expenseValidation.CostItem{ NonstockID: expeditionNonstockID, Quantity: 1, Price: price, Notes: note, }) } req := &expenseValidation.Create{ PoNumber: "", TransactionDate: utils.FormatDate(expenseDate), Category: string(utils.ExpenseCategoryBOP), SupplierID: uint64(supplierID), LocationID: locationID, ExpenseNonstocks: []expenseValidation.ExpenseNonstock{{ KandangID: expenseKandangID, CostItems: costItems, }}, } detail, err := b.expenseSvc.CreateOne(c, req) if err != nil { return nil, err } action := entity.ApprovalActionApproved actorID := uint(transfer.CreatedBy) if actorID == 0 { actorID = 1 } approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(b.db)) if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepHeadArea, &action, actorID, nil); err != nil { return nil, err } if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepUnitVicePresident, &action, actorID, nil); err != nil { return nil, err } if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepFinance, &action, actorID, nil); err != nil { return nil, err } return detail, nil } func (b *transferExpenseBridge) linkExpenseNonstocksToDetails(ctx context.Context, detail *expenseDto.ExpenseDetailDTO, items []groupedTransferItem) error { if detail == nil || len(items) == 0 { return nil } noteToExpenseNonstock := mapExpenseNotesForTransfer(detail) if len(noteToExpenseNonstock) == 0 { return nil } for _, gi := range items { expenseNonstockID, ok := noteToExpenseNonstock[gi.detail.Id] if !ok { continue } if err := b.db.WithContext(ctx). Model(&entity.StockTransferDetail{}). Where("id = ?", gi.detail.Id). Update("expense_nonstock_id", expenseNonstockID).Error; err != nil { return err } } return nil } func mapExpenseNotesForTransfer(detail *expenseDto.ExpenseDetailDTO) map[uint64]uint64 { result := make(map[uint64]uint64) if detail == nil { return result } for _, kandang := range detail.Kandangs { for _, pengajuan := range kandang.Pengajuans { note := strings.TrimSpace(pengajuan.Notes) if note == "" { continue } const prefix = "stock_transfer_detail:" if !strings.HasPrefix(note, prefix) { continue } idStr := strings.TrimPrefix(note, prefix) var detailID uint64 if _, err := fmt.Sscanf(idStr, "%d", &detailID); err != nil { continue } result[detailID] = pengajuan.Id } } return result } func (b *transferExpenseBridge) OnItemsDelivered(c *fiber.Ctx, transferID uint64, updates []TransferExpenseReceivingPayload) error { if transferID == 0 || len(updates) == 0 { return nil } ctx := c.Context() transfer, err := b.transferRepo.GetByID(ctx, uint(transferID), func(db *gorm.DB) *gorm.DB { return db. Preload("Details"). Preload("Details.Product"). Preload("Details.DestProductWarehouse"). Preload("Details.DeliveryItems"). Preload("Details.DeliveryItems.StockTransferDelivery"). Preload("ToWarehouse") }) if err != nil { return err } detailMap := make(map[uint64]*entity.StockTransferDetail, len(transfer.Details)) shippingCostMap := make(map[uint64]float64) // detailID -> ShippingCostTotal for i := range transfer.Details { detailMap[transfer.Details[i].Id] = &transfer.Details[i] for _, deliveryItem := range transfer.Details[i].DeliveryItems { if deliveryItem.StockTransferDelivery != nil { shippingCostMap[transfer.Details[i].Id] = deliveryItem.StockTransferDelivery.ShippingCostTotal break } } } groups := make(map[string][]groupedTransferItem) for _, payload := range updates { if payload.DeliveredDate == nil { return fiber.NewError(fiber.StatusBadRequest, "delivered_date is required") } detail := detailMap[payload.TransferDetailID] if detail == nil { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Transfer detail %d not found", payload.TransferDetailID)) } if payload.DeliveredQty <= 0 { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Delivered quantity for detail %d must be greater than 0", payload.TransferDetailID)) } deliveredDate := payload.DeliveredDate.UTC().Truncate(24 * time.Hour) supplierID := payload.SupplierID if supplierID == 0 { supplierID = 1 // Default supplier } var kandangID *uint var projectFK *uint if detail.DestProductWarehouse.WarehouseId != 0 { var kandangIDResult uint if err := b.db.WithContext(ctx). Table("warehouses"). Select("kandang_id"). Where("id = ?", detail.DestProductWarehouse.WarehouseId). Scan(&kandangIDResult).Error; err == nil && kandangIDResult != 0 { id := uint(kandangIDResult) kandangID = &id if project, err := b.projectFlockKandangRepo.GetActiveByKandangID(ctx, kandangIDResult); err == nil && project != nil { pid := uint(project.Id) projectFK = &pid } } } shippingCostTotal := shippingCostMap[detail.Id] totalPrice := shippingCostTotal if payload.TransportPerItem != nil { totalPrice = *payload.TransportPerItem * payload.DeliveredQty } warehouseID := uint(payload.WarehouseID) if warehouseID == 0 && transfer.ToWarehouse != nil { warehouseID = uint(transfer.ToWarehouse.Id) } if warehouseID == 0 && detail.DestProductWarehouse != nil { warehouseID = uint(detail.DestProductWarehouse.WarehouseId) } key := groupingKey(uint(supplierID), deliveredDate, warehouseID) groups[key] = append(groups[key], groupedTransferItem{ detail: detail, payload: payload, projectFK: projectFK, kandangID: kandangID, totalPrice: totalPrice, shippingCostTotal: shippingCostTotal, }) } updatedExpenses := make(map[uint64]struct{}) for key, items := range groups { if len(items) == 0 { continue } parts := strings.Split(key, ":") if len(parts) < 3 { return errors.New("invalid expense grouping key") } expenseDate, err := utils.ParseDateString(parts[1]) if err != nil { return err } supplierID, err := strconv.ParseUint(parts[0], 10, 64) if err != nil { return err } expeditionNonstockID, err := b.findExpeditionNonstockID(ctx, uint(supplierID)) if err != nil { return err } expenseDetail, err := b.createExpenseViaService(c, transfer, items, expenseDate, expeditionNonstockID, transfer.MovementNumber, uint(supplierID)) if err != nil { return err } if err := b.linkExpenseNonstocksToDetails(ctx, expenseDetail, items); err != nil { return err } if expenseDetail != nil && expenseDetail.Id != 0 { updatedExpenses[uint64(expenseDetail.Id)] = struct{}{} } } if len(updatedExpenses) > 0 { actorID := uint(1) // Default actor if err := b.markExpensesUpdated(ctx, updatedExpenses, actorID); err != nil { return err } } return nil }