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" m "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.Query) ([]entity.Purchase, int64, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.Purchase, error) CreateOne(ctx *fiber.Ctx, req *validation.CreatePurchaseRequest) (*entity.Purchase, error) ApproveStaffPurchase(ctx *fiber.Ctx, id uint, req *validation.ApproveStaffPurchaseRequest) (*entity.Purchase, error) ApproveManagerPurchase(ctx *fiber.Ctx, id uint, req *validation.ApproveManagerPurchaseRequest) (*entity.Purchase, error) ReceiveProducts(ctx *fiber.Ctx, id uint, req *validation.ReceivePurchaseRequest) (*entity.Purchase, error) DeleteItems(ctx *fiber.Ctx, id uint, req *validation.DeletePurchaseItemsRequest) (*entity.Purchase, error) DeletePurchase(ctx *fiber.Ctx, id uint) error } const ( priceTolerance = 0.0001 ) type purchaseService struct { Log *logrus.Logger Validate *validator.Validate PurchaseRepo rPurchase.PurchaseRepository ProductRepo rProduct.ProductRepository WarehouseRepo rWarehouse.WarehouseRepository SupplierRepo rSupplier.SupplierRepository ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository ApprovalSvc commonSvc.ApprovalService ExpenseBridge PurchaseExpenseBridge approvalWorkflow approvalutils.ApprovalWorkflowKey } 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, 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, ApprovalSvc: approvalSvc, ExpenseBridge: expenseBridge, approvalWorkflow: utils.ApprovalWorkflowPurchase, } } func (s *purchaseService) withRelations(db *gorm.DB) *gorm.DB { if db == nil { return db } return db. Preload("Supplier"). Preload("Items", func(db *gorm.DB) *gorm.DB { return db.Order("id ASC") }). Preload("Items.Product"). Preload("Items.Product.Uom"). Preload("Items.Product.ProductCategory"). Preload("Items.Warehouse"). Preload("Items.Product.Flags"). Preload("Items.Warehouse.Area"). Preload("Items.Warehouse.Location"). Preload("Items.ProductWarehouse") } func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Purchase, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err } offset := (params.Page - 1) * params.Limit createdFrom, createdTo, err := parseQueryDates(params.CreatedFrom, params.CreatedTo) if err != nil { return nil, 0, err } purchases, total, err := s.PurchaseRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.SupplierID > 0 { db = db.Where("supplier_id = ?", params.SupplierID) } if createdFrom != nil { db = db.Where("created_at >= ?", *createdFrom) } if createdTo != nil { db = db.Where("created_at < ?", *createdTo) } if params.AreaID > 0 { db = db.Where( `EXISTS ( SELECT 1 FROM purchase_items pi JOIN warehouses w ON w.id = pi.warehouse_id WHERE pi.purchase_id = purchases.id AND w.area_id = ? )`, params.AreaID, ) } if params.LocationID > 0 { db = db.Where( `EXISTS ( SELECT 1 FROM purchase_items pi JOIN warehouses w ON w.id = pi.warehouse_id WHERE pi.purchase_id = purchases.id AND w.location_id = ? )`, params.LocationID, ) } if params.ProductCategoryID > 0 { db = db.Where( `EXISTS ( SELECT 1 FROM purchase_items pi JOIN products p ON p.id = pi.product_id WHERE pi.purchase_id = purchases.id AND p.product_category_id = ? )`, params.ProductCategoryID, ) } return db.Order("created_at DESC").Order("purchases.id DESC") }) if err != nil { s.Log.Errorf("Failed to get purchases: %+v", err) return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchases") } for i := range purchases { if err := s.attachLatestApproval(c.Context(), &purchases[i]); err != nil { s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", purchases[i].Id, err) } } return purchases, total, nil } func (s *purchaseService) GetOne(c *fiber.Ctx, id uint) (*entity.Purchase, error) { purchase, err := s.PurchaseRepo.GetByID(c.Context(), id, s.withRelations) 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(c.Context(), 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 := m.ActorIDFromContext(c) if err != nil { return nil, err } if _, err := s.SupplierRepo.GetByID(c.Context(), 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 uint warehouseId uint 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.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { return db.Preload("Area").Preload("location") }) 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(c.Context(), 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 := uint(item.ProductID) warehouseId := uint(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: uint(req.SupplierID), CreditTerm: creditTerm, DueDate: dueDate, GrandTotal: 0, Notes: req.Notes, CreatedBy: uint(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(c.Context()).Transaction(func(tx *gorm.DB) error { purchaseRepoTx := rPurchase.NewPurchaseRepository(tx) code, err := purchaseRepoTx.NextPrNumber(c.Context(), tx) if err != nil { return err } purchase.PrNumber = code if err := purchaseRepoTx.CreateWithItems(c.Context(), purchase, items); err != nil { return err } actorID := uint(purchase.CreatedBy) if err := s.createPurchaseApproval(c.Context(), tx, purchase.Id, utils.PurchaseStepPengajuan, entity.ApprovalActionCreated, 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.GetByID(c.Context(), purchase.Id, s.withRelations) 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(c.Context(), created); err != nil { s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", created.Id, err) } return created, nil } func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *validation.ApproveStaffPurchaseRequest) (*entity.Purchase, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } action, err := parseApprovalActionInput(req.Action) if err != nil { return nil, err } actorID, err := m.ActorIDFromContext(c) if err != nil { return nil, err } purchase, err := s.PurchaseRepo.GetByID(c.Context(), id, s.withRelations) 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(c.Context(), 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 action == entity.ApprovalActionRejected { return s.rejectAndReload(c, utils.PurchaseStepStaffPurchase, purchase.Id, actorID, req.Notes) } 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 if len(req.Items) == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Items must not be empty for staff approval") } payload, err := s.buildStaffAdjustmentPayload(c.Context(), purchase, req, syncReceiving) if err != nil { return nil, err } transactionErr := s.PurchaseRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { purchaseRepoTx := rPurchase.NewPurchaseRepository(tx) grandTotalUpdated := false if len(payload.PricingUpdates) > 0 { if err := purchaseRepoTx.UpdatePricing(c.Context(), purchase.Id, payload.PricingUpdates, payload.GrandTotal); err != nil { return err } grandTotalUpdated = true } if len(payload.NewItems) > 0 { if err := purchaseRepoTx.CreateItems(c.Context(), purchase.Id, payload.NewItems); err != nil { return err } } if !grandTotalUpdated { if err := purchaseRepoTx.UpdateGrandTotal(c.Context(), purchase.Id, payload.GrandTotal); err != nil { return err } } if isInitialApproval { if err := s.createPurchaseApproval(c.Context(), 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 { if err := s.createPurchaseApproval( c.Context(), tx, purchase.Id, utils.PurchaseStepStaffPurchase, entity.ApprovalActionUpdated, actorID, req.Notes, true, // allowDuplicate = true supaya boleh UPDATED berkali2 ); 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.GetByID(c.Context(), purchase.Id, s.withRelations) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase") } if err := s.attachLatestApproval(c.Context(), 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(c.Context(), purchase.Id, newItems) } return updated, nil } func (s *purchaseService) ApproveManagerPurchase(c *fiber.Ctx, id uint, req *validation.ApproveManagerPurchaseRequest) (*entity.Purchase, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } action, err := parseApprovalActionInput(req.Action) if err != nil { return nil, err } actorID, err := m.ActorIDFromContext(c) if err != nil { return nil, err } purchase, err := s.PurchaseRepo.GetByID(c.Context(), id, s.withRelations) 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(c.Context(), 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") } if action == entity.ApprovalActionRejected { return s.rejectAndReload(c, utils.PurchaseStepManager, purchase.Id, actorID, req.Notes) } now := time.Now().UTC() hasExistingPO := purchase.PoNumber != nil && strings.TrimSpace(*purchase.PoNumber) != "" var generatedNumber string transactionErr := s.PurchaseRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { updateData := map[string]any{} if !hasExistingPO { repoTx := rPurchase.NewPurchaseRepository(tx) code, err := repoTx.NextPoNumber(c.Context(), 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(c.Context(), 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(c.Context(), utils.ApprovalWorkflowPurchase, uint(purchase.Id), filterByStep(utils.PurchaseStepStaffPurchase)) if err != nil { return err } latestManager, err := approvalSvcTx.LatestByTarget(c.Context(), 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(c.Context(), 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.GetByID(c.Context(), purchase.Id, s.withRelations) 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(c.Context(), 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 uint, req *validation.ReceivePurchaseRequest) (*entity.Purchase, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } action, err := parseApprovalActionInput(req.Action) if err != nil { return nil, err } actorID, err := m.ActorIDFromContext(c) if err != nil { return nil, err } purchase, err := s.PurchaseRepo.GetByID(c.Context(), id, s.withRelations) 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(c.Context(), 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") } if action == entity.ApprovalActionApproved && len(req.Items) == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Receiving data must not be empty") } if action == entity.ApprovalActionRejected { if err := s.createPurchaseApproval(c.Context(), nil, purchase.Id, utils.PurchaseStepReceiving, action, actorID, req.Notes, true); err != nil { return nil, err } updated, err := s.PurchaseRepo.GetByID(c.Context(), purchase.Id, s.withRelations) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase") } if err := s.attachLatestApproval(c.Context(), updated); err != nil { s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err) } return updated, nil } itemMap := make(map[uint]*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[uint]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 := action completedAction := entity.ApprovalActionApproved approvalSvc := commonSvc.NewApprovalService( commonRepo.NewApprovalRepository(s.PurchaseRepo.DB()), ) 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( c.Context(), 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(c.Context()).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(c.Context(), 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(c.Context(), purchase.Id, updates); err != nil { return err } if err := pwRepoTx.AdjustQuantities(c.Context(), deltas, nil); err != nil { return err } if err := pwRepoTx.CleanupEmpty(c.Context(), affected); err != nil { return err } if err := s.createPurchaseApproval(c.Context(), tx, purchase.Id, utils.PurchaseStepReceiving, receivingAction, actorID, req.Notes, true); err != nil { return err } if err := s.createPurchaseApproval(c.Context(), 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.GetByID(c.Context(), purchase.Id, s.withRelations) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase ") } if err := s.attachLatestApproval(c.Context(), 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: uint(prep.warehouseID), ReceivedQty: prep.receivedQty, ReceivedDate: &date, } receivingPayloads = append(receivingPayloads, payload) } s.notifyExpenseItemsReceived(c.Context(), purchase.Id, receivingPayloads) return updated, nil } func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint, req *validation.DeletePurchaseItemsRequest) (*entity.Purchase, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } ctx := c.Context() purchase, err := s.PurchaseRepo.GetByID(ctx, id, s.withRelations) 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[uint]struct{}, len(req.ItemIDs)) for _, id := range req.ItemIDs { requested[id] = struct{}{} } toDelete := make([]uint, 0, len(req.ItemIDs)) var remainingTotal float64 for _, item := range purchase.Items { if _, ok := requested[item.Id]; ok { 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.GetByID(ctx, purchase.Id, s.withRelations) 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 uint) error { if id == 0 { return fiber.NewError(fiber.StatusBadRequest, "Invalid purchase id") } ctx := c.Context() purchase, err := s.PurchaseRepo.GetByID(ctx, id, s.withRelations) 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([]uint, 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, uint(id), itemIDs) } return nil } func (s *purchaseService) notifyExpenseItemsCreated(ctx context.Context, purchaseID uint, 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 uint, 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 uint, itemIDs []uint) { 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 (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[uint]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[uint]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[uint]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 } // ? helper 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)) } 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 const queryDateLayout = "2006-01-02" 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 parseApprovalActionInput(raw string) (entity.ApprovalAction, error) { value := strings.ToUpper(strings.TrimSpace(raw)) switch value { case string(entity.ApprovalActionApproved): return entity.ApprovalActionApproved, nil case string(entity.ApprovalActionRejected): return entity.ApprovalActionRejected, nil default: return "", fiber.NewError(fiber.StatusBadRequest, "action must be APPROVED or REJECTED") } } func (s *purchaseService) rejectAndReload( c *fiber.Ctx, step approvalutils.ApprovalStep, purchaseID uint, actorID uint, notes *string, ) (*entity.Purchase, error) { if err := s.createPurchaseApproval(c.Context(), nil, purchaseID, step, entity.ApprovalActionRejected, actorID, notes, false); err != nil { return nil, err } updated, err := s.PurchaseRepo.GetByID(c.Context(), purchaseID, s.withRelations) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase") } if err := s.attachLatestApproval(c.Context(), updated); err != nil { s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err) } return updated, nil } func (s *purchaseService) createPurchaseApproval( ctx context.Context, db *gorm.DB, purchaseID uint, 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 { return fiber.NewError(fiber.StatusBadRequest, "ActorId is invalid for approval") } var svc commonSvc.ApprovalService switch { case db != nil: svc = commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(db)) case s.ApprovalSvc != nil: svc = s.ApprovalSvc case s.PurchaseRepo != nil && s.PurchaseRepo.DB() != nil: svc = commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(s.PurchaseRepo.DB())) } if svc == nil { return fiber.NewError(fiber.StatusInternalServerError, "Approval service not available") } 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 }