mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 13:31:56 +00:00
5ddfb2c745
[Feat][BE}: expense adjust approval flow get rid approval manager change to head area and add business unit vice president approval See merge request mbugroup/lti-api!159
1215 lines
42 KiB
Go
1215 lines
42 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
|
|
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"
|
|
middleware "gitlab.com/mbugroup/lti-api.git/internal/middleware"
|
|
expenseDto "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/dto"
|
|
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories"
|
|
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/validations"
|
|
nonstockRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/repositories"
|
|
supplierRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories"
|
|
projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
|
|
"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 ExpenseService interface {
|
|
GetAll(ctx *fiber.Ctx, params *validation.Query) ([]expenseDto.ExpenseListDTO, int64, error)
|
|
GetOne(ctx *fiber.Ctx, id uint) (*expenseDto.ExpenseDetailDTO, error)
|
|
CreateOne(ctx *fiber.Ctx, req *validation.Create) (*expenseDto.ExpenseDetailDTO, error)
|
|
UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*expenseDto.ExpenseDetailDTO, error)
|
|
DeleteOne(ctx *fiber.Ctx, id uint64) error
|
|
CreateRealization(ctx *fiber.Ctx, expenseID uint, req *validation.CreateRealization) (*expenseDto.ExpenseDetailDTO, error)
|
|
CompleteExpense(ctx *fiber.Ctx, id uint, notes *string) (*expenseDto.ExpenseDetailDTO, error)
|
|
UpdateRealization(ctx *fiber.Ctx, expenseID uint, req *validation.UpdateRealization) (*expenseDto.ExpenseDetailDTO, error)
|
|
DeleteDocument(ctx *fiber.Ctx, expenseID uint, documentID uint64, isRealization bool) error
|
|
Approval(ctx *fiber.Ctx, req *validation.ApprovalRequest, approvalType string) ([]expenseDto.ExpenseDetailDTO, error)
|
|
}
|
|
|
|
type expenseService struct {
|
|
Log *logrus.Logger
|
|
Validate *validator.Validate
|
|
Repository repository.ExpenseRepository
|
|
SupplierRepo supplierRepo.SupplierRepository
|
|
NonstockRepo nonstockRepo.NonstockRepository
|
|
ApprovalSvc commonSvc.ApprovalService
|
|
RealizationRepository repository.ExpenseRealizationRepository
|
|
ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository
|
|
DocumentSvc commonSvc.DocumentService
|
|
}
|
|
|
|
func NewExpenseService(repo repository.ExpenseRepository, supplierRepo supplierRepo.SupplierRepository, nonstockRepo nonstockRepo.NonstockRepository, approvalSvc commonSvc.ApprovalService, realizationRepo repository.ExpenseRealizationRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, documentSvc commonSvc.DocumentService, validate *validator.Validate) ExpenseService {
|
|
return &expenseService{
|
|
Log: utils.Log,
|
|
Validate: validate,
|
|
Repository: repo,
|
|
SupplierRepo: supplierRepo,
|
|
NonstockRepo: nonstockRepo,
|
|
ApprovalSvc: approvalSvc,
|
|
RealizationRepository: realizationRepo,
|
|
ProjectFlockKandangRepo: projectFlockKandangRepo,
|
|
DocumentSvc: documentSvc,
|
|
}
|
|
}
|
|
|
|
func (s expenseService) withRelations(db *gorm.DB) *gorm.DB {
|
|
return db.
|
|
Preload("CreatedUser").
|
|
Preload("Supplier").
|
|
Preload("Location").
|
|
Preload("Nonstocks.Nonstock").
|
|
Preload("Nonstocks.Realization").
|
|
Preload("Nonstocks.ProjectFlockKandang.Kandang.Location").
|
|
Preload("Nonstocks.Kandang").
|
|
Preload("Nonstocks.Kandang.Location").
|
|
Preload("Documents", func(db *gorm.DB) *gorm.DB {
|
|
return db.Where("documentable_type = ?", string(utils.DocumentableTypeExpense))
|
|
}).
|
|
Preload("RealizationDocuments", func(db *gorm.DB) *gorm.DB {
|
|
return db.Where("documentable_type = ?", string(utils.DocumentableTypeExpenseRealization))
|
|
})
|
|
}
|
|
|
|
func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expenseDto.ExpenseListDTO, int64, error) {
|
|
if err := s.Validate.Struct(params); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
offset := (params.Page - 1) * params.Limit
|
|
|
|
expenses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
|
|
db = s.withRelations(db)
|
|
if params.Search != "" {
|
|
return db.Where("category ILIKE ?", "%"+params.Search+"%")
|
|
}
|
|
return db.Order("created_at DESC").Order("updated_at DESC")
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
return nil, 0, err
|
|
}
|
|
|
|
for i := range expenses {
|
|
latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, uint(expenses[i].Id), func(db *gorm.DB) *gorm.DB {
|
|
return db.Preload("ActionUser")
|
|
})
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
expenses[i].LatestApproval = latestApproval
|
|
}
|
|
|
|
result := expenseDto.ToExpenseListDTOs(expenses)
|
|
return result, total, nil
|
|
}
|
|
|
|
func (s expenseService) GetOne(c *fiber.Ctx, id uint) (*expenseDto.ExpenseDetailDTO, error) {
|
|
expense, err := s.Repository.GetByID(c.Context(), id, s.withRelations)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
|
|
return nil, fiber.NewError(fiber.StatusNotFound, "Expense not found")
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
approval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, id, func(db *gorm.DB) *gorm.DB {
|
|
return db.Preload("ActionUser")
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
expense.LatestApproval = approval
|
|
|
|
responseDTO := expenseDto.ToExpenseDetailDTO(expense)
|
|
return &responseDTO, nil
|
|
}
|
|
|
|
func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expenseDto.ExpenseDetailDTO, error) {
|
|
if err := s.Validate.Struct(req); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
supplierID := uint(req.SupplierID)
|
|
|
|
if err := commonSvc.EnsureRelations(c.Context(),
|
|
commonSvc.RelationCheck{Name: "Supplier", ID: &supplierID, Exists: s.SupplierRepo.IdExists},
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, expenseNonstock := range req.ExpenseNonstocks {
|
|
for _, costItem := range expenseNonstock.CostItems {
|
|
nonstockId := uint(costItem.NonstockID)
|
|
|
|
if err := commonSvc.EnsureRelations(c.Context(),
|
|
commonSvc.RelationCheck{Name: "Nonstock", ID: &nonstockId, Exists: s.NonstockRepo.IdExists},
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
supplierFound, err := s.NonstockRepo.IsNonstockAssociatedWithSupplier(c.Context(), nonstockId, req.SupplierID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, fiber.NewError(fiber.StatusNotFound, "Nonstock not found")
|
|
}
|
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check nonstock-supplier relation")
|
|
}
|
|
if !supplierFound {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Nonstock ID %d does not belong to supplier ID %d", nonstockId, req.SupplierID))
|
|
}
|
|
}
|
|
}
|
|
|
|
expenseDate, err := utils.ParseDateString(req.TransactionDate)
|
|
if err != nil {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transaction_date format")
|
|
}
|
|
|
|
var expense *entity.Expense
|
|
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
|
|
|
|
expenseRepoTx := repository.NewExpenseRepository(dbTransaction)
|
|
expenseNonstockRepoTx := repository.NewExpenseNonstockRepository(dbTransaction)
|
|
approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction))
|
|
projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(dbTransaction)
|
|
|
|
referenceNumber, err := GenerateExpenseReferenceNumber(c.Context(), expenseRepoTx)
|
|
if err != nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to generate reference number")
|
|
}
|
|
|
|
actorID, err := middleware.ActorIDFromContext(c)
|
|
if err != nil {
|
|
return fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context")
|
|
}
|
|
createdBy := uint64(actorID)
|
|
|
|
hasKandang := false
|
|
for _, ens := range req.ExpenseNonstocks {
|
|
if ens.KandangID != nil {
|
|
hasKandang = true
|
|
break
|
|
}
|
|
}
|
|
|
|
var projectFlockIdJSON *string
|
|
if !hasKandang && req.Category == string(utils.ExpenseCategoryBOP) {
|
|
projectFlockRepoTx := projectFlockKandangRepo.NewProjectflockRepository(dbTransaction)
|
|
activeProjectFlocks, err := projectFlockRepoTx.GetActiveByLocationID(c.Context(), req.LocationID)
|
|
|
|
if err != nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get active project flocks for location")
|
|
}
|
|
|
|
if len(activeProjectFlocks) > 0 {
|
|
projectFlockIDs := make([]uint64, len(activeProjectFlocks))
|
|
for i, pf := range activeProjectFlocks {
|
|
projectFlockIDs[i] = uint64(pf.Id)
|
|
}
|
|
|
|
projectFlockIdsJSON, err := json.Marshal(projectFlockIDs)
|
|
if err != nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to marshal project_flock_ids")
|
|
}
|
|
jsonStr := string(projectFlockIdsJSON)
|
|
projectFlockIdJSON = &jsonStr
|
|
}
|
|
}
|
|
|
|
expense = &entity.Expense{
|
|
ReferenceNumber: referenceNumber,
|
|
PoNumber: req.PoNumber,
|
|
Category: req.Category,
|
|
SupplierId: req.SupplierID,
|
|
LocationId: req.LocationID,
|
|
ProjectFlockId: projectFlockIdJSON,
|
|
TransactionDate: expenseDate,
|
|
CreatedBy: createdBy,
|
|
}
|
|
|
|
if err := expenseRepoTx.CreateOne(c.Context(), expense, nil); err != nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense")
|
|
}
|
|
|
|
if len(req.ExpenseNonstocks) > 0 {
|
|
|
|
for _, expenseNonstock := range req.ExpenseNonstocks {
|
|
|
|
isAttachingToKandang := (expenseNonstock.KandangID != nil)
|
|
|
|
var projectFlockKandangId *uint64
|
|
var kandangId *uint64
|
|
|
|
if isAttachingToKandang {
|
|
kandangId = expenseNonstock.KandangID
|
|
|
|
if req.Category == string(utils.ExpenseCategoryBOP) {
|
|
|
|
projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(*kandangId))
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang")
|
|
}
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to find project flock kandang for this kandang")
|
|
}
|
|
id := uint64(projectFlockKandang.Id)
|
|
projectFlockKandangId = &id
|
|
}
|
|
|
|
} else {
|
|
kandangId = nil
|
|
projectFlockKandangId = nil
|
|
}
|
|
|
|
for _, costItem := range expenseNonstock.CostItems {
|
|
|
|
nonstockId := costItem.NonstockID
|
|
newExpenseNonstock := &entity.ExpenseNonstock{
|
|
ExpenseId: &expense.Id,
|
|
ProjectFlockKandangId: projectFlockKandangId,
|
|
KandangId: kandangId,
|
|
NonstockId: &nonstockId,
|
|
Qty: costItem.Quantity,
|
|
Price: costItem.Price,
|
|
Notes: costItem.Notes,
|
|
}
|
|
|
|
if err := expenseNonstockRepoTx.CreateOne(c.Context(), newExpenseNonstock, nil); err != nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense cost item")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
approvalAction := entity.ApprovalActionCreated
|
|
createdByUint := uint(createdBy)
|
|
if _, err := approvalSvcTx.CreateApproval(
|
|
c.Context(),
|
|
utils.ApprovalWorkflowExpense,
|
|
uint(expense.Id),
|
|
utils.ExpenseStepPengajuan,
|
|
&approvalAction,
|
|
createdByUint,
|
|
nil); err != nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create initial approval")
|
|
}
|
|
|
|
if s.DocumentSvc != nil && len(req.Documents) > 0 {
|
|
documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents))
|
|
for idx, file := range req.Documents {
|
|
documentFiles = append(documentFiles, commonSvc.DocumentFile{
|
|
File: file,
|
|
Type: string(utils.DocumentTypeExpense),
|
|
Index: &idx,
|
|
})
|
|
}
|
|
_, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{
|
|
DocumentableType: string(utils.DocumentableTypeExpense),
|
|
DocumentableID: expense.Id,
|
|
CreatedBy: &createdByUint,
|
|
Files: documentFiles,
|
|
})
|
|
if err != nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload expense documents")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
if fiberErr, ok := err.(*fiber.Error); ok {
|
|
return nil, fiberErr
|
|
}
|
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense")
|
|
}
|
|
|
|
responseDTO, err := s.GetOne(c, uint(expense.Id))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return responseDTO, nil
|
|
}
|
|
|
|
func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*expenseDto.ExpenseDetailDTO, error) {
|
|
if err := s.Validate.Struct(req); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := commonSvc.EnsureRelations(c.Context(),
|
|
commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: s.Repository.IdExists},
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, id, nil)
|
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate workflow")
|
|
}
|
|
|
|
if latestApproval != nil {
|
|
if latestApproval.StepNumber >= uint16(utils.ExpenseStepRealisasi) {
|
|
currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)]
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot update expense at %s step. Must be before Realisasi step", currentStepName))
|
|
}
|
|
}
|
|
|
|
updateBody := make(map[string]any)
|
|
|
|
if req.TransactionDate != nil {
|
|
expenseDate, err := utils.ParseDateString(*req.TransactionDate)
|
|
if err != nil {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transaction_date format")
|
|
}
|
|
updateBody["transaction_date"] = expenseDate
|
|
}
|
|
|
|
if req.Category != nil {
|
|
updateBody["category"] = *req.Category
|
|
}
|
|
|
|
if req.SupplierID != nil {
|
|
supplierID := uint(*req.SupplierID)
|
|
supplierExistsFunc := func(ctx context.Context, id uint) (bool, error) {
|
|
return commonRepo.Exists[entity.Supplier](ctx, s.SupplierRepo.DB(), id)
|
|
}
|
|
if err := commonSvc.EnsureRelations(c.Context(),
|
|
commonSvc.RelationCheck{Name: "Supplier", ID: &supplierID, Exists: supplierExistsFunc},
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
updateBody["supplier_id"] = *req.SupplierID
|
|
}
|
|
|
|
if req.LocationID != nil {
|
|
locationID := uint(*req.LocationID)
|
|
updateBody["location_id"] = locationID
|
|
}
|
|
|
|
if len(updateBody) == 0 && req.ExpenseNonstocks == nil && len(req.Documents) == 0 {
|
|
|
|
responseDTO, err := s.GetOne(c, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return responseDTO, nil
|
|
}
|
|
|
|
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
|
|
|
|
expenseRepoTx := repository.NewExpenseRepository(tx)
|
|
approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx))
|
|
expenseNonstockRepoTx := repository.NewExpenseNonstockRepository(tx)
|
|
|
|
currentExpense, err := expenseRepoTx.GetByID(c.Context(), id, nil)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return fiber.NewError(fiber.StatusNotFound, "Expense not found")
|
|
}
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense")
|
|
}
|
|
|
|
if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), currentExpense); err != nil {
|
|
return err
|
|
}
|
|
categoryChanged := false
|
|
var newCategory string
|
|
if req.Category != nil && *req.Category != currentExpense.Category {
|
|
categoryChanged = true
|
|
newCategory = *req.Category
|
|
}
|
|
|
|
if len(updateBody) > 0 {
|
|
if err := expenseRepoTx.PatchOne(c.Context(), id, updateBody, nil); err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return fiber.NewError(fiber.StatusNotFound, "Expense not found")
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
|
|
if categoryChanged {
|
|
if currentExpense.Category == string(utils.ExpenseCategoryBOP) && newCategory == string(utils.ExpenseCategoryNonBOP) {
|
|
|
|
var existingExpenseNonstocks []entity.ExpenseNonstock
|
|
if err := tx.Where("expense_id = ?", id).Find(&existingExpenseNonstocks).Error; err != nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense nonstocks")
|
|
}
|
|
|
|
for _, ens := range existingExpenseNonstocks {
|
|
updateData := map[string]interface{}{
|
|
"project_flock_kandang_id": nil,
|
|
}
|
|
if err := expenseNonstockRepoTx.PatchOne(c.Context(), uint(ens.Id), updateData, nil); err != nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update project flock kandang id to null")
|
|
}
|
|
}
|
|
} else if currentExpense.Category == string(utils.ExpenseCategoryNonBOP) && newCategory == string(utils.ExpenseCategoryBOP) {
|
|
|
|
var existingExpenseNonstocks []entity.ExpenseNonstock
|
|
if err := tx.Where("expense_id = ?", id).Find(&existingExpenseNonstocks).Error; err != nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense nonstocks")
|
|
}
|
|
|
|
projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(tx)
|
|
for _, ens := range existingExpenseNonstocks {
|
|
if ens.KandangId != nil {
|
|
projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(*ens.KandangId))
|
|
if err != nil {
|
|
if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), currentExpense); err != nil {
|
|
return err
|
|
}
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang")
|
|
}
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to find project flock kandang for this kandang")
|
|
}
|
|
projectFlockKandangId := uint64(projectFlockKandang.Id)
|
|
|
|
updateData := map[string]interface{}{
|
|
"project_flock_kandang_id": projectFlockKandangId,
|
|
}
|
|
if err := expenseNonstockRepoTx.PatchOne(c.Context(), uint(ens.Id), updateData, nil); err != nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update project flock kandang id")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if req.ExpenseNonstocks != nil {
|
|
|
|
var existingExpenseNonstocks []entity.ExpenseNonstock
|
|
if err := tx.Where("expense_id = ?", id).Find(&existingExpenseNonstocks).Error; err != nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense nonstocks for deletion")
|
|
}
|
|
|
|
for _, ens := range existingExpenseNonstocks {
|
|
if err := expenseNonstockRepoTx.DeleteOne(c.Context(), uint(ens.Id)); err != nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete expense nonstock")
|
|
}
|
|
}
|
|
|
|
updatedExpense, err := expenseRepoTx.GetByID(c.Context(), id, nil)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return fiber.NewError(fiber.StatusNotFound, "Expense not found")
|
|
}
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get updated expense")
|
|
}
|
|
|
|
for _, expenseNonstock := range *req.ExpenseNonstocks {
|
|
var projectFlockKandangId *uint64
|
|
var kandangId *uint64
|
|
|
|
// Check if attaching to kandang
|
|
if expenseNonstock.KandangID != nil {
|
|
kandangId = expenseNonstock.KandangID
|
|
|
|
if updatedExpense.Category == string(utils.ExpenseCategoryBOP) {
|
|
// BOP with kandang: Get active project flock kandang
|
|
projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(tx)
|
|
projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(*kandangId))
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang")
|
|
}
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to find project flock kandang for this kandang")
|
|
}
|
|
id := uint64(projectFlockKandang.Id)
|
|
projectFlockKandangId = &id
|
|
}
|
|
// NON-BOP: projectFlockKandangId stays nil
|
|
}
|
|
|
|
for _, costItem := range expenseNonstock.CostItems {
|
|
|
|
nonstockId := uint(costItem.NonstockID)
|
|
if err := commonSvc.EnsureRelations(c.Context(),
|
|
commonSvc.RelationCheck{Name: "Nonstock", ID: &nonstockId, Exists: s.NonstockRepo.IdExists},
|
|
); err != nil {
|
|
return err
|
|
}
|
|
|
|
expenseId := uint64(id)
|
|
newExpenseNonstock := &entity.ExpenseNonstock{
|
|
ExpenseId: &expenseId,
|
|
ProjectFlockKandangId: projectFlockKandangId,
|
|
KandangId: kandangId,
|
|
NonstockId: &costItem.NonstockID,
|
|
Qty: costItem.Quantity,
|
|
Price: costItem.Price,
|
|
Notes: costItem.Notes,
|
|
}
|
|
|
|
if err := expenseNonstockRepoTx.CreateOne(c.Context(), newExpenseNonstock, nil); err != nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense cost item")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
actorID, err := middleware.ActorIDFromContext(c)
|
|
if err != nil {
|
|
return fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context")
|
|
}
|
|
if *latestApproval.Action != entity.ApprovalActionUpdated {
|
|
|
|
approvalAction := entity.ApprovalActionUpdated
|
|
|
|
if _, err := approvalSvcTx.CreateApproval(
|
|
c.Context(),
|
|
utils.ApprovalWorkflowExpense,
|
|
id,
|
|
utils.ExpenseStepPengajuan,
|
|
&approvalAction,
|
|
actorID,
|
|
nil); err != nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to reset approval step")
|
|
}
|
|
}
|
|
|
|
if s.DocumentSvc != nil && len(req.Documents) > 0 {
|
|
documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents))
|
|
for idx, file := range req.Documents {
|
|
documentFiles = append(documentFiles, commonSvc.DocumentFile{
|
|
File: file,
|
|
Type: string(utils.DocumentTypeExpense),
|
|
Index: &idx,
|
|
})
|
|
}
|
|
_, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{
|
|
DocumentableType: string(utils.DocumentableTypeExpense),
|
|
DocumentableID: uint64(id),
|
|
CreatedBy: &actorID,
|
|
Files: documentFiles,
|
|
})
|
|
if err != nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload expense documents")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
if fiberErr, ok := err.(*fiber.Error); ok {
|
|
return nil, fiberErr
|
|
}
|
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update expense")
|
|
}
|
|
|
|
responseDTO, err := s.GetOne(c, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return responseDTO, nil
|
|
}
|
|
|
|
func (s expenseService) DeleteOne(c *fiber.Ctx, id uint64) error {
|
|
idUint := uint(id)
|
|
|
|
if err := commonSvc.EnsureRelations(c.Context(),
|
|
commonSvc.RelationCheck{Name: "Expense", ID: &idUint, Exists: s.Repository.IdExists},
|
|
); err != nil {
|
|
return err
|
|
}
|
|
expense, err := s.Repository.GetByID(c.Context(), idUint, func(db *gorm.DB) *gorm.DB {
|
|
return db.Preload("Nonstocks")
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
s.Log.Errorf("Expense not found for ID %d: %+v", id, err)
|
|
return fiber.NewError(fiber.StatusNotFound, "Expense not found")
|
|
}
|
|
s.Log.Errorf("Failed to get expense for ID %d: %+v", id, err)
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense")
|
|
}
|
|
|
|
if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), expense); err != nil {
|
|
return err
|
|
}
|
|
if err := s.Repository.DeleteOne(c.Context(), idUint); err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
s.Log.Errorf("Expense not found for ID %d: %+v", id, err)
|
|
return fiber.NewError(fiber.StatusNotFound, "Expense not found")
|
|
}
|
|
s.Log.Errorf("Failed to delete expense for ID %d: %+v", id, err)
|
|
return err
|
|
}
|
|
s.Log.Infof("Successfully deleted expense with ID %d", id)
|
|
return nil
|
|
}
|
|
|
|
func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *validation.CreateRealization) (*expenseDto.ExpenseDetailDTO, error) {
|
|
if err := s.Validate.Struct(req); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := commonSvc.EnsureRelations(c.Context(),
|
|
commonSvc.RelationCheck{Name: "Expense", ID: &expenseID, Exists: s.Repository.IdExists},
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
realizationDate, err := utils.ParseDateString(req.RealizationDate)
|
|
if err != nil {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid realization_date format")
|
|
}
|
|
|
|
expense, err := s.Repository.GetByID(c.Context(), expenseID, func(db *gorm.DB) *gorm.DB {
|
|
return db.Preload("Nonstocks")
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, fiber.NewError(fiber.StatusNotFound, "Expense not found")
|
|
}
|
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense")
|
|
}
|
|
|
|
if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), expense); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
|
|
|
|
approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx))
|
|
realizationRepoTx := repository.NewExpenseRealizationRepository(tx)
|
|
expenseNonstockRepoTx := repository.NewExpenseNonstockRepository(tx)
|
|
expenseRepoTx := repository.NewExpenseRepository(tx)
|
|
|
|
for _, realizationItem := range req.Realizations {
|
|
|
|
expenseNonstockID := realizationItem.ExpenseNonstockID
|
|
|
|
if err := s.validateExpenseNonstockRelation(c, expenseNonstockRepoTx, expenseID, expenseNonstockID); err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = realizationRepoTx.GetByExpenseNonstockID(c.Context(), uint64(expenseNonstockID))
|
|
if err == nil {
|
|
return fiber.NewError(fiber.StatusBadRequest, "Realization already exists for this expense nonstock")
|
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to check existing realization")
|
|
}
|
|
|
|
realization := &entity.ExpenseRealization{
|
|
ExpenseNonstockId: &expenseNonstockID,
|
|
Qty: realizationItem.Qty,
|
|
Price: realizationItem.Price,
|
|
Notes: "",
|
|
}
|
|
|
|
if realizationItem.Notes != nil {
|
|
realization.Notes = *realizationItem.Notes
|
|
}
|
|
|
|
if err := realizationRepoTx.CreateOne(c.Context(), realization, nil); err != nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create realization")
|
|
}
|
|
}
|
|
|
|
if err := expenseRepoTx.PatchOne(c.Context(), expenseID, map[string]interface{}{
|
|
"realization_date": realizationDate,
|
|
}, nil); err != nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update realization date")
|
|
}
|
|
|
|
if s.DocumentSvc != nil && len(req.Documents) > 0 {
|
|
documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents))
|
|
for idx, file := range req.Documents {
|
|
documentFiles = append(documentFiles, commonSvc.DocumentFile{
|
|
File: file,
|
|
Type: string(utils.DocumentTypeExpenseRealization),
|
|
Index: &idx,
|
|
})
|
|
}
|
|
actorID := uint(1) // TODO: replace with authenticated user id
|
|
_, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{
|
|
DocumentableType: string(utils.DocumentableTypeExpenseRealization),
|
|
DocumentableID: uint64(expenseID),
|
|
CreatedBy: &actorID,
|
|
Files: documentFiles,
|
|
})
|
|
if err != nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload expense realization documents")
|
|
}
|
|
}
|
|
|
|
approvalAction := entity.ApprovalActionCreated
|
|
if _, err := approvalSvc.CreateApproval(
|
|
c.Context(),
|
|
utils.ApprovalWorkflowExpense,
|
|
expenseID,
|
|
utils.ExpenseStepRealisasi,
|
|
&approvalAction,
|
|
uint(1), // TODO: replace with authenticated user id
|
|
nil); err != nil {
|
|
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create realization approval")
|
|
}
|
|
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
responseDTO, err := s.GetOne(c, expenseID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return responseDTO, nil
|
|
}
|
|
|
|
func (s *expenseService) CompleteExpense(c *fiber.Ctx, id uint, notes *string) (*expenseDto.ExpenseDetailDTO, error) {
|
|
|
|
if err := commonSvc.EnsureRelations(c.Context(),
|
|
commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: s.Repository.IdExists},
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
actorID, err := middleware.ActorIDFromContext(c)
|
|
if err != nil {
|
|
return nil, fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context")
|
|
}
|
|
|
|
latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, id, nil)
|
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate workflow")
|
|
}
|
|
if latestApproval == nil {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, "No approval found. Please create expense first.")
|
|
}
|
|
|
|
if latestApproval.StepNumber != uint16(utils.ExpenseStepRealisasi) {
|
|
currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)]
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot complete expense at %s step. Must be at Realisasi step", currentStepName))
|
|
}
|
|
|
|
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
|
|
|
|
approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx))
|
|
approvalAction := entity.ApprovalActionApproved
|
|
|
|
if _, err := approvalSvc.CreateApproval(
|
|
c.Context(),
|
|
utils.ApprovalWorkflowExpense,
|
|
id,
|
|
utils.ExpenseStepSelesai,
|
|
&approvalAction,
|
|
actorID,
|
|
notes); err != nil {
|
|
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to complete expense")
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
responseDTO, err := s.GetOne(c, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return responseDTO, nil
|
|
}
|
|
|
|
func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *validation.UpdateRealization) (*expenseDto.ExpenseDetailDTO, error) {
|
|
if err := s.Validate.Struct(req); err != nil {
|
|
|
|
return nil, err
|
|
}
|
|
|
|
if err := commonSvc.EnsureRelations(c.Context(),
|
|
commonSvc.RelationCheck{Name: "Expense", ID: &expenseID, Exists: s.Repository.IdExists},
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
expense, err := s.Repository.GetByID(c.Context(), expenseID, func(db *gorm.DB) *gorm.DB {
|
|
return db.Preload("Nonstocks")
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, fiber.NewError(fiber.StatusNotFound, "Expense not found")
|
|
}
|
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense")
|
|
}
|
|
|
|
if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), expense); err != nil {
|
|
return nil, err
|
|
}
|
|
latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, expenseID, nil)
|
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate workflow")
|
|
}
|
|
|
|
if latestApproval != nil && (latestApproval.StepNumber < uint16(utils.ExpenseStepRealisasi)) {
|
|
currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)]
|
|
return nil, fiber.NewError(fiber.StatusBadRequest,
|
|
fmt.Sprintf("tidak bisa update realisasi pada step %s. Harus pada step Realisasi atau selesai", currentStepName))
|
|
}
|
|
|
|
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
|
|
|
|
approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx))
|
|
realizationRepoTx := repository.NewExpenseRealizationRepository(tx)
|
|
expenseNonstockRepoTx := repository.NewExpenseNonstockRepository(tx)
|
|
expenseRepoTx := repository.NewExpenseRepository(tx)
|
|
|
|
// Check if only updating documents
|
|
updateDataOnly := req.Realizations == nil && len(req.Documents) > 0
|
|
|
|
if req.Realizations != nil {
|
|
for _, realizationItem := range *req.Realizations {
|
|
|
|
expenseNonstockID := realizationItem.ExpenseNonstockID
|
|
|
|
if err := s.validateExpenseNonstockRelation(c, expenseNonstockRepoTx, expenseID, expenseNonstockID); err != nil {
|
|
return err
|
|
}
|
|
|
|
existingRealization, err := realizationRepoTx.GetByExpenseNonstockID(c.Context(), uint64(expenseNonstockID))
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
|
|
return fiber.NewError(fiber.StatusNotFound, "Realization not found for this expense nonstock")
|
|
}
|
|
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get existing realization")
|
|
}
|
|
|
|
updateData := map[string]interface{}{
|
|
"qty": realizationItem.Qty,
|
|
"price": realizationItem.Price,
|
|
}
|
|
|
|
if realizationItem.Notes != nil {
|
|
updateData["notes"] = *realizationItem.Notes
|
|
}
|
|
|
|
if err := realizationRepoTx.PatchOne(c.Context(), uint(existingRealization.Id), updateData, nil); err != nil {
|
|
s.Log.Errorf("Failed to update realization: %+v", err)
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update realization")
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
if req.RealizationDate != nil {
|
|
if err := expenseRepoTx.PatchOne(c.Context(), expenseID, map[string]interface{}{"realization_date": *req.RealizationDate}, nil); err != nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update realization date")
|
|
}
|
|
}
|
|
|
|
if s.DocumentSvc != nil && len(req.Documents) > 0 {
|
|
documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents))
|
|
for idx, file := range req.Documents {
|
|
documentFiles = append(documentFiles, commonSvc.DocumentFile{
|
|
File: file,
|
|
Type: string(utils.DocumentTypeExpenseRealization),
|
|
Index: &idx,
|
|
})
|
|
}
|
|
actorID := uint(1) // TODO: replace with authenticated user id
|
|
_, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{
|
|
DocumentableType: string(utils.DocumentableTypeExpenseRealization),
|
|
DocumentableID: uint64(expenseID),
|
|
CreatedBy: &actorID,
|
|
Files: documentFiles,
|
|
})
|
|
if err != nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload expense realization documents")
|
|
}
|
|
}
|
|
|
|
if !updateDataOnly && *latestApproval.Action == entity.ApprovalActionUpdated {
|
|
actorID := uint(1) // TODO: replace with authenticated user id
|
|
approvalAction := entity.ApprovalActionUpdated
|
|
if _, err := approvalSvcTx.CreateApproval(
|
|
c.Context(),
|
|
utils.ApprovalWorkflowExpense,
|
|
expenseID,
|
|
utils.ExpenseStepRealisasi,
|
|
&approvalAction,
|
|
actorID,
|
|
nil); err != nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create realization approval")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
if fiberErr, ok := err.(*fiber.Error); ok {
|
|
return nil, fiberErr
|
|
}
|
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "gagal update realisasi expense")
|
|
}
|
|
|
|
responseDTO, err := s.GetOne(c, expenseID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return responseDTO, nil
|
|
}
|
|
|
|
func (s *expenseService) DeleteDocument(ctx *fiber.Ctx, expenseID uint, documentID uint64, isRealization bool) error {
|
|
|
|
if err := commonSvc.EnsureRelations(ctx.Context(),
|
|
commonSvc.RelationCheck{Name: "Expense", ID: &expenseID, Exists: s.Repository.IdExists},
|
|
); err != nil {
|
|
return err
|
|
}
|
|
|
|
if s.DocumentSvc == nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Document service not available")
|
|
}
|
|
|
|
// Verify document exists and belongs to the expense
|
|
var documentableType string
|
|
if isRealization {
|
|
documentableType = string(utils.DocumentableTypeExpenseRealization)
|
|
} else {
|
|
documentableType = string(utils.DocumentableTypeExpense)
|
|
}
|
|
|
|
documents, err := s.DocumentSvc.ListByTarget(ctx.Context(), documentableType, uint64(expenseID))
|
|
if err != nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve documents")
|
|
}
|
|
|
|
documentFound := false
|
|
var documentIDsToDelete []uint
|
|
for _, doc := range documents {
|
|
if uint64(doc.Id) == documentID {
|
|
documentFound = true
|
|
documentIDsToDelete = append(documentIDsToDelete, doc.Id)
|
|
break
|
|
}
|
|
}
|
|
|
|
if !documentFound {
|
|
return fiber.NewError(fiber.StatusNotFound, "Document not found")
|
|
}
|
|
|
|
// Delete document from database and storage
|
|
if err := s.DocumentSvc.DeleteDocuments(ctx.Context(), documentIDsToDelete, true); err != nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete document")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *expenseService) Approval(c *fiber.Ctx, req *validation.ApprovalRequest, approvalType string) ([]expenseDto.ExpenseDetailDTO, error) {
|
|
if len(req.ApprovableIds) == 0 {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, "No expense IDs provided")
|
|
}
|
|
|
|
actorID, err := middleware.ActorIDFromContext(c)
|
|
if err != nil {
|
|
return nil, fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context")
|
|
}
|
|
|
|
var results []expenseDto.ExpenseDetailDTO
|
|
|
|
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
|
|
|
|
approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx))
|
|
expenseRepoTx := repository.NewExpenseRepository(tx)
|
|
|
|
for _, id := range req.ApprovableIds {
|
|
if err := commonSvc.EnsureRelations(c.Context(),
|
|
commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: s.Repository.IdExists},
|
|
); err != nil {
|
|
return err
|
|
}
|
|
|
|
latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, id, nil)
|
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate workflow")
|
|
}
|
|
|
|
var stepNumber approvalutils.ApprovalStep
|
|
if approvalType == "head-area" {
|
|
|
|
stepNumber = utils.ExpenseStepHeadArea
|
|
if latestApproval.StepNumber != uint16(utils.ExpenseStepPengajuan) {
|
|
currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)]
|
|
return fiber.NewError(fiber.StatusBadRequest,
|
|
fmt.Sprintf("Cannot process at Head Area step. Latest approval is at %s step. Expected previous step: Pengajuan", currentStepName))
|
|
}
|
|
} else if approvalType == "unit-vice-president" {
|
|
|
|
stepNumber = utils.ExpenseStepUnitVicePresident
|
|
if latestApproval.StepNumber != uint16(utils.ExpenseStepHeadArea) {
|
|
currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)]
|
|
return fiber.NewError(fiber.StatusBadRequest,
|
|
fmt.Sprintf("Cannot process at Unit Vice President step. Latest approval is at %s step. Expected previous step: Head Area", currentStepName))
|
|
}
|
|
|
|
} else if approvalType == "finance" {
|
|
|
|
stepNumber = utils.ExpenseStepFinance
|
|
if latestApproval.StepNumber != uint16(utils.ExpenseStepUnitVicePresident) {
|
|
currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)]
|
|
return fiber.NewError(fiber.StatusBadRequest,
|
|
fmt.Sprintf("Cannot process at Finance step. Latest approval is at %s step. Expected previous step: Unit Vice President", currentStepName))
|
|
}
|
|
} else {
|
|
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid approval type: %v", approvalType))
|
|
}
|
|
|
|
var approvalAction entity.ApprovalAction
|
|
if req.Action == "APPROVED" {
|
|
approvalAction = entity.ApprovalActionApproved
|
|
} else if req.Action == "REJECTED" {
|
|
approvalAction = entity.ApprovalActionRejected
|
|
} else {
|
|
return fiber.NewError(fiber.StatusBadRequest, "Invalid approval action")
|
|
}
|
|
if approvalAction == entity.ApprovalActionApproved {
|
|
expense, err := expenseRepoTx.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
|
|
return db.Preload("Nonstocks")
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return fiber.NewError(fiber.StatusNotFound, "Expense not found")
|
|
}
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to load expense")
|
|
}
|
|
|
|
if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), expense); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if _, err := approvalSvcTx.CreateApproval(
|
|
c.Context(),
|
|
utils.ApprovalWorkflowExpense,
|
|
id,
|
|
stepNumber,
|
|
&approvalAction,
|
|
actorID,
|
|
req.Notes); err != nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create approval")
|
|
}
|
|
|
|
if stepNumber == utils.ExpenseStepFinance && req.Action == "APPROVED" {
|
|
poNumber, err := s.generatePoNumber(tx, id)
|
|
if err != nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to generate PO number")
|
|
}
|
|
|
|
updateData := map[string]interface{}{
|
|
"po_number": poNumber,
|
|
}
|
|
if err := expenseRepoTx.PatchOne(c.Context(), id, updateData, nil); err != nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update PO number")
|
|
}
|
|
}
|
|
|
|
responseDTO, err := s.GetOne(c, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
results = append(results, *responseDTO)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
if fiberErr, ok := err.(*fiber.Error); ok {
|
|
return nil, fiberErr
|
|
}
|
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed approve expenses")
|
|
}
|
|
|
|
return results, nil
|
|
}
|
|
|
|
func (s *expenseService) generatePoNumber(ctx *gorm.DB, expenseID uint) (string, error) {
|
|
|
|
expenseRepoTx := repository.NewExpenseRepository(ctx)
|
|
expense, err := expenseRepoTx.GetByID(context.Background(), uint(expenseID), nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
poNum := fmt.Sprintf("PO-%s", expense.ReferenceNumber)
|
|
return poNum, nil
|
|
}
|
|
|
|
func (s *expenseService) validateExpenseNonstockRelation(ctx *fiber.Ctx, expenseNonstockRepoTx repository.ExpenseNonstockRepository, expenseID uint, expenseNonstockID uint64) error {
|
|
belongsToExpense, err := expenseNonstockRepoTx.GetByExpenseID(ctx.Context(), uint64(expenseID), expenseNonstockID)
|
|
if err != nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate ExpenseNonstock relation")
|
|
}
|
|
if !belongsToExpense {
|
|
return fiber.NewError(fiber.StatusNotFound, "ExpenseNonstock not found or does not belong to this expense")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *expenseService) ensureProjectFlockNotClosedForExpense(
|
|
ctx context.Context,
|
|
expense *entity.Expense,
|
|
) error {
|
|
// Kalau repo belum di-wire atau expense kosong → gak usah ngecek apa-apa
|
|
if s.ProjectFlockKandangRepo == nil || expense == nil {
|
|
return nil
|
|
}
|
|
|
|
seen := make(map[uint]struct{})
|
|
|
|
for _, ens := range expense.Nonstocks {
|
|
// Field ini pointer, bisa nil
|
|
if ens.ProjectFlockKandangId == nil || *ens.ProjectFlockKandangId == 0 {
|
|
continue
|
|
}
|
|
|
|
pfkID := uint(*ens.ProjectFlockKandangId)
|
|
if _, ok := seen[pfkID]; ok {
|
|
continue
|
|
}
|
|
seen[pfkID] = struct{}{}
|
|
|
|
pfk, err := s.ProjectFlockKandangRepo.GetByID(ctx, pfkID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return fiber.NewError(
|
|
fiber.StatusBadRequest,
|
|
fmt.Sprintf("Project flock %d tidak ditemukan", pfkID),
|
|
)
|
|
}
|
|
s.Log.Errorf("Failed to validate project flock %d for expense %d: %+v", pfkID, expense.Id, err)
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate project flock")
|
|
}
|
|
// ❗ RULE: kalau ClosedAt tidak nil → project sudah closing
|
|
if pfk.ClosedAt != nil {
|
|
return fiber.NewError(fiber.StatusBadRequest, "Project sudah closing")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|