package service import ( "context" "errors" "fmt" "math" "mime/multipart" "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" projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rPurchase "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/validations" rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "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 ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository ApprovalSvc commonSvc.ApprovalService ExpenseBridge PurchaseExpenseBridge FifoStockV2Svc commonSvc.FifoStockV2Service DocumentSvc commonSvc.DocumentService approvalWorkflow approvalutils.ApprovalWorkflowKey } type staffAdjustmentPayload struct { PricingUpdates []rPurchase.PurchasePricingUpdate NewItems []*entity.PurchaseItem } func NewPurchaseService( validate *validator.Validate, purchaseRepo rPurchase.PurchaseRepository, productRepo rProduct.ProductRepository, warehouseRepo rWarehouse.WarehouseRepository, supplierRepo rSupplier.SupplierRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, approvalSvc commonSvc.ApprovalService, expenseBridge PurchaseExpenseBridge, fifoStockV2Svc commonSvc.FifoStockV2Service, documentSvc commonSvc.DocumentService, ) PurchaseService { return &purchaseService{ Log: utils.Log, Validate: validate, PurchaseRepo: purchaseRepo, ProductRepo: productRepo, WarehouseRepo: warehouseRepo, SupplierRepo: supplierRepo, ProductWarehouseRepo: productWarehouseRepo, ProjectFlockKandangRepo: projectFlockKandangRepo, ApprovalSvc: approvalSvc, ExpenseBridge: expenseBridge, FifoStockV2Svc: fifoStockV2Svc, DocumentSvc: documentSvc, approvalWorkflow: utils.ApprovalWorkflowPurchase, } } func (s *purchaseService) withRelations(db *gorm.DB) *gorm.DB { if db == nil { return db } return db. Preload("Supplier"). Preload("CreatedUser"). 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"). Preload("Items.ExpenseNonstock"). Preload("Items.ExpenseNonstock.Expense"). Preload("Items.ExpenseNonstock.Expense.Supplier") } 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 } scope, err := m.ResolveLocationScope(c, s.PurchaseRepo.DB()) if err != nil { return nil, 0, err } offset := (params.Page - 1) * params.Limit createdFrom, createdTo, err := utils.ParseDateRangeForQuery(params.CreatedFrom, params.CreatedTo) if err != nil { return nil, 0, utils.BadRequest(err.Error()) } purchases, total, err := s.PurchaseRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) db = db.Where("purchases.deleted_at IS NULL") 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 scope.Restrict { if len(scope.IDs) == 0 { return db.Where("1 = 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 IN ? )`, scope.IDs, ) } 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, utils.Internal("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) { scope, err := m.ResolveLocationScope(c, s.PurchaseRepo.DB()) if err != nil { return nil, err } purchase, err := s.PurchaseRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if scope.Restrict { if len(scope.IDs) == 0 { return db.Where("1 = 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 IN ? )`, scope.IDs, ) } return db }) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, utils.NotFound("Purchase not found") } s.Log.Errorf("Failed to get purchase %d: %+v", id, err) return nil, utils.Internal("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) } if len(purchase.Items) > 0 { lockedIDs, err := s.resolveChickinLockedItemIDs(c.Context(), s.PurchaseRepo.DB(), purchase.Items) if err != nil { return nil, err } for i := range purchase.Items { if _, ok := lockedIDs[purchase.Items[i].Id]; ok { purchase.Items[i].HasChickin = true } } } s.applyTravelDocumentURLs(c.Context(), purchase) 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, utils.NotFound("Supplier not found") } s.Log.Errorf("Failed to get supplier: %+v", err) return nil, utils.Internal("Failed to get supplier") } type aggregatedItem struct { productId uint warehouseId uint subQty float64 pfkID *uint } if len(req.Items) == 0 { return nil, utils.BadRequest("Items must not be empty") } warehouseCache := make(map[uint]*entity.Warehouse) productSupplierCache := make(map[uint]bool) getWarehouse := func(id uint) (*entity.Warehouse, *uint, error) { if warehouse, ok := warehouseCache[id]; ok { return warehouse, nil, 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, nil, utils.NotFound(fmt.Sprintf("Warehouse %d not found", id)) } s.Log.Errorf("Failed to get warehouse %d: %+v", id, err) return nil, nil, utils.Internal("Failed to get warehouse") } var pfkID *uint isKandang := strings.EqualFold(strings.TrimSpace(warehouse.Type), "KANDANG") if isKandang { if warehouse.KandangId == nil || *warehouse.KandangId == 0 { return nil, nil, utils.BadRequest(fmt.Sprintf("%s is not linked to a kandang", warehouse.Name)) } if s.ProjectFlockKandangRepo != nil { if pfk, err := s.ProjectFlockKandangRepo.GetActiveByKandangID(c.Context(), uint(*warehouse.KandangId)); err == nil && pfk != nil { if pfk.ClosedAt != nil { return nil, nil, utils.BadRequest("Project sudah closing") } idCopy := uint(pfk.Id) pfkID = &idCopy } else if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil, utils.BadRequest(fmt.Sprintf("%s has no active project flock", warehouse.Name)) } else if err != nil { s.Log.Errorf("Failed to validate project flock for warehouse %d: %+v", id, err) return nil, nil, utils.Internal("Failed to validate project flock") } } } warehouseCache[id] = warehouse return warehouse, pfkID, nil } aggregated := make([]*aggregatedItem, 0, len(req.Items)) indexMap := make(map[string]int) for _, item := range req.Items { _, pfkID, err := getWarehouse(item.WarehouseID) if 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, utils.Internal("Failed to validate product for supplier") } if !linked { return nil, utils.BadRequest(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, pfkID: pfkID, } aggregated = append(aggregated, entry) indexMap[key] = len(aggregated) - 1 } var dueDate *time.Time now := time.Now().UTC() d := now.AddDate(0, 0, req.CreditTerm) dueDate = &d purchase := &entity.Purchase{ SupplierId: uint(req.SupplierID), CreditTerm: req.CreditTerm, DueDate: dueDate, Notes: req.Notes, CreatedBy: uint(actorID), } items := make([]*entity.PurchaseItem, 0, len(aggregated)) emptyVehicle := "" for _, item := range aggregated { items = append(items, &entity.PurchaseItem{ ProductId: item.productId, WarehouseId: item.warehouseId, ProjectFlockKandangId: item.pfkID, SubQty: item.subQty, TotalQty: 0, TotalUsed: 0, Price: 0, TotalPrice: 0, VehicleNumber: &emptyVehicle, }) } 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 } if err := purchaseRepoTx.BackfillProjectFlockKandang(c.Context(), purchase.Id); 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, utils.Internal("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, utils.Internal("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 } ctx := c.Context() 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.loadPurchase(ctx, id) if err != nil { return nil, err } if action == entity.ApprovalActionApproved { if err := s.ensureProjectFlockNotClosedForPurchase(ctx, purchase); err != nil { return nil, 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, utils.BadRequest("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 action == entity.ApprovalActionApproved && len(req.Items) == 0 { return nil, utils.BadRequest("Items must not be empty for staff approval") } if action == entity.ApprovalActionApproved { itemByID := make(map[uint]entity.PurchaseItem, len(purchase.Items)) for i := range purchase.Items { if purchase.Items[i].Id == 0 { continue } itemByID[purchase.Items[i].Id] = purchase.Items[i] } lockedIDs, err := s.resolveChickinLockedItemIDs(ctx, s.PurchaseRepo.DB(), purchase.Items) if err != nil { return nil, err } if len(lockedIDs) > 0 { for _, payload := range req.Items { if payload.PurchaseItemID == 0 || payload.Qty == nil { continue } if _, locked := lockedIDs[payload.PurchaseItemID]; !locked { continue } item, ok := itemByID[payload.PurchaseItemID] if !ok { continue } if *payload.Qty != item.SubQty { return nil, utils.BadRequest("Purchase sudah chickin, qty tidak bisa diubah") } } } } 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) if len(payload.PricingUpdates) > 0 { if err := purchaseRepoTx.UpdatePricing(c.Context(), purchase.Id, payload.PricingUpdates); err != nil { return err } } if len(payload.NewItems) > 0 { if err := purchaseRepoTx.CreateItems(c.Context(), purchase.Id, payload.NewItems); 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, utils.NotFound("Purchase item not found") } if isInitialApproval { s.Log.Errorf("Failed to approve purchase %d: %+v", purchase.Id, transactionErr) return nil, utils.Internal("Failed to approve purchase") } return nil, utils.Internal("Failed to update purchase pricing") } updated, err := s.PurchaseRepo.GetByID(c.Context(), purchase.Id, s.withRelations) if err != nil { return nil, utils.Internal("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) 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.loadPurchase(c.Context(), id) if err != nil { return nil, err } if action == entity.ApprovalActionApproved { if err := s.ensureProjectFlockNotClosedForPurchase(c.Context(), purchase); err != nil { return nil, err } } if purchase.LatestApproval == nil || purchase.LatestApproval.StepNumber < uint16(utils.PurchaseStepStaffPurchase) { return nil, utils.BadRequest("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, utils.Internal("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, utils.Internal("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 } ctx := c.Context() 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.loadPurchase(ctx, id) if err != nil { return nil, err } if purchase.PoNumber == nil || strings.TrimSpace(*purchase.PoNumber) == "" { return nil, utils.BadRequest("Purchase order has not been generated") } if purchase.LatestApproval == nil || purchase.LatestApproval.StepNumber < uint16(utils.PurchaseStepManager) { return nil, utils.BadRequest("Purchase must be approved by manager before receiving products") } if action == entity.ApprovalActionApproved { if err := s.ensureProjectFlockNotClosedForPurchase(ctx, purchase); err != nil { return nil, err } } if action == entity.ApprovalActionApproved && len(req.Items) == 0 { return nil, utils.BadRequest("Receiving data must not be empty") } if action == entity.ApprovalActionRejected { if err := s.createPurchaseApproval(ctx, nil, purchase.Id, utils.PurchaseStepReceiving, action, actorID, req.Notes, true); err != nil { return nil, err } updated, err := s.PurchaseRepo.GetByID(ctx, purchase.Id, s.withRelations) if err != nil { return nil, utils.Internal("Failed to load purchase") } if err := s.attachLatestApproval(ctx, updated); err != nil { s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err) } return updated, nil } if action == entity.ApprovalActionApproved && len(req.TravelDocuments) > 0 { if len(req.TravelDocuments) > len(req.Items) { return nil, utils.BadRequest("Travel documents exceed total receiving items") } for idx, file := range req.TravelDocuments { if file == nil { continue } if idx >= len(req.Items) { break } itemID := req.Items[idx].PurchaseItemID if itemID == 0 { return nil, utils.BadRequest("Purchase item id is required for travel document upload") } uploadedURL, err := s.uploadTravelDocument(ctx, actorID, itemID, file) if err != nil { s.Log.Errorf("Failed to upload travel document for item %d: %+v", itemID, err) return nil, utils.Internal("Failed to upload travel document") } req.Items[idx].TravelDocumentPath = &uploadedURL } } lockedIDs := map[uint]struct{}{} if action == entity.ApprovalActionApproved { itemByID := make(map[uint]entity.PurchaseItem, len(purchase.Items)) for i := range purchase.Items { if purchase.Items[i].Id == 0 { continue } itemByID[purchase.Items[i].Id] = purchase.Items[i] } locked, err := s.resolveChickinLockedItemIDs(ctx, s.PurchaseRepo.DB(), purchase.Items) if err != nil { return nil, err } if len(locked) > 0 { for id := range locked { lockedIDs[id] = struct{}{} } for _, payload := range req.Items { if _, used := lockedIDs[payload.PurchaseItemID]; !used { continue } item, ok := itemByID[payload.PurchaseItemID] if !ok { continue } receivedQty := item.SubQty if payload.ReceivedQty != nil { receivedQty = *payload.ReceivedQty } if receivedQty != item.TotalQty { return nil, utils.BadRequest("Purchase sudah chickin, qty penerimaan tidak bisa diubah") } } } } 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 supplierID uint transportPerItem *float64 vehicleNumber *string overrideWarehouse bool receivedQty float64 } visitedItems := make(map[uint]struct{}, len(req.Items)) prepared := make([]preparedReceiving, 0, len(req.Items)) var earliestReceived *time.Time for _, payload := range req.Items { item, exists := itemMap[payload.PurchaseItemID] if !exists { return nil, utils.BadRequest(fmt.Sprintf("Purchase item %d not found", payload.PurchaseItemID)) } receivedDate, err := utils.ParseDateString(payload.ReceivedDate) if err != nil { return nil, utils.BadRequest(fmt.Sprintf("Invalid received_date for item %d", payload.PurchaseItemID)) } receivedDate = receivedDate.UTC() if earliestReceived == nil || receivedDate.Before(*earliestReceived) { copy := receivedDate earliestReceived = © } warehouseID := uint(item.WarehouseId) overrideWarehouse := false if payload.WarehouseID != nil && *payload.WarehouseID != 0 { warehouseID = *payload.WarehouseID overrideWarehouse = true } if warehouseID == 0 { return nil, utils.BadRequest(fmt.Sprintf("Warehouse must be specified for item %d", payload.PurchaseItemID)) } if payload.WarehouseID != nil && uint(item.WarehouseId) != warehouseID { return nil, utils.BadRequest("Receiving does not allow changing warehouse") } var receivedQty float64 if payload.ReceivedQty != nil { receivedQty = *payload.ReceivedQty } else { receivedQty = item.SubQty } if receivedQty < 0 { return nil, utils.BadRequest(fmt.Sprintf("Received quantity for item %d cannot be negative", payload.PurchaseItemID)) } if receivedQty > item.SubQty { return nil, utils.BadRequest(fmt.Sprintf("Received quantity for item %d cannot exceed ordered quantity (%.3f)", payload.PurchaseItemID, item.SubQty)) } if receivedQty < item.TotalUsed && isReceivingBelowUsedBlocked(item, lockedIDs) { return nil, utils.BadRequest(fmt.Sprintf("Received quantity for item %d cannot be lower than used amount (%.3f)", payload.PurchaseItemID, item.TotalUsed)) } if _, dup := visitedItems[payload.PurchaseItemID]; dup { return nil, utils.BadRequest(fmt.Sprintf("Duplicate receiving data for item %d", payload.PurchaseItemID)) } visitedItems[payload.PurchaseItemID] = struct{}{} var supplierID uint if payload.ExpeditionVendorID != nil && *payload.ExpeditionVendorID != 0 { supplierID = *payload.ExpeditionVendorID } var transportPerItem *float64 if payload.TransportPerItem != nil { if *payload.TransportPerItem < 0 { return nil, utils.BadRequest(fmt.Sprintf("transport_per_item for item %d cannot be negative", payload.PurchaseItemID)) } val := *payload.TransportPerItem transportPerItem = &val } var vehicleNumber *string if payload.VehicleNumber != nil && strings.TrimSpace(*payload.VehicleNumber) != "" { val := strings.TrimSpace(*payload.VehicleNumber) vehicleNumber = &val } else if item.VehicleNumber != nil && strings.TrimSpace(*item.VehicleNumber) != "" { val := strings.TrimSpace(*item.VehicleNumber) vehicleNumber = &val } prepared = append(prepared, preparedReceiving{ item: item, payload: payload, receivedDate: receivedDate, warehouseID: warehouseID, supplierID: supplierID, transportPerItem: transportPerItem, vehicleNumber: vehicleNumber, 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, utils.BadRequest("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, utils.Internal("Failed to record purchase receiving") } if latestReceiving != nil { receivingAction = entity.ApprovalActionUpdated } } noteSuffix := "receive" if receivingAction == entity.ApprovalActionUpdated { noteSuffix = "edit-receive" } receiveNote := fmt.Sprintf("%s#%s", strings.TrimSpace(*purchase.PoNumber), noteSuffix) transactionErr := s.PurchaseRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { repoTx := rPurchase.NewPurchaseRepository(tx) pwRepoTx := rProductWarehouse.NewProductWarehouseRepository(tx) stockLogRepoTx := rStockLogs.NewStockLogRepository(tx) affected := make(map[uint]struct{}) updates := make([]rPurchase.PurchaseReceivingUpdate, 0, len(prepared)) priceUpdates := make([]rPurchase.PurchasePricingUpdate, 0, len(prepared)) totalQtyDeltas := make(map[uint]float64) reflowAsOfByPW := make(map[uint]time.Time) logEntries := make([]struct { itemID uint pwID uint delta float64 }, 0, len(prepared)) for _, prep := range prepared { item := prep.item var newPWID *uint // Always ensure PW after receiving so linkage stays stable. pwID, err := pwRepoTx.EnsureProductWarehouse( c.Context(), uint(item.ProductId), prep.warehouseID, item.ProjectFlockKandangId, purchase.CreatedBy, ) if err != nil { return err } // Safety: ensure the PW we got matches the purchase item product. if pwDetail, err := pwRepoTx.GetDetailByID(c.Context(), pwID); err != nil { return err } else if pwDetail.ProductId != uint(item.ProductId) { return fiber.NewError( fiber.StatusBadRequest, fmt.Sprintf("Product warehouse %d belongs to product %d, not purchase item product %d", pwID, pwDetail.ProductId, item.ProductId), ) } newPWID = &pwID deltaQty := prep.receivedQty - item.TotalQty if newPWID != nil && deltaQty != 0 { logEntries = append(logEntries, struct { itemID uint pwID uint delta float64 }{itemID: item.Id, pwID: *newPWID, delta: deltaQty}) } if newPWID != nil { assignEarliestAsOf(reflowAsOfByPW, *newPWID, prep.receivedDate.UTC()) } if deltaQty != 0 { totalQtyDeltas[item.Id] += deltaQty } if deltaQty < 0 && newPWID != nil { affected[*newPWID] = 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: false, } if prep.overrideWarehouse || uint(item.WarehouseId) != prep.warehouseID { warehouseCopy := prep.warehouseID update.WarehouseID = &warehouseCopy } updates = append(updates, update) if prep.receivedQty >= 0 { priceUpdates = append(priceUpdates, rPurchase.PurchasePricingUpdate{ ItemID: item.Id, Price: item.Price, TotalPrice: item.Price * prep.receivedQty, }) } } if err := repoTx.UpdateReceivingDetails(c.Context(), purchase.Id, updates); err != nil { return err } if len(priceUpdates) > 0 { if err := repoTx.UpdatePricing(c.Context(), purchase.Id, priceUpdates); err != nil { return err } } if len(totalQtyDeltas) > 0 { for itemID, delta := range totalQtyDeltas { if delta == 0 { continue } if err := tx.Model(&entity.PurchaseItem{}). Where("purchase_id = ? AND id = ?", purchase.Id, itemID). Update("total_qty", gorm.Expr("COALESCE(total_qty,0) + ?", delta)).Error; err != nil { return err } } } // Update due_date based on earliest received date when receiving approved. if earliestReceived != nil { due := earliestReceived.AddDate(0, 0, purchase.CreditTerm) if err := tx.Model(&entity.Purchase{}). Where("id = ?", purchase.Id). Update("due_date", due).Error; err != nil { return err } } if len(reflowAsOfByPW) > 0 { if s.FifoStockV2Svc == nil { return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available") } for pwID, asOf := range reflowAsOfByPW { asOfCopy := asOf if err := reflowPurchaseScope(c.Context(), s.FifoStockV2Svc, tx, pwID, &asOfCopy); err != nil { return err } } } if len(logEntries) > 0 { logs := make([]*entity.StockLog, 0, len(logEntries)) for _, entry := range logEntries { if entry.pwID == 0 || entry.delta == 0 { continue } log := &entity.StockLog{ ProductWarehouseId: entry.pwID, CreatedBy: actorID, LoggableType: string(utils.StockLogTypePurchase), LoggableId: purchase.Id, Notes: receiveNote, } stockLogs, err := stockLogRepoTx.GetByProductWarehouse(ctx, entry.pwID, 1) if err != nil { s.Log.Errorf("Failed to get stock logs: %+v", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") } if len(stockLogs) > 0 { latestStockLog := stockLogs[0] log.Stock = latestStockLog.Stock } else { log.Stock = 0 } if entry.delta > 0 { log.Increase = entry.delta log.Stock += log.Increase } else { log.Decrease = -entry.delta log.Stock -= log.Decrease } logs = append(logs, log) } if len(logs) > 0 { if err := stockLogRepoTx.CreateMany(c.Context(), logs, nil); err != nil { return err } } } if len(affected) > 0 { if err := pwRepoTx.CleanupEmpty(c.Context(), affected); err != nil { return err } } return nil }) if transactionErr != nil { if errors.Is(transactionErr, gorm.ErrRecordNotFound) { return nil, utils.NotFound("Purchase item not found for receiving") } s.Log.Errorf("Failed to save purchase receiving %d: %+v", purchase.Id, transactionErr) return nil, utils.Internal("Failed to record purchase receiving") } updated, err := s.PurchaseRepo.GetByID(c.Context(), purchase.Id, s.withRelations) if err != nil { return nil, utils.Internal("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), SupplierID: prep.supplierID, TransportPerItem: prep.transportPerItem, ReceivedQty: prep.receivedQty, ReceivedDate: &date, VehicleNumber: prep.vehicleNumber, } receivingPayloads = append(receivingPayloads, payload) } if err := s.notifyExpenseItemsReceived(c, purchase.Id, receivingPayloads); err != nil { s.Log.Errorf("Failed to sync expense for purchase %d: %+v", purchase.Id, err) if fe, ok := err.(*fiber.Error); ok { return nil, fe } return nil, utils.Internal("Failed to sync expense") } // Create approvals only after expense sync succeeds if err := s.createPurchaseApproval(c.Context(), nil, purchase.Id, utils.PurchaseStepReceiving, receivingAction, actorID, req.Notes, true); err != nil { return nil, err } if err := s.createPurchaseApproval(c.Context(), nil, purchase.Id, utils.PurchaseStepCompleted, completedAction, actorID, req.Notes, true); err != nil { return nil, err } return updated, nil } func (s *purchaseService) uploadTravelDocument( ctx context.Context, actorID uint, itemID uint, file *multipart.FileHeader, ) (string, error) { if file == nil { return "", errors.New("travel document file is required") } if s.DocumentSvc == nil { return "", errors.New("document service not available") } documents, err := s.DocumentSvc.ListByTarget(ctx, string(utils.DocumentableTypePurchaseItem), uint64(itemID)) if err != nil { return "", err } if len(documents) > 0 { var ids []uint for _, doc := range documents { if doc.Type == string(utils.DocumentTypePurchaseTravel) { ids = append(ids, doc.Id) } } if err := s.DocumentSvc.DeleteDocuments(ctx, ids, true); err != nil { return "", err } } documentFiles := []commonSvc.DocumentFile{{ File: file, Type: string(utils.DocumentTypePurchaseTravel), }} results, err := s.DocumentSvc.UploadDocuments(ctx, commonSvc.DocumentUploadRequest{ DocumentableType: string(utils.DocumentableTypePurchaseItem), DocumentableID: uint64(itemID), CreatedBy: &actorID, Files: documentFiles, }) if err != nil { return "", err } if len(results) == 0 { return "", errors.New("upload result is empty") } return results[0].Document.Path, 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, utils.NotFound("Purchase not found") } return nil, utils.Internal("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 err := s.ensureProjectFlockNotClosedForPurchase(ctx, purchase); err != nil { return nil, err } if purchase.LatestApproval == nil || purchase.LatestApproval.StepNumber == uint16(utils.PurchaseStepPengajuan) { return nil, utils.BadRequest("Purchase cannot delete items before staff purchase approval") } if len(purchase.Items) == 0 { return nil, utils.BadRequest("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, utils.BadRequest("Requested items were not found in this purchase") } toDeleteSet := make(map[uint]struct{}, len(toDelete)) for _, id := range toDelete { toDeleteSet[id] = struct{}{} } itemsToDelete := make([]entity.PurchaseItem, 0, len(toDelete)) for _, item := range purchase.Items { if _, ok := toDeleteSet[item.Id]; ok { itemsToDelete = append(itemsToDelete, item) } } if len(purchase.Items)-len(toDelete) <= 0 { return nil, utils.BadRequest("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 } return nil }) if transactionErr != nil { if errors.Is(transactionErr, gorm.ErrRecordNotFound) { return nil, utils.NotFound("Purchase item not found") } return nil, utils.Internal("Failed to delete purchase items") } if err := s.deletePurchaseItemDocuments(ctx, itemsToDelete); err != nil { return nil, utils.Internal("Failed to delete purchase documents") } if len(itemsToDelete) > 0 { if err := s.notifyExpenseItemsDeleted(ctx, purchase.Id, itemsToDelete); err != nil { s.Log.Errorf("Failed to sync expense deletion for purchase %d: %+v", purchase.Id, err) if fe, ok := err.(*fiber.Error); ok { return nil, fe } return nil, utils.Internal("Failed to sync expense") } } updated, err := s.PurchaseRepo.GetByID(ctx, purchase.Id, s.withRelations) if err != nil { return nil, utils.Internal("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 utils.BadRequest("Invalid purchase id") } ctx := c.Context() actorID, err := m.ActorIDFromContext(c) if err != nil { return err } purchase, err := s.loadPurchase(ctx, id) if err != nil { return err } if err := s.ensureProjectFlockNotClosedForPurchase(ctx, purchase); err != nil { return err } itemsToDelete := make([]entity.PurchaseItem, len(purchase.Items)) for i, item := range purchase.Items { itemsToDelete[i] = item } note := fmt.Sprintf("Purchase-Delete#%d", purchase.Id) if purchase.PoNumber != nil && strings.TrimSpace(*purchase.PoNumber) != "" { note = fmt.Sprintf("%s#delete", strings.TrimSpace(*purchase.PoNumber)) } transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { lockedIDs, err := s.resolveChickinLockedItemIDs(ctx, tx, itemsToDelete) if err != nil { return err } if len(lockedIDs) > 0 { return utils.BadRequest("Purchase already chickin, failed to delete purchase") } if err := s.rollbackPurchaseStock(ctx, tx, itemsToDelete, note, actorID); err != nil { return err } 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 { var fe *fiber.Error if errors.As(transactionErr, &fe) { return fe } if errors.Is(transactionErr, gorm.ErrRecordNotFound) { return utils.NotFound("Purchase not found") } return utils.Internal("Failed to delete purchase") } if err := s.deletePurchaseItemDocuments(ctx, itemsToDelete); err != nil { return utils.Internal("Failed to delete purchase documents") } if len(itemsToDelete) > 0 { if err := s.notifyExpenseItemsDeleted(ctx, uint(id), itemsToDelete); err != nil { s.Log.Errorf("Failed to sync expense deletion for purchase %d: %+v", id, err) if fe, ok := err.(*fiber.Error); ok { return fe } return utils.Internal("Failed to sync expense") } } return nil } func (s *purchaseService) rollbackPurchaseStock(ctx context.Context, tx *gorm.DB, items []entity.PurchaseItem, note string, actorID uint) error { if len(items) == 0 { return nil } stockLogRepoTx := rStockLogs.NewStockLogRepository(tx) affected := make(map[uint]struct{}) reflowAsOfByPW := make(map[uint]time.Time) logEntries := make([]struct { pwID uint qty float64 }, 0, len(items)) for _, item := range items { if item.ProductWarehouseId == nil || *item.ProductWarehouseId == 0 { continue } if item.TotalQty == 0 { continue } pwID := *item.ProductWarehouseId qty := item.TotalQty if err := tx.WithContext(ctx). Model(&entity.PurchaseItem{}). Where("id = ?", item.Id). Update("total_qty", 0).Error; err != nil { return err } affected[pwID] = struct{}{} if item.ReceivedDate != nil { assignEarliestAsOf(reflowAsOfByPW, pwID, item.ReceivedDate.UTC()) } else { assignEarliestAsOf(reflowAsOfByPW, pwID, time.Now().UTC()) } logEntries = append(logEntries, struct { pwID uint qty float64 }{pwID: pwID, qty: qty}) } if len(reflowAsOfByPW) > 0 { if s.FifoStockV2Svc == nil { return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available") } for pwID, asOf := range reflowAsOfByPW { asOfCopy := asOf if err := reflowPurchaseScope(ctx, s.FifoStockV2Svc, tx, pwID, &asOfCopy); err != nil { return err } } } if len(affected) > 0 { if err := rProductWarehouse.NewProductWarehouseRepository(tx).CleanupEmpty(ctx, affected); err != nil { return err } } if strings.TrimSpace(note) != "" && actorID != 0 && len(logEntries) > 0 { logs := make([]*entity.StockLog, 0, len(logEntries)) for _, entry := range logEntries { if entry.pwID == 0 || entry.qty <= 0 { continue } logs = append(logs, &entity.StockLog{ ProductWarehouseId: entry.pwID, CreatedBy: actorID, Decrease: entry.qty, LoggableType: string(utils.StockLogTypePurchase), LoggableId: items[0].PurchaseId, Notes: note, }) } if len(logs) > 0 { if err := stockLogRepoTx.CreateMany(ctx, logs, nil); err != nil { return err } } } return 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 utils.BadRequest("Purchase is invalid for approval") } if actorID == 0 { actorID = 1 } svc := s.approvalServiceForDB(db) if svc == nil { return utils.Internal("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 } 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 } if s.PurchaseRepo != nil && s.PurchaseRepo.DB() != nil { return commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(s.PurchaseRepo.DB())) } return nil } func (s *purchaseService) notifyExpenseItemsReceived(c *fiber.Ctx, purchaseID uint, payloads []ExpenseReceivingPayload) error { if s.ExpenseBridge == nil || purchaseID == 0 || len(payloads) == 0 { return nil } return s.ExpenseBridge.OnItemsReceived(c, purchaseID, payloads) } func (s *purchaseService) notifyExpenseItemsDeleted(ctx context.Context, purchaseID uint, items []entity.PurchaseItem) error { if s.ExpenseBridge == nil || purchaseID == 0 || len(items) == 0 { return nil } return s.ExpenseBridge.OnItemsDeleted(ctx, purchaseID, items) } func (s *purchaseService) deletePurchaseItemDocuments(ctx context.Context, items []entity.PurchaseItem) error { if s.DocumentSvc == nil || len(items) == 0 { return nil } for _, item := range items { if item.Id == 0 { continue } if err := s.DocumentSvc.DeleteByTarget(ctx, string(utils.DocumentableTypePurchaseItem), uint64(item.Id), true); err != nil { return err } } return 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, utils.BadRequest("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, utils.BadRequest(fmt.Sprintf("Duplicate pricing data for item %d", item.PurchaseItemID)) } requestItems[item.PurchaseItemID] = item } updates := make([]rPurchase.PurchasePricingUpdate, 0, len(purchase.Items)) 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, utils.BadRequest("No available warehouses for this purchase") } for _, item := range purchase.Items { data, ok := requestItems[item.Id] if !ok { return nil, utils.BadRequest(fmt.Sprintf("Missing pricing data for item %d", item.Id)) } if data.ProductID != 0 && data.ProductID != item.ProductId { return nil, utils.BadRequest(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, utils.BadRequest(fmt.Sprintf("Warehouse mismatch for item %d", item.Id)) } effectiveQty := item.SubQty if data.Qty != nil { if *data.Qty <= 0 { return nil, utils.BadRequest(fmt.Sprintf("Quantity for item %d must be greater than 0", item.Id)) } if item.TotalUsed > 0 && *data.Qty < item.TotalUsed && isReceivingBelowUsedBlocked(&item, nil) { return nil, utils.BadRequest(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, utils.BadRequest(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 } updates = append(updates, update) delete(requestItems, item.Id) } if len(requestItems) > 0 { return nil, utils.BadRequest("Found pricing data for items that do not belong to this purchase") } productSupplierCache := make(map[uint]bool) newItems := make([]*entity.PurchaseItem, 0, len(newPayloads)) emptyVehicle := "" for _, payload := range newPayloads { if payload.ProductID == 0 || payload.WarehouseID == 0 { return nil, utils.BadRequest("Product and warehouse must be provided for new items") } if payload.Qty == nil || *payload.Qty <= 0 { return nil, utils.BadRequest(fmt.Sprintf("Quantity must be greater than 0 for product %d", payload.ProductID)) } if _, ok := allowedWarehouses[payload.WarehouseID]; !ok { return nil, utils.BadRequest(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, utils.BadRequest(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, utils.Internal("Failed to validate product for supplier") } if !linked { return nil, utils.BadRequest(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, VehicleNumber: &emptyVehicle, } newItems = append(newItems, newItem) existingCombos[key] = struct{}{} } if len(updates) == 0 && len(newItems) == 0 { return nil, utils.BadRequest("Purchase has no items to process") } return &staffAdjustmentPayload{ PricingUpdates: updates, NewItems: newItems, }, nil } // ? helper func calculateTotalPrice(quantity float64, price float64, provided *float64, ref string) (float64, error) { if quantity <= 0 { return 0, utils.BadRequest(fmt.Sprintf("Quantity for %s must be greater than 0", ref)) } if price <= 0 { return 0, utils.BadRequest(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, utils.BadRequest(fmt.Sprintf("Total price for %s must be greater than 0", ref)) } if math.Abs(*provided-expectedTotal) > priceTolerance { return 0, utils.BadRequest(fmt.Sprintf("Total price for %s must equal quantity x price", ref)) } return *provided, nil } func purchaseItemHasFlag(item *entity.PurchaseItem, flag utils.FlagType) bool { if item == nil || item.Product == nil { return false } target := utils.NormalizeFlag(string(flag)) for _, f := range item.Product.Flags { if utils.NormalizeFlag(f.Name) == target { return true } } return false } func isReceivingBelowUsedBlocked(item *entity.PurchaseItem, lockedIDs map[uint]struct{}) bool { if item == nil { return false } if !purchaseItemHasAnyFlag(item, []utils.FlagType{ utils.FlagPullet, utils.FlagLayer, utils.FlagAyamAfkir, utils.FlagAyamCulling, utils.FlagAyamMati, }) { return false } if lockedIDs == nil { return true } _, locked := lockedIDs[item.Id] return locked } func purchaseItemHasAnyFlag(item *entity.PurchaseItem, flags []utils.FlagType) bool { if item == nil || item.Product == nil || len(flags) == 0 { return false } for _, flag := range flags { if purchaseItemHasFlag(item, flag) { return true } } return false } 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 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 "", utils.BadRequest("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 } return s.loadPurchase(c.Context(), purchaseID) } func (s *purchaseService) loadPurchase( ctx context.Context, id uint, ) (*entity.Purchase, error) { purchase, err := s.PurchaseRepo.GetByID(ctx, id, s.withRelations) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, utils.NotFound("Purchase not found") } s.Log.Errorf("Failed to get purchase %d: %+v", id, err) return nil, utils.Internal("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) } s.applyTravelDocumentURLs(ctx, purchase) return purchase, nil } func (s *purchaseService) applyTravelDocumentURLs(ctx context.Context, purchase *entity.Purchase) { if purchase == nil || s.DocumentSvc == nil { return } for i := range purchase.Items { item := &purchase.Items[i] documents, err := s.DocumentSvc.ListByTarget(ctx, string(utils.DocumentableTypePurchaseItem), uint64(item.Id)) if err != nil { s.Log.Warnf("Unable to load travel documents for purchase item %d: %+v", item.Id, err) } else { var targetDoc *entity.Document for j := len(documents) - 1; j >= 0; j-- { if documents[j].Type == string(utils.DocumentTypePurchaseTravel) { targetDoc = &documents[j] break } } if targetDoc != nil { url, err := s.DocumentSvc.PresignURL(ctx, *targetDoc, 15*time.Minute) if err != nil { s.Log.Warnf("Unable to presign travel document for purchase item %d: %+v", item.Id, err) } else if url != "" { item.TravelNumberDocs = &url continue } } } path := item.TravelNumberDocs if path == nil || strings.TrimSpace(*path) == "" { continue } url, err := commonSvc.ResolveDocumentURL(ctx, s.DocumentSvc, *path, 15*time.Minute) if err != nil { s.Log.Warnf("Unable to presign travel document for purchase item %d: %+v", item.Id, err) continue } if url == "" { continue } item.TravelNumberDocs = &url } } func collectPurchaseItemIDs(items []entity.PurchaseItem) []uint { itemIDs := make([]uint, 0, len(items)) for i := range items { if items[i].Id == 0 { continue } itemIDs = append(itemIDs, items[i].Id) } return itemIDs } func (s *purchaseService) resolveChickinLockedItemIDs(ctx context.Context, db *gorm.DB, items []entity.PurchaseItem) (map[uint]struct{}, error) { itemIDs := collectPurchaseItemIDs(items) return s.resolveChickinLockedItemIDsByItemID(ctx, db, itemIDs) } func (s *purchaseService) resolveChickinLockedItemIDsByItemID(ctx context.Context, db *gorm.DB, itemIDs []uint) (map[uint]struct{}, error) { locked := make(map[uint]struct{}) if len(itemIDs) == 0 { return locked, nil } if db == nil { return nil, errors.New("database is required") } var allocationLockedIDs []uint if err := db.WithContext(ctx). Model(&entity.StockAllocation{}). Distinct("stockable_id"). Where("stockable_type = ? AND stockable_id IN ? AND usable_type = ? AND status IN ? AND allocation_purpose = ?", fifo.StockableKeyPurchaseItems.String(), itemIDs, fifo.UsableKeyProjectChickin.String(), []string{entity.StockAllocationStatusActive, entity.StockAllocationStatusPending}, entity.StockAllocationPurposeConsume, ). Pluck("stockable_id", &allocationLockedIDs).Error; err != nil { return nil, err } for _, itemID := range allocationLockedIDs { locked[itemID] = struct{}{} } var conversionLockedIDs []uint if err := db.WithContext(ctx). Table("purchase_items pi"). Distinct("pi.id"). Joins("JOIN project_chickins pc ON pc.product_warehouse_id = pi.product_warehouse_id AND pc.deleted_at IS NULL"). Joins("JOIN project_flock_populations pfp ON pfp.project_chickin_id = pc.id AND pfp.deleted_at IS NULL"). Where("pi.id IN ?", itemIDs). Where("pi.project_flock_kandang_id IS NOT NULL"). Where("pc.project_flock_kandang_id = pi.project_flock_kandang_id"). Pluck("pi.id", &conversionLockedIDs).Error; err != nil { return nil, err } for _, itemID := range conversionLockedIDs { locked[itemID] = struct{}{} } return locked, nil } func collectPFKIDsFromPurchase(p *entity.Purchase) []uint { seen := make(map[uint]struct{}) ids := make([]uint, 0) for _, item := range p.Items { if item.ProjectFlockKandangId == nil || *item.ProjectFlockKandangId == 0 { continue } id := uint(*item.ProjectFlockKandangId) if _, ok := seen[id]; ok { continue } seen[id] = struct{}{} ids = append(ids, id) } return ids } func (s *purchaseService) ensureProjectFlockNotClosedForPurchase( ctx context.Context, purchase *entity.Purchase, ) error { pfkIDs := collectPFKIDsFromPurchase(purchase) if len(pfkIDs) == 0 { return nil } db := s.PurchaseRepo.DB() if db == nil { return utils.Internal("DB not available for project flock validation") } return commonSvc.EnsureProjectFlockNotClosedByProjectFlockKandangID(ctx, db, pfkIDs) }