mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-24 15:25:43 +00:00
Feat[BE-261,265]: createing BOP and BOP realization(Unfinished)
This commit is contained in:
@@ -1,12 +1,22 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"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"
|
||||
@@ -15,33 +25,78 @@ import (
|
||||
)
|
||||
|
||||
type ExpenseService interface {
|
||||
GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Expense, int64, error)
|
||||
GetOne(ctx *fiber.Ctx, id uint) (*entity.Expense, error)
|
||||
CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Expense, error)
|
||||
UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Expense, error)
|
||||
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
|
||||
ApproveExpense(ctx *fiber.Ctx, id uint, step string, action string, notes *string) (*expenseDto.ExpenseDetailDTO, 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)
|
||||
}
|
||||
|
||||
type expenseService struct {
|
||||
Log *logrus.Logger
|
||||
Validate *validator.Validate
|
||||
Repository repository.ExpenseRepository
|
||||
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, validate *validator.Validate) ExpenseService {
|
||||
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,
|
||||
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")
|
||||
return db.
|
||||
Preload("CreatedUser").
|
||||
Preload("Supplier").
|
||||
Preload("Nonstocks", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Preload("Nonstock").Preload("Realization").Preload("ProjectFlockKandang")
|
||||
})
|
||||
}
|
||||
|
||||
func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Expense, int64, error) {
|
||||
func (s expenseService) getExpenseWithDetails(c *fiber.Ctx, expenseId uint) (*expenseDto.ExpenseDetailDTO, error) {
|
||||
expense, err := s.Repository.GetByID(c.Context(), expenseId, s.withRelations)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
s.Log.Errorf("Expense not found for ID %d: %+v", expenseId, err)
|
||||
return nil, fiber.NewError(fiber.StatusNotFound, "Expense not found")
|
||||
}
|
||||
s.Log.Errorf("Failed to get expense for ID %d: %+v", expenseId, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Load latest approval with ActionUser
|
||||
latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, expenseId, func(db *gorm.DB) *gorm.DB {
|
||||
return db.Preload("ActionUser")
|
||||
})
|
||||
if err != nil {
|
||||
// Don't fail if approval loading fails, just log
|
||||
s.Log.Warnf("Failed to load approval for expense %d: %+v", expenseId, err)
|
||||
}
|
||||
expense.LatestApproval = latestApproval
|
||||
|
||||
responseDTO := expenseDto.ToExpenseDetailDTO(expense)
|
||||
return &responseDTO, nil
|
||||
}
|
||||
|
||||
func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expenseDto.ExpenseListDTO, int64, error) {
|
||||
if err := s.Validate.Struct(params); err != nil {
|
||||
s.Log.Errorf("Validation failed for GetAll: %+v", err)
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
@@ -50,7 +105,7 @@ func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity
|
||||
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("name LIKE ?", "%"+params.Search+"%")
|
||||
return db.Where("category LIKE ?", "%"+params.Search+"%")
|
||||
}
|
||||
return db.Order("created_at DESC").Order("updated_at DESC")
|
||||
})
|
||||
@@ -59,57 +114,218 @@ func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity
|
||||
s.Log.Errorf("Failed to get expenses: %+v", err)
|
||||
return nil, 0, err
|
||||
}
|
||||
return expenses, total, nil
|
||||
|
||||
// Load approvals for each expense
|
||||
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 {
|
||||
s.Log.Warnf("Failed to load approval for expense %d: %+v", expenses[i].Id, err)
|
||||
}
|
||||
expenses[i].LatestApproval = latestApproval
|
||||
}
|
||||
|
||||
result := expenseDto.ToExpenseListDTOs(expenses)
|
||||
return result, total, nil
|
||||
}
|
||||
|
||||
func (s expenseService) GetOne(c *fiber.Ctx, id uint) (*entity.Expense, error) {
|
||||
expense, err := s.Repository.GetByID(c.Context(), id, s.withRelations)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, fiber.NewError(fiber.StatusNotFound, "Expense not found")
|
||||
func (s expenseService) GetOne(c *fiber.Ctx, id uint) (*expenseDto.ExpenseDetailDTO, error) {
|
||||
return s.getExpenseWithDetails(c, id)
|
||||
}
|
||||
|
||||
func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expenseDto.ExpenseDetailDTO, error) {
|
||||
if err := s.Validate.Struct(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Validate supplier exists using common service
|
||||
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
|
||||
}
|
||||
|
||||
// Get supplier for category
|
||||
supplierEntity, err := s.SupplierRepo.GetByID(c.Context(), uint(req.SupplierID), nil)
|
||||
if err != nil {
|
||||
s.Log.Errorf("Failed get expense by id: %+v", err)
|
||||
return nil, err
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, fiber.NewError(fiber.StatusNotFound, "Supplier not found")
|
||||
}
|
||||
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get supplier")
|
||||
}
|
||||
return expense, nil
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
nonstockEntity, err := s.NonstockRepo.GetByID(c.Context(), nonstockId, func(db *gorm.DB) *gorm.DB {
|
||||
return db.Preload("Suppliers")
|
||||
})
|
||||
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 get nonstock")
|
||||
}
|
||||
|
||||
supplierFound := false
|
||||
for _, sn := range nonstockEntity.Suppliers {
|
||||
if uint64(sn.Id) == req.SupplierID {
|
||||
supplierFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
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))
|
||||
|
||||
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: supplierEntity.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 {
|
||||
|
||||
projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(dbTransaction)
|
||||
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")
|
||||
}
|
||||
|
||||
for _, costItem := range costPerKandang.CostItems {
|
||||
|
||||
projectFlockKandangId := uint64(projectFlockKandang.Id)
|
||||
nonstockId := costItem.NonstockID
|
||||
expenseNonstock := &entity.ExpenseNonstock{
|
||||
ExpenseId: &expense.Id,
|
||||
ProjectFlockKandangId: &projectFlockKandangId,
|
||||
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")
|
||||
} // TODO: Handle documents (save file references)
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
return s.getExpenseWithDetails(c, uint(expense.Id))
|
||||
}
|
||||
|
||||
func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Expense, error) {
|
||||
func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*expenseDto.ExpenseDetailDTO, error) {
|
||||
if err := s.Validate.Struct(req); err != nil {
|
||||
s.Log.Errorf("Validation failed for UpdateOne: %+v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
createdBy := uint64(1)
|
||||
createBody := &entity.Expense{
|
||||
PoNumber: req.PoNumber,
|
||||
Category: req.Category,
|
||||
CreatedBy: &createdBy,
|
||||
}
|
||||
|
||||
if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil {
|
||||
s.Log.Errorf("Failed to create expense: %+v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.GetOne(c, uint(createBody.Id))
|
||||
}
|
||||
|
||||
func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Expense, error) {
|
||||
if err := s.Validate.Struct(req); err != nil {
|
||||
// Validate expense exists using common service
|
||||
if err := commonSvc.EnsureRelations(c.Context(),
|
||||
commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: func(ctx context.Context, id uint) (bool, error) {
|
||||
return s.Repository.IdExists(ctx, uint64(id))
|
||||
}},
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updateBody := make(map[string]any)
|
||||
|
||||
if req.PoNumber != nil {
|
||||
updateBody["po_number"] = *req.PoNumber
|
||||
}
|
||||
if req.Category != nil {
|
||||
updateBody["category"] = *req.Category
|
||||
if req.SupplierID != nil {
|
||||
// Validate supplier exists using common service
|
||||
supplierID := uint(*req.SupplierID)
|
||||
if err := commonSvc.EnsureRelations(c.Context(),
|
||||
commonSvc.RelationCheck{Name: "Supplier", ID: &supplierID, Exists: func(ctx context.Context, id uint) (bool, error) {
|
||||
return commonRepo.Exists[entity.Supplier](ctx, s.SupplierRepo.DB(), id)
|
||||
}},
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updateBody["supplier_id"] = *req.SupplierID
|
||||
}
|
||||
|
||||
if len(updateBody) == 0 {
|
||||
return s.GetOne(c, id)
|
||||
return s.getExpenseWithDetails(c, id)
|
||||
}
|
||||
|
||||
if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil {
|
||||
@@ -120,16 +336,368 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.GetOne(c, id)
|
||||
return s.getExpenseWithDetails(c, id)
|
||||
}
|
||||
|
||||
func (s expenseService) DeleteOne(c *fiber.Ctx, id uint) error {
|
||||
if err := s.Repository.DeleteOne(c.Context(), id); err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fiber.NewError(fiber.StatusNotFound, "Expense not found")
|
||||
}
|
||||
s.Log.Errorf("Failed to delete expense: %+v", err)
|
||||
// Validate expense exists using common service
|
||||
if err := commonSvc.EnsureRelations(c.Context(),
|
||||
commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: func(ctx context.Context, id uint) (bool, error) {
|
||||
return s.Repository.IdExists(ctx, uint64(id))
|
||||
}},
|
||||
); 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) ApproveExpense(c *fiber.Ctx, id uint, stepName string, action string, notes *string) (*expenseDto.ExpenseDetailDTO, error) {
|
||||
|
||||
if err := commonSvc.EnsureRelations(c.Context(),
|
||||
commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: func(ctx context.Context, id uint) (bool, error) {
|
||||
return s.Repository.IdExists(ctx, uint64(id))
|
||||
}},
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
actorID := uint(1) // TODO: replace with authenticated user id
|
||||
|
||||
var stepNumber approvalutils.ApprovalStep
|
||||
switch stepName {
|
||||
case "Manager":
|
||||
stepNumber = utils.ExpenseStepManager
|
||||
case "Finance":
|
||||
stepNumber = utils.ExpenseStepFinance
|
||||
default:
|
||||
s.Log.Errorf("Invalid approval step: %s", stepName)
|
||||
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid approval step")
|
||||
}
|
||||
|
||||
var expectedPreviousStep uint16
|
||||
switch stepName {
|
||||
case "Manager":
|
||||
expectedPreviousStep = uint16(utils.ExpenseStepPengajuan)
|
||||
case "Finance":
|
||||
expectedPreviousStep = uint16(utils.ExpenseStepManager)
|
||||
}
|
||||
|
||||
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 != expectedPreviousStep {
|
||||
|
||||
expectedStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(expectedPreviousStep)]
|
||||
currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)]
|
||||
|
||||
return nil, fiber.NewError(fiber.StatusBadRequest,
|
||||
fmt.Sprintf("Cannot approve at step %s. Latest approval is at %s step. Expected previous step: %s",
|
||||
stepName, currentStepName, expectedStepName))
|
||||
}
|
||||
|
||||
var approvalAction entity.ApprovalAction
|
||||
switch action {
|
||||
case "APPROVED":
|
||||
approvalAction = entity.ApprovalActionApproved
|
||||
case "REJECTED":
|
||||
approvalAction = entity.ApprovalActionRejected
|
||||
default:
|
||||
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid approval action")
|
||||
}
|
||||
|
||||
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
|
||||
|
||||
approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx))
|
||||
expenseRepoTx := repository.NewExpenseRepository(tx)
|
||||
|
||||
if _, err := approvalSvc.CreateApproval(
|
||||
c.Context(),
|
||||
utils.ApprovalWorkflowExpense,
|
||||
id,
|
||||
stepNumber,
|
||||
&approvalAction,
|
||||
actorID,
|
||||
notes); err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create approval")
|
||||
}
|
||||
|
||||
if stepName == "Finance" && 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")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if fiberErr, ok := err.(*fiber.Error); ok {
|
||||
return nil, fiberErr
|
||||
}
|
||||
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to Approve")
|
||||
}
|
||||
|
||||
return s.getExpenseWithDetails(c, id)
|
||||
}
|
||||
|
||||
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: func(ctx context.Context, id uint) (bool, error) {
|
||||
return s.Repository.IdExists(ctx, uint64(id))
|
||||
}},
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
realizationDate := time.Now()
|
||||
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)
|
||||
|
||||
for _, realizationItem := range req.Realizations {
|
||||
|
||||
expenseNonstockID := realizationItem.ExpenseNonstockID
|
||||
|
||||
belongsToExpense, err := expenseNonstockRepoTx.GetByExpenseID(c.Context(), uint64(expenseID), uint64(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")
|
||||
}
|
||||
|
||||
_, 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")
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
return s.getExpenseWithDetails(c, expenseID)
|
||||
}
|
||||
|
||||
func (s *expenseService) CompleteExpense(c *fiber.Ctx, id uint, notes *string) (*expenseDto.ExpenseDetailDTO, error) {
|
||||
// Validate expense exists using common service
|
||||
if err := commonSvc.EnsureRelations(c.Context(),
|
||||
commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: func(ctx context.Context, id uint) (bool, error) {
|
||||
return s.Repository.IdExists(ctx, uint64(id))
|
||||
}},
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
actorID := uint(1) // TODO: replace with authenticated user id
|
||||
|
||||
// Get latest approval to validate workflow
|
||||
latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, id, nil)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
s.Log.Errorf("Failed to get latest approval for expense %d: %+v", id, err)
|
||||
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate workflow")
|
||||
}
|
||||
|
||||
// Check if expense can be completed (must be at Realisasi step)
|
||||
if latestApproval == nil {
|
||||
s.Log.Errorf("No approval found for expense %d", id)
|
||||
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)]
|
||||
s.Log.Errorf("Cannot complete expense at step %s. Must be at Realisasi step", currentStepName)
|
||||
return nil, fiber.NewError(fiber.StatusBadRequest,
|
||||
fmt.Sprintf("Cannot complete expense at %s step. Must be at Realisasi step", currentStepName))
|
||||
}
|
||||
|
||||
// Create approval for Selesai step (step 5) using transaction
|
||||
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 {
|
||||
s.Log.Errorf("Failed to create Selesai approval for expense %d: %+v", id, err)
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to complete expense")
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.getExpenseWithDetails(c, id)
|
||||
}
|
||||
|
||||
func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *validation.UpdateRealization) (*expenseDto.ExpenseDetailDTO, error) {
|
||||
if err := s.Validate.Struct(req); err != nil {
|
||||
s.Log.Errorf("Validation failed for UpdateRealization: %+v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Validate Expense exists using common service
|
||||
if err := commonSvc.EnsureRelations(c.Context(),
|
||||
commonSvc.RelationCheck{Name: "Expense", ID: &expenseID, Exists: func(ctx context.Context, id uint) (bool, error) {
|
||||
return s.Repository.IdExists(ctx, uint64(id))
|
||||
}},
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Use current date for realization date
|
||||
realizationDate := time.Now()
|
||||
|
||||
// Update realizations using transaction
|
||||
if err := s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
|
||||
realizationRepoTx := repository.NewExpenseRealizationRepository(tx)
|
||||
expenseNonstockRepoTx := repository.NewExpenseNonstockRepository(tx)
|
||||
|
||||
// Process each realization item
|
||||
for _, realizationItem := range req.Realizations {
|
||||
// Validate ExpenseNonstock exists and belongs to this expense
|
||||
expenseNonstockID := realizationItem.ExpenseNonstockID
|
||||
|
||||
belongsToExpense, err := expenseNonstockRepoTx.GetByExpenseID(c.Context(), uint64(expenseID), uint64(expenseNonstockID))
|
||||
if err != nil {
|
||||
s.Log.Errorf("Failed to validate ExpenseNonstock relation: %+v", err)
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate ExpenseNonstock relation")
|
||||
}
|
||||
if !belongsToExpense {
|
||||
s.Log.Errorf("ExpenseNonstock not found or does not belong to expense %d", expenseID)
|
||||
return fiber.NewError(fiber.StatusNotFound, "ExpenseNonstock not found or does not belong to this expense")
|
||||
}
|
||||
|
||||
// Get existing realization
|
||||
existingRealization, err := realizationRepoTx.GetByExpenseNonstockID(c.Context(), uint64(expenseNonstockID))
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
s.Log.Errorf("Realization not found for expense nonstock %d", expenseNonstockID)
|
||||
return fiber.NewError(fiber.StatusNotFound, "Realization not found for this expense nonstock")
|
||||
}
|
||||
s.Log.Errorf("Failed to get existing realization: %+v", err)
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get existing realization")
|
||||
}
|
||||
|
||||
// Update 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")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Return updated expense
|
||||
return s.getExpenseWithDetails(c, expenseID)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if expense.ReferenceNumber == nil {
|
||||
return "", errors.New("reference number is required")
|
||||
}
|
||||
|
||||
poNum := fmt.Sprintf("PO-%s", *expense.ReferenceNumber)
|
||||
|
||||
return poNum, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user