mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-23 06:45:43 +00:00
Merge branch 'feat/BE/Sprint-7' of https://gitlab.com/mbugroup/lti-api into feat/BE/US-304/permission-middleware-adjustment
This commit is contained in:
@@ -11,7 +11,6 @@ import (
|
||||
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
|
||||
rExpense "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories"
|
||||
sExpense "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/services"
|
||||
|
||||
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
|
||||
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
|
||||
|
||||
|
||||
@@ -2,9 +2,11 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -13,6 +15,8 @@ type ExpenseRepository interface {
|
||||
IdExists(ctx context.Context, id uint) (bool, error)
|
||||
GetNextSequence(ctx context.Context) (int, error)
|
||||
GetWithSupplier(ctx context.Context, id uint64) (*entity.Expense, error)
|
||||
WithProjectFlockKandangFilter(pfkID, kandangID uint) func(*gorm.DB) *gorm.DB
|
||||
CountUnfinishedByProjectFlockKandang(ctx context.Context, pfkID, kandangID uint, isFinished func(*entity.Approval) bool) (int64, error)
|
||||
}
|
||||
|
||||
type ExpenseRepositoryImpl struct {
|
||||
@@ -49,3 +53,57 @@ func (r *ExpenseRepositoryImpl) GetWithSupplier(ctx context.Context, id uint64)
|
||||
}
|
||||
return &expense, nil
|
||||
}
|
||||
|
||||
func (r *ExpenseRepositoryImpl) WithProjectFlockKandangFilter(pfkID, kandangID uint) func(*gorm.DB) *gorm.DB {
|
||||
return func(db *gorm.DB) *gorm.DB {
|
||||
if pfkID == 0 && kandangID == 0 {
|
||||
return db
|
||||
}
|
||||
q := db.Joins("JOIN expense_nonstocks ON expense_nonstocks.expense_id = expenses.id")
|
||||
if pfkID > 0 && kandangID > 0 {
|
||||
return q.Where("expense_nonstocks.project_flock_kandang_id = ? OR expense_nonstocks.kandang_id = ?", pfkID, kandangID)
|
||||
}
|
||||
if pfkID > 0 {
|
||||
return q.Where("expense_nonstocks.project_flock_kandang_id = ?", pfkID)
|
||||
}
|
||||
return q.Where("expense_nonstocks.kandang_id = ?", kandangID)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ExpenseRepositoryImpl) CountUnfinishedByProjectFlockKandang(ctx context.Context, pfkID, kandangID uint, isFinished func(*entity.Approval) bool) (int64, error) {
|
||||
if pfkID == 0 && kandangID == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var ids []uint64
|
||||
if err := r.DB().WithContext(ctx).
|
||||
Table("expenses").
|
||||
Scopes(r.WithProjectFlockKandangFilter(pfkID, kandangID)).
|
||||
Group("expenses.id").Where("expenses.deleted_at IS NULL").
|
||||
Pluck("expenses.id", &ids).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var unfinished int64
|
||||
for _, id := range ids {
|
||||
var latest entity.Approval
|
||||
err := r.DB().WithContext(ctx).
|
||||
Table("approvals").
|
||||
Where("approvable_type = ? AND approvable_id = ?", utils.ApprovalWorkflowExpense.String(), id).
|
||||
Order("action_at DESC").
|
||||
Limit(1).
|
||||
First(&latest).Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return 0, err
|
||||
}
|
||||
if isFinished != nil {
|
||||
if !isFinished(&latest) {
|
||||
unfinished++
|
||||
}
|
||||
}
|
||||
}
|
||||
return unfinished, nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -12,6 +14,8 @@ type ExpenseRealizationRepository interface {
|
||||
repository.BaseRepository[entity.ExpenseRealization]
|
||||
IdExists(ctx context.Context, id uint64) (bool, error)
|
||||
GetByExpenseNonstockID(ctx context.Context, expenseNonstockID uint64) (*entity.ExpenseRealization, error)
|
||||
GetByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.ExpenseRealization, error)
|
||||
GetAllWithFilters(ctx context.Context, offset, limit int, filters *validation.ExpenseQuery) ([]entity.ExpenseRealization, int64, error)
|
||||
}
|
||||
|
||||
type ExpenseRealizationRepositoryImpl struct {
|
||||
@@ -30,11 +34,102 @@ func (r *ExpenseRealizationRepositoryImpl) IdExists(ctx context.Context, id uint
|
||||
|
||||
func (r *ExpenseRealizationRepositoryImpl) GetByExpenseNonstockID(ctx context.Context, expenseNonstockID uint64) (*entity.ExpenseRealization, error) {
|
||||
var realization entity.ExpenseRealization
|
||||
err := r.DB().WithContext(ctx).
|
||||
Where("expense_nonstock_id = ?", expenseNonstockID).
|
||||
First(&realization).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &realization, nil
|
||||
err := r.DB().WithContext(ctx).Where("expense_nonstock_id = ?", expenseNonstockID).First(&realization).Error
|
||||
return &realization, err
|
||||
}
|
||||
|
||||
func (r *ExpenseRealizationRepositoryImpl) GetByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.ExpenseRealization, error) {
|
||||
var realizations []entity.ExpenseRealization
|
||||
err := r.DB().WithContext(ctx).
|
||||
Preload("ExpenseNonstock").
|
||||
Preload("ExpenseNonstock.Nonstock").
|
||||
Preload("ExpenseNonstock.Nonstock.Uom").
|
||||
Preload("ExpenseNonstock.Expense").
|
||||
Joins("JOIN expense_nonstocks ON expense_nonstocks.id = expense_realizations.expense_nonstock_id").
|
||||
Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = expense_nonstocks.project_flock_kandang_id").
|
||||
Joins("JOIN expenses ON expenses.id = expense_nonstocks.expense_id").
|
||||
Where("project_flock_kandangs.project_flock_id = ?", projectFlockID).
|
||||
Where("expenses.category = ?", "BOP").
|
||||
Find(&realizations).Error
|
||||
return realizations, err
|
||||
}
|
||||
|
||||
func (r *ExpenseRealizationRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, filters *validation.ExpenseQuery) ([]entity.ExpenseRealization, int64, error) {
|
||||
var realizations []entity.ExpenseRealization
|
||||
var total int64
|
||||
|
||||
db := r.DB().WithContext(ctx).
|
||||
Model(&entity.ExpenseRealization{}).
|
||||
Preload("ExpenseNonstock", func(db *gorm.DB) *gorm.DB {
|
||||
return db.
|
||||
Preload("Expense").
|
||||
Preload("Expense.Supplier").
|
||||
Preload("Kandang").
|
||||
Preload("Kandang.Location").
|
||||
Preload("Nonstock")
|
||||
}).
|
||||
Joins("JOIN expense_nonstocks ON expense_nonstocks.id = expense_realizations.expense_nonstock_id").
|
||||
Joins("JOIN expenses ON expenses.id = expense_nonstocks.expense_id").
|
||||
Joins("LEFT JOIN suppliers ON suppliers.id = expenses.supplier_id")
|
||||
|
||||
if filters.Search != "" {
|
||||
db = db.Where("expenses.category LIKE ? OR expenses.reference_number LIKE ? OR expenses.po_number LIKE ? OR expenses.notes LIKE ? OR suppliers.name LIKE ?",
|
||||
"%"+filters.Search+"%", "%"+filters.Search+"%", "%"+filters.Search+"%", "%"+filters.Search+"%", "%"+filters.Search+"%")
|
||||
}
|
||||
|
||||
if filters.Category != "" {
|
||||
db = db.Where("expenses.category = ?", filters.Category)
|
||||
}
|
||||
|
||||
if filters.SupplierId > 0 {
|
||||
db = db.Where("expenses.supplier_id = ?", filters.SupplierId)
|
||||
}
|
||||
|
||||
if filters.KandangId > 0 {
|
||||
db = db.Where("expense_nonstocks.kandang_id = ?", filters.KandangId)
|
||||
}
|
||||
|
||||
if filters.ProjectFlockKandangId > 0 {
|
||||
db = db.Where("expense_nonstocks.project_flock_kandang_id = ?", filters.ProjectFlockKandangId)
|
||||
}
|
||||
|
||||
if filters.NonstockId > 0 {
|
||||
db = db.Where("expense_nonstocks.nonstock_id = ?", filters.NonstockId)
|
||||
}
|
||||
|
||||
locationID := filters.LocationId
|
||||
areaID := filters.AreaId
|
||||
|
||||
if locationID > 0 || areaID > 0 {
|
||||
db = db.Joins("JOIN kandangs ON kandangs.id = expense_nonstocks.kandang_id")
|
||||
|
||||
if locationID > 0 {
|
||||
db = db.Where("kandangs.location_id = ?", uint(locationID))
|
||||
}
|
||||
|
||||
if areaID > 0 {
|
||||
db = db.Joins("JOIN locations ON locations.id = kandangs.location_id").
|
||||
Where("locations.area_id = ?", uint(areaID))
|
||||
}
|
||||
}
|
||||
|
||||
if filters.RealizationDate != "" {
|
||||
if realizationDate, err := utils.ParseDateString(filters.RealizationDate); err == nil {
|
||||
db = db.Where("DATE(expenses.realization_date) = ?", realizationDate)
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if err := db.
|
||||
Offset(offset).
|
||||
Limit(limit).
|
||||
Order("expense_realizations.created_at DESC").
|
||||
Find(&realizations).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return realizations, total, nil
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
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"
|
||||
@@ -188,7 +189,11 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to generate reference number")
|
||||
}
|
||||
|
||||
createdBy := uint64(1) //todo get from auth
|
||||
actorID, err := middleware.ActorIDFromContext(c)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context")
|
||||
}
|
||||
createdBy := uint64(actorID)
|
||||
expense = &entity.Expense{
|
||||
ReferenceNumber: referenceNumber,
|
||||
PoNumber: req.PoNumber,
|
||||
@@ -360,6 +365,9 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
|
||||
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 {
|
||||
@@ -404,6 +412,9 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
|
||||
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")
|
||||
}
|
||||
@@ -496,7 +507,10 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
|
||||
}
|
||||
}
|
||||
|
||||
actorID := uint(1) // TODO: replace with authenticated user id
|
||||
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
|
||||
@@ -543,7 +557,21 @@ func (s expenseService) DeleteOne(c *fiber.Ctx, id uint) error {
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
expense, err := s.Repository.GetByID(c.Context(), id, 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(), id); err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
s.Log.Errorf("Expense not found for ID %d: %+v", id, err)
|
||||
@@ -572,6 +600,20 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va
|
||||
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))
|
||||
@@ -655,7 +697,10 @@ func (s *expenseService) CompleteExpense(c *fiber.Ctx, id uint, notes *string) (
|
||||
return nil, err
|
||||
}
|
||||
|
||||
actorID := uint(1) // TODO: replace with authenticated user id
|
||||
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) {
|
||||
@@ -712,7 +757,19 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va
|
||||
); 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")
|
||||
@@ -960,11 +1017,14 @@ func (s *expenseService) Approval(c *fiber.Ctx, req *validation.ApprovalRequest,
|
||||
return nil, fiber.NewError(fiber.StatusBadRequest, "No expense IDs provided")
|
||||
}
|
||||
|
||||
actorID := uint(1) // TODO: replace with authenticated user id
|
||||
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 {
|
||||
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
|
||||
|
||||
approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx))
|
||||
expenseRepoTx := repository.NewExpenseRepository(tx)
|
||||
@@ -1010,6 +1070,21 @@ func (s *expenseService) Approval(c *fiber.Ctx, req *validation.ApprovalRequest,
|
||||
} 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(),
|
||||
@@ -1079,13 +1154,45 @@ func (s *expenseService) validateExpenseNonstockRelation(ctx *fiber.Ctx, 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
|
||||
// }
|
||||
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
|
||||
}
|
||||
|
||||
// return user.Id, 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user