package service import ( "context" "errors" "fmt" "math" "strings" "time" 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" authmiddleware "gitlab.com/mbugroup/lti-api.git/internal/middleware" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" rPurchase "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "github.com/sirupsen/logrus" "gorm.io/gorm" ) type PurchaseService interface { GetAll(ctx *fiber.Ctx, params *validation.PurchaseQuery) ([]entity.Purchase, int64, error) GetOne(ctx *fiber.Ctx, id uint64) (*entity.Purchase, error) CreateOne(ctx *fiber.Ctx, req *validation.CreatePurchaseRequest) (*entity.Purchase, error) ApproveStaffPurchase(ctx *fiber.Ctx, id uint64, req *validation.ApproveStaffPurchaseRequest) (*entity.Purchase, error) ApproveManagerPurchase(ctx *fiber.Ctx, id uint64, req *validation.ApproveManagerPurchaseRequest) (*entity.Purchase, error) ReceiveProducts(ctx *fiber.Ctx, id uint64, req *validation.ReceivePurchaseRequest) (*entity.Purchase, error) DeleteItems(ctx *fiber.Ctx, id uint64, req *validation.DeletePurchaseItemsRequest) (*entity.Purchase, error) DeletePurchase(ctx *fiber.Ctx, id uint64) error } const ( priceTolerance = 0.0001 queryDateLayout = "2006-01-02" ) type purchaseService struct { Log *logrus.Logger Validate *validator.Validate PurchaseRepo rPurchase.PurchaseRepository ProductRepo rProduct.ProductRepository WarehouseRepo rWarehouse.WarehouseRepository SupplierRepo rSupplier.SupplierRepository ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository ApprovalRepo commonRepo.ApprovalRepository ApprovalSvc commonSvc.ApprovalService ExpenseBridge PurchaseExpenseBridge } type staffAdjustmentPayload struct { PricingUpdates []rPurchase.PurchasePricingUpdate NewItems []*entity.PurchaseItem GrandTotal float64 } func NewPurchaseService( validate *validator.Validate, purchaseRepo rPurchase.PurchaseRepository, productRepo rProduct.ProductRepository, warehouseRepo rWarehouse.WarehouseRepository, supplierRepo rSupplier.SupplierRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, approvalRepo commonRepo.ApprovalRepository, approvalSvc commonSvc.ApprovalService, expenseBridge PurchaseExpenseBridge, ) PurchaseService { if expenseBridge == nil { expenseBridge = NewNoopPurchaseExpenseBridge() } return &purchaseService{ Log: utils.Log, Validate: validate, PurchaseRepo: purchaseRepo, ProductRepo: productRepo, WarehouseRepo: warehouseRepo, SupplierRepo: supplierRepo, ProductWarehouseRepo: productWarehouseRepo, ApprovalRepo: approvalRepo, ApprovalSvc: approvalSvc, ExpenseBridge: expenseBridge, } } func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.PurchaseQuery) ([]entity.Purchase, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err } limit := params.Limit if limit <= 0 { limit = 10 } page := params.Page if page <= 0 { page = 1 } offset := (page - 1) * limit ctx := c.Context() createdFrom, createdTo, err := parseQueryDates(params.CreatedFrom, params.CreatedTo) if err != nil { return nil, 0, err } statusAction, completedOnly, err := parseApprovalAction(params.Status) if err != nil { return nil, 0, err } filter := &rPurchase.PurchaseListFilter{ SupplierID: params.SupplierID, Search: params.Search, PrNumber: params.PrNumber, CreatedFrom: createdFrom, CreatedTo: createdTo, Status: statusAction, CompletedOnly: completedOnly, } purchases, total, err := s.PurchaseRepo.GetAllWithFilters(ctx, offset, limit, filter) if err != nil { s.Log.Errorf("Failed to get purchases: %+v", err) return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchases") } if err := s.attachLatestApprovals(ctx, purchases); err != nil { s.Log.Warnf("Unable to attach latest approvals to purchases: %+v", err) } return purchases, total, nil } func (s *purchaseService) GetOne(c *fiber.Ctx, id uint64) (*entity.Purchase, error) { if id == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid purchase id") } ctx := c.Context() purchase, err := s.PurchaseRepo.GetByIDWithRelations(ctx, id) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Purchase not found") } s.Log.Errorf("Failed to get purchase: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase") } if err := s.attachLatestApproval(ctx, purchase); err != nil { s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", id, err) } return purchase, nil } func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchaseRequest) (*entity.Purchase, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } actorID, err := actorIDFromContext(c) if err != nil { return nil, err } ctx := c.Context() if _, err := s.SupplierRepo.GetByID(ctx, req.SupplierID, nil); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Supplier not found") } s.Log.Errorf("Failed to get supplier: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get supplier") } type aggregatedItem struct { productId uint64 warehouseId uint64 subQty float64 } if len(req.Items) == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Items must not be empty") } warehouseCache := make(map[uint]*entity.Warehouse) productSupplierCache := make(map[uint]bool) getWarehouse := func(id uint) (*entity.Warehouse, error) { if warehouse, ok := warehouseCache[id]; ok { return warehouse, nil } warehouse, err := s.WarehouseRepo.GetDetailByID(ctx, id) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Warehouse %d not found", id)) } s.Log.Errorf("Failed to get warehouse %d: %+v", id, err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get warehouse") } warehouseCache[id] = warehouse return warehouse, nil } aggregated := make([]*aggregatedItem, 0, len(req.Items)) indexMap := make(map[string]int) for _, item := range req.Items { if _, err := getWarehouse(item.WarehouseID); err != nil { return nil, err } if _, checked := productSupplierCache[item.ProductID]; !checked { linked, err := s.ProductRepo.IsLinkedToSupplier(ctx, item.ProductID, req.SupplierID) if err != nil { s.Log.Errorf("Failed to validate product %d for supplier %d: %+v", item.ProductID, req.SupplierID, err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product for supplier") } if !linked { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Product %d is not linked to supplier %d", item.ProductID, req.SupplierID)) } productSupplierCache[item.ProductID] = true } productId := uint64(item.ProductID) warehouseId := uint64(item.WarehouseID) key := fmt.Sprintf("%d:%d", productId, warehouseId) if idx, ok := indexMap[key]; ok { aggregated[idx].subQty += item.Quantity continue } entry := &aggregatedItem{ productId: productId, warehouseId: warehouseId, subQty: item.Quantity, } aggregated = append(aggregated, entry) indexMap[key] = len(aggregated) - 1 } creditTermValue := req.CreditTerm creditTerm := &creditTermValue dueDateValue := time.Now().UTC().AddDate(0, 0, creditTermValue) dueDate := &dueDateValue purchase := &entity.Purchase{ SupplierId: uint64(req.SupplierID), CreditTerm: creditTerm, DueDate: dueDate, GrandTotal: 0, Notes: req.Notes, CreatedBy: uint64(actorID), } items := make([]*entity.PurchaseItem, 0, len(aggregated)) for _, item := range aggregated { items = append(items, &entity.PurchaseItem{ ProductId: item.productId, WarehouseId: item.warehouseId, SubQty: item.subQty, TotalQty: 0, TotalUsed: 0, Price: 0, TotalPrice: 0, }) } transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { purchaseRepoTx := rPurchase.NewPurchaseRepository(tx) code, err := purchaseRepoTx.NextPrNumber(ctx, tx) if err != nil { return err } purchase.PrNumber = code if err := purchaseRepoTx.CreateWithItems(ctx, purchase, items); err != nil { return err } actorID := uint(purchase.CreatedBy) if actorID == 0 { actorID = 1 } action := entity.ApprovalActionCreated if err := s.createPurchaseApproval(ctx, tx, purchase.Id, utils.PurchaseStepPengajuan, action, actorID, nil, false); err != nil { return err } return nil }) if transactionErr != nil { s.Log.Errorf("Failed to create purchase: %+v", transactionErr) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create purchase") } created, err := s.PurchaseRepo.GetByIDWithRelations(ctx, purchase.Id) if err != nil { s.Log.Errorf("Failed to load created purchase: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase") } if err := s.attachLatestApproval(ctx, created); err != nil { s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", created.Id, err) } s.notifyExpenseItemsCreated(ctx, created.Id, created.Items) return created, nil } func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint64, req *validation.ApproveStaffPurchaseRequest) (*entity.Purchase, error) { return s.processStaffPurchaseApproval(c, id, req, false) } func (s *purchaseService) processStaffPurchaseApproval(c *fiber.Ctx, id uint64, req *validation.ApproveStaffPurchaseRequest, requireStaffApproval bool) (*entity.Purchase, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } actorID, err := actorIDFromContext(c) if err != nil { return nil, err } ctx := c.Context() purchase, err := s.PurchaseRepo.GetByIDWithRelations(ctx, id) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Purchase not found") } return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase") } if err := s.attachLatestApproval(ctx, purchase); err != nil { s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err) } var latestStep uint16 if purchase.LatestApproval != nil { latestStep = purchase.LatestApproval.StepNumber } if requireStaffApproval && latestStep < uint16(utils.PurchaseStepStaffPurchase) { return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase cannot be edited before staff approval") } isInitialApproval := latestStep < uint16(utils.PurchaseStepStaffPurchase) if isInitialApproval && latestStep != uint16(utils.PurchaseStepPengajuan) { return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase is not ready for staff approval") } hasReceivingData := false for _, item := range purchase.Items { if item.TotalQty > 0 || item.TotalUsed > 0 { hasReceivingData = true break } } syncReceiving := !isInitialApproval && hasReceivingData payload, err := s.buildStaffAdjustmentPayload(ctx, purchase, req, syncReceiving) if err != nil { return nil, err } transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { purchaseRepoTx := rPurchase.NewPurchaseRepository(tx) grandTotalUpdated := false if len(payload.PricingUpdates) > 0 { if err := purchaseRepoTx.UpdatePricing(ctx, purchase.Id, payload.PricingUpdates, payload.GrandTotal); err != nil { return err } grandTotalUpdated = true } if len(payload.NewItems) > 0 { if err := purchaseRepoTx.CreateItems(ctx, purchase.Id, payload.NewItems); err != nil { return err } } if !grandTotalUpdated { if err := purchaseRepoTx.UpdateGrandTotal(ctx, purchase.Id, payload.GrandTotal); err != nil { return err } } if isInitialApproval { action := entity.ApprovalActionApproved if err := s.createPurchaseApproval(ctx, tx, purchase.Id, utils.PurchaseStepStaffPurchase, action, actorID, req.Notes, false); err != nil { return err } return nil } if len(payload.PricingUpdates) > 0 || len(payload.NewItems) > 0 { approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) if approvalSvc != nil { latest, err := approvalSvc.LatestByTarget(ctx, utils.ApprovalWorkflowPurchase, uint(purchase.Id), nil) if err != nil { return err } shouldRecordStaffUpdate := latest == nil || latest.StepNumber != uint16(utils.PurchaseStepStaffPurchase) || latest.Action == nil || (latest.Action != nil && *latest.Action != entity.ApprovalActionUpdated) if shouldRecordStaffUpdate { action := entity.ApprovalActionUpdated if _, err := approvalSvc.CreateApproval( ctx, utils.ApprovalWorkflowPurchase, uint(purchase.Id), utils.PurchaseStepStaffPurchase, &action, actorID, req.Notes, ); err != nil { return err } } } } return nil }) if transactionErr != nil { if errors.Is(transactionErr, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Purchase item not found") } if isInitialApproval { s.Log.Errorf("Failed to approve purchase %d: %+v", purchase.Id, transactionErr) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to approve purchase") } return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update purchase pricing") } updated, err := s.PurchaseRepo.GetByIDWithRelations(ctx, purchase.Id) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase") } if err := s.attachLatestApproval(ctx, updated); err != nil { s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err) } if len(payload.NewItems) > 0 { newItems := make([]entity.PurchaseItem, len(payload.NewItems)) for i, item := range payload.NewItems { if item == nil { continue } newItems[i] = *item } s.notifyExpenseItemsCreated(ctx, purchase.Id, newItems) } return updated, nil } func (s *purchaseService) ApproveManagerPurchase(c *fiber.Ctx, id uint64, req *validation.ApproveManagerPurchaseRequest) (*entity.Purchase, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } actorID, err := actorIDFromContext(c) if err != nil { return nil, err } ctx := c.Context() purchase, err := s.PurchaseRepo.GetByIDWithRelations(ctx, id) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Purchase not found") } s.Log.Errorf("Failed to get purchase: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase") } if err := s.attachLatestApproval(ctx, purchase); err != nil { s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err) } if purchase.LatestApproval == nil || purchase.LatestApproval.StepNumber < uint16(utils.PurchaseStepStaffPurchase) { return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase must reach staff purchase step before manager approval") } action := entity.ApprovalActionApproved now := time.Now().UTC() hasExistingPO := purchase.PoNumber != nil && strings.TrimSpace(*purchase.PoNumber) != "" var generatedNumber string transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { updateData := map[string]any{} if !hasExistingPO { repoTx := rPurchase.NewPurchaseRepository(tx) code, err := repoTx.NextPoNumber(ctx, tx) if err != nil { return err } updateData["po_number"] = code updateData["po_date"] = now generatedNumber = code } if len(updateData) > 0 { repoTx := rPurchase.NewPurchaseRepository(tx) if err := repoTx.PatchOne(ctx, uint(purchase.Id), updateData, nil); err != nil { return err } } forceManagerApproval := false approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) if approvalSvcTx != nil { filterByStep := func(step approvalutils.ApprovalStep) func(*gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { return db.Where("step_number = ?", uint16(step)) } } latestStaff, err := approvalSvcTx.LatestByTarget(ctx, utils.ApprovalWorkflowPurchase, uint(purchase.Id), filterByStep(utils.PurchaseStepStaffPurchase)) if err != nil { return err } latestManager, err := approvalSvcTx.LatestByTarget(ctx, utils.ApprovalWorkflowPurchase, uint(purchase.Id), filterByStep(utils.PurchaseStepManager)) if err != nil { return err } if latestStaff != nil && latestManager != nil && latestStaff.ActionAt.After(latestManager.ActionAt) { forceManagerApproval = true } } if err := s.createPurchaseApproval(ctx, tx, purchase.Id, utils.PurchaseStepManager, action, actorID, req.Notes, forceManagerApproval); err != nil { return err } return nil }) if transactionErr != nil { s.Log.Errorf("Failed to approve manager purchase %d: %+v", purchase.Id, transactionErr) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to generate purchase order") } if generatedNumber != "" { purchase.PoNumber = &generatedNumber purchase.PoDate = &now } updated, err := s.PurchaseRepo.GetByIDWithRelations(ctx, purchase.Id) if err != nil { s.Log.Errorf("Failed to load purchase after manager approval: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase") } if err := s.attachLatestApproval(ctx, updated); err != nil { s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err) } return updated, nil } func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint64, req *validation.ReceivePurchaseRequest) (*entity.Purchase, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } actorID, err := actorIDFromContext(c) if err != nil { return nil, err } ctx := c.Context() purchase, err := s.PurchaseRepo.GetByIDWithRelations(ctx, id) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Purchase not found") } return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase ") } if purchase.PoNumber == nil || strings.TrimSpace(*purchase.PoNumber) == "" { return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase order has not been generated") } if err := s.attachLatestApproval(ctx, purchase); err != nil { s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err) } if purchase.LatestApproval == nil || purchase.LatestApproval.StepNumber < uint16(utils.PurchaseStepManager) { return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase must be approved by manager before receiving products") } itemMap := make(map[uint64]*entity.PurchaseItem, len(purchase.Items)) for i := range purchase.Items { itemMap[purchase.Items[i].Id] = &purchase.Items[i] } type preparedReceiving struct { item *entity.PurchaseItem payload validation.ReceivePurchaseItemRequest receivedDate time.Time warehouseID uint overrideWarehouse bool receivedQty float64 } visitedItems := make(map[uint64]struct{}, len(req.Items)) prepared := make([]preparedReceiving, 0, len(req.Items)) for _, payload := range req.Items { item, exists := itemMap[payload.PurchaseItemID] if !exists { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Purchase item %d not found", payload.PurchaseItemID)) } receivedDate, err := time.Parse("2006-01-02", payload.ReceivedDate) if err != nil { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid received_date for item %d", payload.PurchaseItemID)) } receivedDate = receivedDate.UTC() warehouseID := uint(item.WarehouseId) overrideWarehouse := false if payload.WarehouseID != nil && *payload.WarehouseID != 0 { warehouseID = *payload.WarehouseID overrideWarehouse = true } if warehouseID == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Warehouse must be specified for item %d", payload.PurchaseItemID)) } var receivedQty float64 if payload.ReceivedQty != nil { receivedQty = *payload.ReceivedQty } else { receivedQty = item.SubQty } if receivedQty < 0 { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Received quantity for item %d cannot be negative", payload.PurchaseItemID)) } if receivedQty > item.SubQty { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Received quantity for item %d cannot exceed ordered quantity (%.3f)", payload.PurchaseItemID, item.SubQty)) } if _, dup := visitedItems[payload.PurchaseItemID]; dup { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Duplicate receiving data for item %d", payload.PurchaseItemID)) } visitedItems[payload.PurchaseItemID] = struct{}{} prepared = append(prepared, preparedReceiving{ item: item, payload: payload, receivedDate: receivedDate, warehouseID: warehouseID, overrideWarehouse: overrideWarehouse, receivedQty: receivedQty, }) } // Require receiving payload to cover all purchase items so that // receiving cannot be submitted partially item-by-item. if len(visitedItems) != len(itemMap) { return nil, fiber.NewError(fiber.StatusBadRequest, "Receiving data must be provided for all purchase items") } receivingAction := entity.ApprovalActionApproved completedAction := entity.ApprovalActionApproved approvalSvc := s.approvalServiceForDB(nil) if approvalSvc != nil { filterStep := func(step approvalutils.ApprovalStep) func(*gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { return db.Where("step_number = ?", uint16(step)) } } latestReceiving, err := approvalSvc.LatestByTarget(ctx, utils.ApprovalWorkflowPurchase, uint(purchase.Id), filterStep(utils.PurchaseStepReceiving)) if err != nil { s.Log.Errorf("Failed to inspect receiving approval for purchase %d: %+v", purchase.Id, err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to record purchase receiving") } if latestReceiving != nil { receivingAction = entity.ApprovalActionUpdated } } transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { repoTx := rPurchase.NewPurchaseRepository(tx) pwRepoTx := rProductWarehouse.NewProductWarehouseRepository(tx) deltas := make(map[uint]float64) affected := make(map[uint]struct{}) updates := make([]rPurchase.PurchaseReceivingUpdate, 0, len(prepared)) for _, prep := range prepared { item := prep.item var oldPWID *uint if item.ProductWarehouseId != nil { idCopy := uint(*item.ProductWarehouseId) oldPWID = &idCopy } var newPWID *uint clearPW := false if prep.receivedQty > 0 { pwID, err := pwRepoTx.EnsureProductWarehouse(ctx, uint(item.ProductId), prep.warehouseID, purchase.CreatedBy) if err != nil { return err } newPWID = &pwID deltas[pwID] += prep.receivedQty affected[pwID] = struct{}{} } else { clearPW = true } if oldPWID != nil { deltas[*oldPWID] -= item.TotalQty affected[*oldPWID] = struct{}{} } dateCopy := prep.receivedDate qtyCopy := prep.receivedQty update := rPurchase.PurchaseReceivingUpdate{ ItemID: item.Id, ReceivedDate: &dateCopy, TravelNumber: prep.payload.TravelNumber, TravelDocumentPath: prep.payload.TravelDocumentPath, VehicleNumber: prep.payload.VehicleNumber, ReceivedQty: &qtyCopy, ProductWarehouseID: newPWID, ClearProductWarehouse: clearPW, } if prep.overrideWarehouse || uint(item.WarehouseId) != prep.warehouseID { warehouseCopy := prep.warehouseID update.WarehouseID = &warehouseCopy } updates = append(updates, update) } if err := repoTx.UpdateReceivingDetails(ctx, purchase.Id, updates); err != nil { return err } if err := pwRepoTx.AdjustQuantities(ctx, deltas, nil); err != nil { return err } if err := pwRepoTx.CleanupEmpty(ctx, affected); err != nil { return err } if err := s.createPurchaseApproval(ctx, tx, purchase.Id, utils.PurchaseStepReceiving, receivingAction, actorID, req.Notes, true); err != nil { return err } if err := s.createPurchaseApproval(ctx, tx, purchase.Id, utils.PurchaseStepCompleted, completedAction, actorID, req.Notes, true); err != nil { return err } return nil }) if transactionErr != nil { if errors.Is(transactionErr, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Purchase item not found for receiving") } s.Log.Errorf("Failed to save purchase receiving %d: %+v", purchase.Id, transactionErr) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to record purchase receiving") } updated, err := s.PurchaseRepo.GetByIDWithRelations(ctx, purchase.Id) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase ") } if err := s.attachLatestApproval(ctx, updated); err != nil { s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err) } receivingPayloads := make([]ExpenseReceivingPayload, 0, len(prepared)) for _, prep := range prepared { date := prep.receivedDate payload := ExpenseReceivingPayload{ PurchaseItemID: prep.item.Id, ProductID: prep.item.ProductId, WarehouseID: uint64(prep.warehouseID), ReceivedQty: prep.receivedQty, ReceivedDate: &date, } receivingPayloads = append(receivingPayloads, payload) } s.notifyExpenseItemsReceived(ctx, purchase.Id, receivingPayloads) return updated, nil } func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint64, req *validation.DeletePurchaseItemsRequest) (*entity.Purchase, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } ctx := c.Context() purchase, err := s.PurchaseRepo.GetByIDWithRelations(ctx, id) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Purchase not found") } return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase") } if err := s.attachLatestApproval(ctx, purchase); err != nil { s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err) } if purchase.LatestApproval == nil || purchase.LatestApproval.StepNumber == uint16(utils.PurchaseStepPengajuan) { return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase cannot delete items before staff purchase approval") } if len(purchase.Items) == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase has no items to delete") } requested := make(map[uint64]struct{}, len(req.ItemIDs)) for _, id := range req.ItemIDs { requested[id] = struct{}{} } toDelete := make([]uint64, 0, len(req.ItemIDs)) var remainingTotal float64 for _, item := range purchase.Items { if _, ok := requested[item.Id]; ok { if item.TotalQty > 0 || item.TotalUsed > 0 { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot delete item %d because it already has receiving data", item.Id)) } toDelete = append(toDelete, item.Id) } else { remainingTotal += item.TotalPrice } } if len(toDelete) == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Requested items were not found in this purchase") } if len(purchase.Items)-len(toDelete) <= 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase must keep at least one item") } transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { repoTx := rPurchase.NewPurchaseRepository(tx) if err := repoTx.DeleteItems(ctx, purchase.Id, toDelete); err != nil { return err } if err := repoTx.UpdateGrandTotal(ctx, purchase.Id, remainingTotal); err != nil { return err } return nil }) if transactionErr != nil { if errors.Is(transactionErr, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Purchase item not found") } return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to delete purchase items") } if len(toDelete) > 0 { s.notifyExpenseItemsDeleted(ctx, purchase.Id, toDelete) } updated, err := s.PurchaseRepo.GetByIDWithRelations(ctx, purchase.Id) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase") } if err := s.attachLatestApproval(ctx, updated); err != nil { s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err) } return updated, nil } func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint64) error { if id == 0 { return fiber.NewError(fiber.StatusBadRequest, "Invalid purchase id") } ctx := c.Context() purchase, err := s.PurchaseRepo.GetByIDWithRelations(ctx, id) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, "Purchase not found") } return fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase") } itemIDs := make([]uint64, 0, len(purchase.Items)) for _, item := range purchase.Items { itemIDs = append(itemIDs, item.Id) } transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { approvalRepoTx := commonRepo.NewApprovalRepository(tx) if err := approvalRepoTx.DeleteByTarget(ctx, utils.ApprovalWorkflowPurchase.String(), uint(id)); err != nil { return err } purchaseRepoTx := rPurchase.NewPurchaseRepository(tx) if err := purchaseRepoTx.DeleteOne(ctx, uint(id)); err != nil { return err } return nil }) if transactionErr != nil { if errors.Is(transactionErr, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, "Purchase not found") } return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete purchase") } if len(itemIDs) > 0 { s.notifyExpenseItemsDeleted(ctx, uint64(id), itemIDs) } return nil } func (s *purchaseService) createPurchaseApproval( ctx context.Context, db *gorm.DB, purchaseID uint64, step approvalutils.ApprovalStep, action entity.ApprovalAction, actorID uint, notes *string, allowDuplicate bool, ) error { if purchaseID == 0 { return fiber.NewError(fiber.StatusBadRequest, "Purchase is invalid for approval") } if actorID == 0 { actorID = 1 } svc := s.approvalServiceForDB(db) modifier := func(db *gorm.DB) *gorm.DB { return db.Where("step_number = ?", uint16(step)) } latest, err := svc.LatestByTarget(ctx, utils.ApprovalWorkflowPurchase, uint(purchaseID), modifier) if err != nil { return err } if !allowDuplicate && latest != nil && latest.Action != nil && *latest.Action == action { return nil } actionCopy := action _, err = svc.CreateApproval(ctx, utils.ApprovalWorkflowPurchase, uint(purchaseID), step, &actionCopy, actorID, notes) return err } func (s *purchaseService) approvalServiceForDB(db *gorm.DB) commonSvc.ApprovalService { if db != nil { return commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(db)) } if s.ApprovalSvc != nil { return s.ApprovalSvc } return commonSvc.NewApprovalService(s.ApprovalRepo) } func (s *purchaseService) attachLatestApprovals(ctx context.Context, items []entity.Purchase) error { if len(items) == 0 || s.ApprovalSvc == nil { return nil } ids := make([]uint, 0, len(items)) visited := make(map[uint64]struct{}, len(items)) for _, item := range items { if item.Id == 0 { continue } if _, ok := visited[item.Id]; ok { continue } visited[item.Id] = struct{}{} ids = append(ids, uint(item.Id)) } if len(ids) == 0 { return nil } latestMap, err := s.ApprovalSvc.LatestByTargets(ctx, utils.ApprovalWorkflowPurchase, ids, func(db *gorm.DB) *gorm.DB { return db.Preload("ActionUser") }) if err != nil { return err } for i := range items { if items[i].Id == 0 { continue } if approval, ok := latestMap[uint(items[i].Id)]; ok { items[i].LatestApproval = approval } else { items[i].LatestApproval = nil } } return nil } func (s *purchaseService) notifyExpenseItemsCreated(ctx context.Context, purchaseID uint64, items []entity.PurchaseItem) { if s.ExpenseBridge == nil || purchaseID == 0 || len(items) == 0 { return } if err := s.ExpenseBridge.OnItemsCreated(ctx, purchaseID, items); err != nil { s.Log.Warnf("Failed to notify expense bridge for created purchase %d: %+v", purchaseID, err) } } func (s *purchaseService) notifyExpenseItemsReceived(ctx context.Context, purchaseID uint64, payloads []ExpenseReceivingPayload) { if s.ExpenseBridge == nil || purchaseID == 0 || len(payloads) == 0 { return } if err := s.ExpenseBridge.OnItemsReceived(ctx, purchaseID, payloads); err != nil { s.Log.Warnf("Failed to notify expense bridge for received purchase %d: %+v", purchaseID, err) } } func (s *purchaseService) notifyExpenseItemsDeleted(ctx context.Context, purchaseID uint64, itemIDs []uint64) { if s.ExpenseBridge == nil || purchaseID == 0 || len(itemIDs) == 0 { return } if err := s.ExpenseBridge.OnItemsDeleted(ctx, purchaseID, itemIDs); err != nil { s.Log.Warnf("Failed to notify expense bridge for deleted purchase %d: %+v", purchaseID, err) } } func actorIDFromContext(c *fiber.Ctx) (uint, error) { user, ok := authmiddleware.AuthenticatedUser(c) if !ok || user == nil || user.Id == 0 { return 0, fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") } return user.Id, nil } func (s *purchaseService) buildStaffAdjustmentPayload( ctx context.Context, purchase *entity.Purchase, req *validation.ApproveStaffPurchaseRequest, syncReceiving bool, ) (*staffAdjustmentPayload, error) { if len(req.Items) == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Items must not be empty") } requestItems := make(map[uint64]validation.StaffPurchaseApprovalItem, len(req.Items)) newPayloads := make([]validation.StaffPurchaseApprovalItem, 0) for _, item := range req.Items { if item.PurchaseItemID == 0 { newPayloads = append(newPayloads, item) continue } if _, exists := requestItems[item.PurchaseItemID]; exists { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Duplicate pricing data for item %d", item.PurchaseItemID)) } requestItems[item.PurchaseItemID] = item } updates := make([]rPurchase.PurchasePricingUpdate, 0, len(purchase.Items)) var grandTotal float64 existingCombos := make(map[string]struct{}, len(purchase.Items)+len(newPayloads)) for _, item := range purchase.Items { key := fmt.Sprintf("%d:%d", item.ProductId, item.WarehouseId) existingCombos[key] = struct{}{} } allowedWarehouses := make(map[uint64]struct{}, len(purchase.Items)) for _, item := range purchase.Items { allowedWarehouses[item.WarehouseId] = struct{}{} } if len(allowedWarehouses) == 0 && len(newPayloads) > 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "No available warehouses for this purchase") } for _, item := range purchase.Items { data, ok := requestItems[item.Id] if !ok { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Missing pricing data for item %d", item.Id)) } if data.ProductID != 0 && data.ProductID != item.ProductId { return nil, fiber.NewError( fiber.StatusBadRequest, fmt.Sprintf("Cannot change product for item %d. Delete the item and create a new one instead", item.Id), ) } if data.WarehouseID != 0 && data.WarehouseID != item.WarehouseId { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Warehouse mismatch for item %d", item.Id)) } effectiveQty := item.SubQty if data.Qty != nil { if *data.Qty <= 0 { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Quantity for item %d must be greater than 0", item.Id)) } if item.TotalUsed > 0 && *data.Qty < item.TotalUsed { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Quantity for item %d cannot be lower than used amount (%.3f)", item.Id, item.TotalUsed)) } if (item.TotalQty > 0 || item.TotalUsed > 0) && !syncReceiving { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot change quantity for item %d because it already has receiving data", item.Id)) } effectiveQty = *data.Qty } totalPriceInput := data.TotalPrice totalPrice, err := calculateTotalPrice(effectiveQty, data.Price, &totalPriceInput, fmt.Sprintf("item %d", item.Id)) if err != nil { return nil, err } update := rPurchase.PurchasePricingUpdate{ ItemID: item.Id, Price: data.Price, TotalPrice: totalPrice, } if data.Qty != nil { qtyCopy := effectiveQty update.Quantity = &qtyCopy } if syncReceiving { qtyCopy := effectiveQty update.TotalQty = &qtyCopy } updates = append(updates, update) grandTotal += totalPrice delete(requestItems, item.Id) } if len(requestItems) > 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Found pricing data for items that do not belong to this purchase") } productSupplierCache := make(map[uint64]bool) newItems := make([]*entity.PurchaseItem, 0, len(newPayloads)) for _, payload := range newPayloads { if payload.ProductID == 0 || payload.WarehouseID == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Product and warehouse must be provided for new items") } if payload.Qty == nil || *payload.Qty <= 0 { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Quantity must be greater than 0 for product %d", payload.ProductID)) } if _, ok := allowedWarehouses[payload.WarehouseID]; !ok { return nil, fiber.NewError( fiber.StatusBadRequest, fmt.Sprintf("Warehouse %d is not available for this purchase", payload.WarehouseID), ) } key := fmt.Sprintf("%d:%d", payload.ProductID, payload.WarehouseID) if _, exists := existingCombos[key]; exists { return nil, fiber.NewError( fiber.StatusBadRequest, fmt.Sprintf("Product %d in warehouse %d already exists in this purchase", payload.ProductID, payload.WarehouseID), ) } if _, checked := productSupplierCache[payload.ProductID]; !checked { linked, err := s.ProductRepo.IsLinkedToSupplier(ctx, uint(payload.ProductID), uint(purchase.SupplierId)) if err != nil { s.Log.Errorf("Failed to validate product %d for supplier %d: %+v", payload.ProductID, purchase.SupplierId, err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product for supplier") } if !linked { return nil, fiber.NewError( fiber.StatusBadRequest, fmt.Sprintf("Product %d is not linked to supplier %d", payload.ProductID, purchase.SupplierId), ) } productSupplierCache[payload.ProductID] = true } qty := *payload.Qty totalPriceInput := payload.TotalPrice totalPrice, err := calculateTotalPrice(qty, payload.Price, &totalPriceInput, fmt.Sprintf("product %d in warehouse %d", payload.ProductID, payload.WarehouseID)) if err != nil { return nil, err } newItem := &entity.PurchaseItem{ PurchaseId: purchase.Id, ProductId: payload.ProductID, WarehouseId: payload.WarehouseID, SubQty: qty, TotalQty: 0, TotalUsed: 0, Price: payload.Price, TotalPrice: totalPrice, } newItems = append(newItems, newItem) existingCombos[key] = struct{}{} grandTotal += totalPrice } if len(updates) == 0 && len(newItems) == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase has no items to process") } return &staffAdjustmentPayload{ PricingUpdates: updates, NewItems: newItems, GrandTotal: grandTotal, }, nil } func calculateTotalPrice(quantity float64, price float64, provided *float64, ref string) (float64, error) { if quantity <= 0 { return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Quantity for %s must be greater than 0", ref)) } if price <= 0 { return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Price for %s must be greater than 0", ref)) } fmt.Println(price, quantity) expectedTotal := price * quantity if provided == nil { return expectedTotal, nil } if *provided <= 0 { return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Total price for %s must be greater than 0", ref)) } if math.Abs(*provided-expectedTotal) > priceTolerance { return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Total price for %s must equal quantity x price", ref)) } return *provided, nil } func (s *purchaseService) attachLatestApproval(ctx context.Context, item *entity.Purchase) error { if item == nil || item.Id == 0 || s.ApprovalSvc == nil { return nil } latest, err := s.ApprovalSvc.LatestByTarget(ctx, utils.ApprovalWorkflowPurchase, uint(item.Id), func(db *gorm.DB) *gorm.DB { return db.Preload("ActionUser") }) if err != nil { return err } item.LatestApproval = latest return nil } func parseQueryDates(fromStr, toStr string) (*time.Time, *time.Time, error) { var fromPtr *time.Time var toPtr *time.Time if strings.TrimSpace(fromStr) != "" { parsed, err := time.Parse(queryDateLayout, fromStr) if err != nil { return nil, nil, fiber.NewError(fiber.StatusBadRequest, "created_from must use format YYYY-MM-DD") } fromValue := parsed fromPtr = &fromValue } if strings.TrimSpace(toStr) != "" { parsed, err := time.Parse(queryDateLayout, toStr) if err != nil { return nil, nil, fiber.NewError(fiber.StatusBadRequest, "created_to must use format YYYY-MM-DD") } toValue := parsed.AddDate(0, 0, 1) toPtr = &toValue } if fromPtr != nil && toPtr != nil && fromPtr.After(*toPtr) { return nil, nil, fiber.NewError(fiber.StatusBadRequest, "created_from must be earlier than created_to") } return fromPtr, toPtr, nil } func parseApprovalAction(status string) (*entity.ApprovalAction, bool, error) { value := strings.TrimSpace(strings.ToUpper(status)) if value == "" { return nil, false, nil } if value == "COMPLETED" { return nil, true, nil } action := entity.ApprovalAction(value) switch action { case entity.ApprovalActionApproved, entity.ApprovalActionRejected, entity.ApprovalActionCreated, entity.ApprovalActionUpdated: return &action, false, nil default: return nil, false, fiber.NewError(fiber.StatusBadRequest, "Invalid status filter") } }