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" projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rPurchase "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" ) type PurchaseExpenseBridge interface { OnItemsDeleted(ctx context.Context, purchaseID uint, items []entity.PurchaseItem) error OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates []ExpenseReceivingPayload) error } type ExpenseReceivingPayload struct { PurchaseItemID uint ProductID uint WarehouseID uint SupplierID uint TransportPerItem *float64 ReceivedQty float64 ReceivedDate *time.Time } type groupedItem struct { item *entity.PurchaseItem payload ExpenseReceivingPayload projectFK *uint kandangID *uint totalPrice float64 } func groupingKey(supplierID uint, date time.Time, warehouseID uint) string { return fmt.Sprintf("%d:%s:%d", supplierID, utils.FormatDate(date), warehouseID) } type expenseBridge struct { db *gorm.DB purchaseRepo rPurchase.PurchaseRepository projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository expenseSvc expenseSvc.ExpenseService } func NewExpenseBridge( db *gorm.DB, purchaseRepo rPurchase.PurchaseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, expenseSvc expenseSvc.ExpenseService, ) PurchaseExpenseBridge { return &expenseBridge{ db: db, purchaseRepo: purchaseRepo, projectFlockKandangRepo: projectFlockKandangRepo, expenseSvc: expenseSvc, } } func (b *expenseBridge) OnItemsDeleted(ctx context.Context, _ uint, items []entity.PurchaseItem) 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 } } var links []struct { ItemID uint ExpenseNonstockID *uint64 } if err := tx. Model(&entity.PurchaseItem{}). Select("id as item_id, expense_nonstock_id"). Where("id IN ?", extractIDs(items)). Scan(&links).Error; err != nil { return err } for _, link := range links { if link.ExpenseNonstockID == nil || *link.ExpenseNonstockID == 0 { continue } var expenseID uint64 if err := tx. Model(&entity.ExpenseNonstock{}). Select("expense_id"). Where("id = ?", *link.ExpenseNonstockID). Scan(&expenseID).Error; err != nil { return err } if expenseID != 0 { expenseIDs[expenseID] = struct{}{} } if err := tx.Delete(&entity.ExpenseNonstock{}, *link.ExpenseNonstockID).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 *expenseBridge) 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 *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates []ExpenseReceivingPayload) error { if purchaseID == 0 || len(updates) == 0 { return nil } ctx := c.Context() // Load current links to decide whether to update in place or recreate. type itemLink struct { ExpenseNonstockID uint64 ExpenseID uint64 SupplierID uint TransactionDate time.Time Qty float64 Price float64 } purchase, err := b.purchaseRepo.GetByID(ctx, purchaseID, func(db *gorm.DB) *gorm.DB { return db. Preload("Items"). Preload("Items.Warehouse"). Preload("Items.Warehouse.Kandang") }) if err != nil { return err } itemLinks := make(map[uint]itemLink) updatedExpenses := make(map[uint64]struct{}) if len(updates) > 0 { ids := make([]uint, 0, len(updates)) for _, upd := range updates { if upd.PurchaseItemID != 0 { ids = append(ids, upd.PurchaseItemID) } } if len(ids) > 0 { rows := make([]struct { ItemID uint ExpenseNonstockID uint64 ExpenseID uint64 SupplierID uint TransactionDate time.Time Qty float64 Price float64 }, 0) if err := b.db.WithContext(ctx). Table("purchase_items pi"). Select("pi.id as item_id, en.id as expense_nonstock_id, en.expense_id, e.supplier_id, e.transaction_date, en.qty, en.price"). Joins("LEFT JOIN expense_nonstocks en ON en.id = pi.expense_nonstock_id"). Joins("LEFT JOIN expenses e ON e.id = en.expense_id"). Where("pi.id IN ?", ids). Scan(&rows).Error; err != nil { return err } // Build quick lookup per item and per group key for existing expenses. for _, row := range rows { itemLinks[row.ItemID] = itemLink{ ExpenseNonstockID: row.ExpenseNonstockID, ExpenseID: row.ExpenseID, SupplierID: row.SupplierID, TransactionDate: row.TransactionDate, Qty: row.Qty, Price: row.Price, } } } } itemMap := make(map[uint]*entity.PurchaseItem, len(purchase.Items)) for i := range purchase.Items { itemMap[purchase.Items[i].Id] = &purchase.Items[i] } groups := make(map[string][]groupedItem) for _, payload := range updates { if payload.ReceivedDate == nil { return fiber.NewError(fiber.StatusBadRequest, "received_date is required") } item := itemMap[payload.PurchaseItemID] if item == nil { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Purchase item %d not found", payload.PurchaseItemID)) } if payload.ReceivedQty <= 0 { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Received quantity for item %d must be greater than 0", payload.PurchaseItemID)) } receivedDate := payload.ReceivedDate.UTC().Truncate(24 * time.Hour) supplierID := payload.SupplierID if supplierID == 0 { supplierID = purchase.SupplierId } // Decide whether to update existing expense_nonstock or create new. link, hasLink := itemLinks[payload.PurchaseItemID] if hasLink && link.ExpenseNonstockID != 0 && link.ExpenseID != 0 { oldDate := link.TransactionDate.UTC().Truncate(24 * time.Hour) newDate := receivedDate oldSupplier := link.SupplierID pricePerItem := item.Price if payload.TransportPerItem != nil { pricePerItem = *payload.TransportPerItem } // If supplier/date unchanged, update nonstock in place. if oldSupplier == supplierID && oldDate.Equal(newDate) { note := fmt.Sprintf("purchase_item:%d", payload.PurchaseItemID) if err := b.db.WithContext(ctx). Model(&entity.ExpenseNonstock{}). Where("id = ?", link.ExpenseNonstockID). Updates(map[string]interface{}{ "qty": payload.ReceivedQty, "price": pricePerItem, "notes": note, }).Error; err != nil { return err } if link.ExpenseID != 0 { updatedExpenses[link.ExpenseID] = struct{}{} } continue } // Supplier/date changed: if the linked expense has only this nonstock, update it in place. if link.ExpenseID != 0 { var cnt int64 if err := b.db.WithContext(ctx). Model(&entity.ExpenseNonstock{}). Where("expense_id = ?", link.ExpenseID). Count(&cnt).Error; err != nil { return err } if cnt == 1 { if item.Warehouse == nil || item.Warehouse.KandangId == nil || *item.Warehouse.KandangId == 0 { return fiber.NewError(fiber.StatusBadRequest, "Warehouse not connect to kandangs") } newNonstockID, err := b.findExpeditionNonstockID(ctx, supplierID) if err != nil { return err } note := fmt.Sprintf("purchase_item:%d", payload.PurchaseItemID) if err := b.db.WithContext(ctx). Model(&entity.Expense{}). Where("id = ?", link.ExpenseID). Updates(map[string]interface{}{ "transaction_date": newDate, "supplier_id": supplierID, }).Error; err != nil { return err } updateBody := map[string]interface{}{ "qty": payload.ReceivedQty, "price": pricePerItem, "notes": note, "nonstock_id": newNonstockID, "kandang_id": uint64(*item.Warehouse.KandangId), } if err := b.db.WithContext(ctx). Model(&entity.ExpenseNonstock{}). Where("id = ?", link.ExpenseNonstockID). Updates(updateBody).Error; err != nil { return err } updatedExpenses[link.ExpenseID] = struct{}{} continue } // Expense has multiple nonstocks: create new expense header for this item, then move existing nonstock to it. var kandangID *uint var projectFK *uint if item.Warehouse != nil && item.Warehouse.KandangId != nil { id := uint(*item.Warehouse.KandangId) kandangID = &id if project, err := b.projectFlockKandangRepo.GetActiveByKandangID(ctx, uint(*item.Warehouse.KandangId)); err == nil && project != nil { pid := uint(project.Id) projectFK = &pid } } pricePerItem := item.Price if payload.TransportPerItem != nil { pricePerItem = *payload.TransportPerItem } totalPrice := pricePerItem * payload.ReceivedQty gItem := groupedItem{ item: item, payload: payload, projectFK: projectFK, kandangID: kandangID, totalPrice: totalPrice, } newNonstockID, err := b.findExpeditionNonstockID(ctx, supplierID) if err != nil { return err } expenseDetail, err := b.createExpenseViaService(c, purchase, []groupedItem{gItem}, newDate, newNonstockID, purchase.PoNumber, supplierID) if err != nil { return err } var createdNonstockID uint64 if expenseDetail != nil { noteMap := mapExpenseNotes(expenseDetail) createdNonstockID = noteMap[payload.PurchaseItemID] } note := fmt.Sprintf("purchase_item:%d", payload.PurchaseItemID) updateBody := map[string]interface{}{ "expense_id": expenseDetail.Id, "qty": payload.ReceivedQty, "price": pricePerItem, "notes": note, "nonstock_id": newNonstockID, } if kandangID != nil { updateBody["kandang_id"] = uint64(*kandangID) } if projectFK != nil { updateBody["project_flock_kandang_id"] = uint64(*projectFK) } if err := b.db.WithContext(ctx). Model(&entity.ExpenseNonstock{}). Where("id = ?", link.ExpenseNonstockID). Updates(updateBody).Error; err != nil { return err } if createdNonstockID != 0 { if err := b.db.WithContext(ctx).Delete(&entity.ExpenseNonstock{}, createdNonstockID).Error; err != nil { return err } } if link.ExpenseID != 0 { updatedExpenses[link.ExpenseID] = struct{}{} } if expenseDetail != nil && expenseDetail.Id != 0 { updatedExpenses[uint64(expenseDetail.Id)] = struct{}{} } continue } // Otherwise create new expense/nonstock in grouping flow. } baseKey := fmt.Sprintf("%d:%s:%d", supplierID, utils.FormatDate(receivedDate), payload.WarehouseID) key := baseKey if hasLink && link.ExpenseNonstockID != 0 && link.ExpenseID != 0 { key = fmt.Sprintf("%s:%d", baseKey, payload.PurchaseItemID) } var kandangID *uint var projectFK *uint if item.Warehouse != nil && item.Warehouse.KandangId != nil { id := uint(*item.Warehouse.KandangId) kandangID = &id if project, err := b.projectFlockKandangRepo.GetActiveByKandangID(ctx, uint(*item.Warehouse.KandangId)); err == nil && project != nil { pid := uint(project.Id) projectFK = &pid } } pricePerItem := item.Price if payload.TransportPerItem != nil { pricePerItem = *payload.TransportPerItem } totalPrice := pricePerItem * payload.ReceivedQty groups[key] = append(groups[key], groupedItem{ item: item, payload: payload, projectFK: projectFK, kandangID: kandangID, totalPrice: totalPrice, }) } 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, purchase, items, expenseDate, expeditionNonstockID, purchase.PoNumber, uint(supplierID)) if err != nil { return err } if err := b.linkExpenseNonstocksToItems(ctx, expenseDetail, items); err != nil { return err } if expenseDetail != nil && expenseDetail.Id != 0 { updatedExpenses[uint64(expenseDetail.Id)] = struct{}{} } } if len(updatedExpenses) > 0 { if err := b.markExpensesUpdated(ctx, updatedExpenses, purchase.CreatedBy); err != nil { return err } } return nil } func (b *expenseBridge) 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 extractIDs(items []entity.PurchaseItem) []uint { result := make([]uint, 0, len(items)) for _, item := range items { if item.Id != 0 { result = append(result, item.Id) } } return result } func (b *expenseBridge) createExpenseViaService( c *fiber.Ctx, purchase *entity.Purchase, items []groupedItem, expenseDate time.Time, expeditionNonstockID uint64, poNumber *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 if kandangID == nil || *kandangID == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Warehouse not connect to kandangs") } costItems := make([]expenseValidation.CostItem, 0, len(items)) for _, gi := range items { note := fmt.Sprintf("purchase_item:%d", gi.payload.PurchaseItemID) price := gi.item.Price if gi.payload.TransportPerItem != nil { price = *gi.payload.TransportPerItem } costItems = append(costItems, expenseValidation.CostItem{ NonstockID: expeditionNonstockID, Quantity: gi.payload.ReceivedQty, Price: price, Notes: note, }) } req := &expenseValidation.Create{ PoNumber: "", TransactionDate: utils.FormatDate(expenseDate), Category: "BOP", SupplierID: uint64(supplierID), ExpenseNonstocks: []expenseValidation.ExpenseNonstock{{ KandangID: func() *uint64 { id := uint64(*kandangID); return &id }(), CostItems: costItems, }}, } if poNumber != nil { req.PoNumber = *poNumber } detail, err := b.expenseSvc.CreateOne(c, req) if err != nil { return nil, err } // Mark approvals up to Finance so latest is Manager Finance action := entity.ApprovalActionApproved actorID := uint(purchase.CreatedBy) if actorID == 0 { actorID = 1 } approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(b.db)) if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepManager, &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 *expenseBridge) linkExpenseNonstocksToItems(ctx context.Context, detail *expenseDto.ExpenseDetailDTO, items []groupedItem) error { if detail == nil || len(items) == 0 { return nil } noteToExpenseNonstock := mapExpenseNotes(detail) if len(noteToExpenseNonstock) == 0 { return nil } for _, gi := range items { expenseNonstockID, ok := noteToExpenseNonstock[gi.payload.PurchaseItemID] if !ok { continue } if err := b.db.WithContext(ctx). Model(&entity.PurchaseItem{}). Where("id = ?", gi.payload.PurchaseItemID). Update("expense_nonstock_id", expenseNonstockID).Error; err != nil { return err } } return nil } func mapExpenseNotes(detail *expenseDto.ExpenseDetailDTO) map[uint]uint64 { result := make(map[uint]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 = "purchase_item:" if !strings.HasPrefix(note, prefix) { continue } idStr := strings.TrimPrefix(note, prefix) var itemID uint if _, err := fmt.Sscanf(idStr, "%d", &itemID); err != nil { continue } result[itemID] = pengajuan.Id } } return result }