Files
lti-api/internal/modules/expenses/services/expense.service.go
T

1049 lines
35 KiB
Go

package service
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"mime/multipart"
"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"
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 uint) 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
}
func NewExpenseService(repo repository.ExpenseRepository, supplierRepo supplierRepo.SupplierRepository, nonstockRepo nonstockRepo.NonstockRepository, approvalSvc commonSvc.ApprovalService, realizationRepo repository.ExpenseRealizationRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, validate *validator.Validate) ExpenseService {
return &expenseService{
Log: utils.Log,
Validate: validate,
Repository: repo,
SupplierRepo: supplierRepo,
NonstockRepo: nonstockRepo,
ApprovalSvc: approvalSvc,
RealizationRepository: realizationRepo,
ProjectFlockKandangRepo: projectFlockKandangRepo,
}
}
func (s expenseService) withRelations(db *gorm.DB) *gorm.DB {
return db.
Preload("CreatedUser").
Preload("Supplier").
Preload("Nonstocks.Nonstock").
Preload("Nonstocks.Realization").
Preload("Nonstocks.ProjectFlockKandang.Kandang.Location").
Preload("Nonstocks.Kandang").
Preload("Nonstocks.Kandang.Location")
}
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 LIKE ?", "%"+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)
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
}
for _, costPerKandang := range req.CostPerKandangs {
for _, costItem := range costPerKandang.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 := s.generateReferenceNumber(dbTransaction)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to generate reference number")
}
var grandTotal float64
for _, costPerKandang := range req.CostPerKandangs {
for _, costItem := range costPerKandang.CostItems {
grandTotal += costItem.TotalCost
}
}
createdBy := uint64(1) //todo get from auth
expense = &entity.Expense{
ReferenceNumber: referenceNumber,
PoNumber: req.PoNumber,
Category: req.Category,
SupplierId: req.SupplierID,
ExpenseDate: expenseDate,
GrandTotal: grandTotal,
CreatedBy: createdBy,
}
if err := expenseRepoTx.CreateOne(c.Context(), expense, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense")
}
if len(req.CostPerKandangs) > 0 {
for _, costPerKandang := range req.CostPerKandangs {
var projectFlockKandangId *uint64
if req.Category == "BOP" {
projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(costPerKandang.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
}
for _, costItem := range costPerKandang.CostItems {
nonstockId := costItem.NonstockID
var kandangId *uint64
if req.Category == "NON-BOP" {
id := uint64(costPerKandang.KandangID)
kandangId = &id
} else if req.Category == "BOP" {
if projectFlockKandangId != nil {
kandangId = &costPerKandang.KandangID
}
}
expenseNonstock := &entity.ExpenseNonstock{
ExpenseId: &expense.Id,
ProjectFlockKandangId: projectFlockKandangId,
KandangId: kandangId,
NonstockId: &nonstockId,
Qty: costItem.Quantity,
TotalPrice: costItem.TotalCost,
Note: costItem.Notes,
}
if err := expenseNonstockRepoTx.CreateOne(c.Context(), expenseNonstock, 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 len(req.Documents) > 0 {
if err := s.processDocuments(c, expenseRepoTx, uint(expense.Id), req.Documents, false); err != nil {
return err
}
}
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["expense_date"] = expenseDate
}
if len(updateBody) == 0 && req.CostPerKandang == 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)
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 req.CostPerKandang != nil {
if err := tx.Where("expense_id = ?", id).Delete(&entity.ExpenseNonstock{}).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update expense items")
}
var grandTotal float64
for _, cpk := range *req.CostPerKandang {
for _, costItem := range cpk.CostItems {
grandTotal += costItem.TotalCost
}
}
if err := expenseRepoTx.PatchOne(c.Context(), id, map[string]interface{}{
"grand_total": grandTotal,
}, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update expense grand total")
}
for _, cpk := range *req.CostPerKandang {
var projectFlockKandangId *uint64
expense, 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 expense.Category == "BOP" {
projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(tx)
projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(cpk.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
}
for _, costItem := range cpk.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
}
var kandangId *uint64
if expense.Category == "NON-BOP" {
id := uint64(cpk.KandangID)
kandangId = &id
} else if expense.Category == "BOP" {
if projectFlockKandangId != nil {
kandangId = &cpk.KandangID
}
}
expenseId := uint64(id)
expenseNonstock := &entity.ExpenseNonstock{
ExpenseId: &expenseId,
ProjectFlockKandangId: projectFlockKandangId,
KandangId: kandangId,
NonstockId: &costItem.NonstockID,
Qty: costItem.Quantity,
TotalPrice: costItem.TotalCost,
Note: costItem.Notes,
}
if err := expenseNonstockRepoTx.CreateOne(c.Context(), expenseNonstock, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense cost item")
}
}
}
}
actorID := uint(1) // TODO: replace with authenticated user id
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 len(req.Documents) > 0 {
if err := s.processDocuments(c, expenseRepoTx, id, req.Documents, false); err != nil {
return err
}
}
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 uint) error {
if err := commonSvc.EnsureRelations(c.Context(),
commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: s.Repository.IdExists},
); err != nil {
return err
}
if err := s.Repository.DeleteOne(c.Context(), id); 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")
}
createdBy := uint64(1) // TODO: replace with authenticated user id
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,
RealizationQty: realizationItem.Qty,
RealizationUnitPrice: realizationItem.UnitPrice,
RealizationTotalPrice: realizationItem.TotalPrice,
RealizationDate: realizationDate,
Note: realizationItem.Notes,
CreatedBy: &createdBy,
}
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 len(req.Documents) > 0 {
if err := s.processDocuments(c, expenseRepoTx, expenseID, req.Documents, true); err != nil {
return err
}
}
approvalAction := entity.ApprovalActionCreated
if _, err := approvalSvc.CreateApproval(
c.Context(),
utils.ApprovalWorkflowExpense,
expenseID,
utils.ExpenseStepRealisasi,
&approvalAction,
uint(createdBy),
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 := uint(1) // TODO: replace with authenticated user id
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
}
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))
}
var realizationDate *time.Time
if req.RealizationDate != "" {
parsedDate, err := utils.ParseDateString(req.RealizationDate)
if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid realization_date format")
}
realizationDate = &parsedDate
}
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)
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{}{
"realization_qty": realizationItem.Qty,
"realization_unit_price": realizationItem.UnitPrice,
"realization_total_price": realizationItem.TotalPrice,
"realization_date": *realizationDate,
}
if realizationItem.Notes != nil {
updateData["note"] = *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 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 len(req.Documents) > 0 {
if err := s.processDocuments(c, expenseRepoTx, expenseID, req.Documents, true); err != nil {
return err
}
}
if *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) processDocuments(ctx *fiber.Ctx, expenseRepoTx repository.ExpenseRepository, expenseID uint, documents []*multipart.FileHeader, isRealization bool) error {
if len(documents) == 0 {
return nil
}
var existingDocuments []expenseDto.DocumentDTO
var fieldName string
if isRealization {
fieldName = "realization_document_path"
} else {
fieldName = "document_path"
}
expense, err := expenseRepoTx.GetByID(ctx.Context(), expenseID, nil)
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense for document processing")
}
} else {
var documentField sql.NullString
if isRealization {
documentField = expense.RealizationDocumentPath
} else {
documentField = expense.DocumentPath
}
if documentField.Valid && documentField.String != "" {
if err := json.Unmarshal([]byte(documentField.String), &existingDocuments); err != nil {
existingDocuments = []expenseDto.DocumentDTO{}
}
}
}
var startID uint64 = 1
if len(existingDocuments) > 0 {
maxID := uint64(0)
for _, doc := range existingDocuments {
if doc.ID > maxID {
maxID = doc.ID
}
}
startID = maxID + 1
}
for i, doc := range documents {
documentPath := doc.Filename
document := expenseDto.DocumentDTO{
ID: startID + uint64(i),
Path: documentPath,
}
existingDocuments = append(existingDocuments, document)
}
documentJSON, err := json.Marshal(existingDocuments)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to save documents")
}
if err := expenseRepoTx.PatchOne(ctx.Context(), expenseID, map[string]interface{}{
fieldName: string(documentJSON),
}, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to save documents")
}
return 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 err := s.Repository.DB().WithContext(ctx.Context()).Transaction(func(tx *gorm.DB) error {
expenseRepoTx := repository.NewExpenseRepository(tx)
expense, err := expenseRepoTx.GetByID(ctx.Context(), expenseID, nil)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense for document deletion")
}
var existingDocuments []expenseDto.DocumentDTO
var fieldName string
if isRealization {
fieldName = "realization_document_path"
if expense.RealizationDocumentPath.Valid && expense.RealizationDocumentPath.String != "" {
if err := json.Unmarshal([]byte(expense.RealizationDocumentPath.String), &existingDocuments); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to parse existing realization documents")
}
}
} else {
fieldName = "document_path"
if expense.DocumentPath.Valid && expense.DocumentPath.String != "" {
if err := json.Unmarshal([]byte(expense.DocumentPath.String), &existingDocuments); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to parse existing documents")
}
}
}
var updatedDocuments []expenseDto.DocumentDTO
documentFound := false
for _, doc := range existingDocuments {
if doc.ID == documentID {
documentFound = true
continue
}
updatedDocuments = append(updatedDocuments, doc)
}
if !documentFound {
return fiber.NewError(fiber.StatusNotFound, "Document not found")
}
documentJSON, err := json.Marshal(updatedDocuments)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to save documents")
}
if err := expenseRepoTx.PatchOne(ctx.Context(), expenseID, map[string]interface{}{
fieldName: string(documentJSON),
}, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to save documents")
}
return nil
}); err != nil {
return err
}
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 := uint(1) // TODO: replace with authenticated user id
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 == "manager" {
stepNumber = utils.ExpenseStepManager
if latestApproval.StepNumber != uint16(utils.ExpenseStepPengajuan) {
currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)]
return fiber.NewError(fiber.StatusBadRequest,
fmt.Sprintf("Cannot process at Manager step. Latest approval is at %s step. Expected previous step: Pengajuan", currentStepName))
}
} else if approvalType == "finance" {
stepNumber = utils.ExpenseStepFinance
if latestApproval.StepNumber != uint16(utils.ExpenseStepManager) {
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: Manager", 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 _, 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) generateReferenceNumber(ctx *gorm.DB) (string, error) {
sequence, err := s.Repository.GetNextSequence(ctx.Statement.Context)
if err != nil {
return "", err
}
refNum := fmt.Sprintf("BOP-LTI-%05d", sequence)
return refNum, 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 actorIDFromContext(c *fiber.Ctx) (uint, error) {
// user, ok := authmiddleware.AuthenticatedUser(c)
// if !ok || user == nil || user.Id == 0 {
// return 0, fiber.NewError(fiber.StatusUnauthorized, "Please authenticate")
// }
// return user.Id, nil
// }
// return user.Id, nil
// }