Feat[BE]: integrate document service into expense module and update related DTOs for document handling

This commit is contained in:
aguhh18
2025-12-26 11:20:57 +07:00
committed by Hafizh A. Y
parent c7ae836cf0
commit c3302397cc
6 changed files with 150 additions and 159 deletions
+2 -3
View File
@@ -1,7 +1,6 @@
package entities package entities
import ( import (
"database/sql"
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"
@@ -13,8 +12,6 @@ type Expense struct {
SupplierId uint64 `gorm:""` SupplierId uint64 `gorm:""`
Category string `gorm:"type:varchar(50);not null"` Category string `gorm:"type:varchar(50);not null"`
PoNumber string `gorm:"type:varchar(50)"` PoNumber string `gorm:"type:varchar(50)"`
DocumentPath sql.NullString `gorm:"type:json"`
RealizationDocumentPath sql.NullString `gorm:"type:json;column:realization_document_path"`
RealizationDate time.Time `gorm:"type:date;column:realization_date"` RealizationDate time.Time `gorm:"type:date;column:realization_date"`
TransactionDate time.Time `gorm:"type:date;not null"` TransactionDate time.Time `gorm:"type:date;not null"`
Notes string `gorm:"type:text;column:notes"` Notes string `gorm:"type:text;column:notes"`
@@ -26,5 +23,7 @@ type Expense struct {
Supplier *Supplier `gorm:"foreignKey:SupplierId;references:Id"` Supplier *Supplier `gorm:"foreignKey:SupplierId;references:Id"`
CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"`
Nonstocks []ExpenseNonstock `gorm:"foreignKey:ExpenseId;references:Id"` Nonstocks []ExpenseNonstock `gorm:"foreignKey:ExpenseId;references:Id"`
Documents []Document `gorm:"foreignKey:DocumentableId;references:Id"`
RealizationDocuments []Document `gorm:"foreignKey:DocumentableId;references:Id"`
LatestApproval *Approval `gorm:"-" json:"latest_approval,omitempty"` LatestApproval *Approval `gorm:"-" json:"latest_approval,omitempty"`
} }
+14 -7
View File
@@ -1,7 +1,6 @@
package dto package dto
import ( import (
"encoding/json"
"time" "time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
@@ -41,8 +40,8 @@ type ExpenseListDTO struct {
type ExpenseDetailDTO struct { type ExpenseDetailDTO struct {
ExpenseBaseDTO ExpenseBaseDTO
Documents []DocumentDTO `json:"documents,omitempty"` Documents []DocumentDTO `json:"documents"`
RealizationDocs []DocumentDTO `json:"realization_docs,omitempty"` RealizationDocs []DocumentDTO `json:"realization_docs"`
Kandangs []KandangGroupDTO `json:"kandangs,omitempty"` Kandangs []KandangGroupDTO `json:"kandangs,omitempty"`
TotalPengajuan float64 `json:"total_pengajuan"` TotalPengajuan float64 `json:"total_pengajuan"`
TotalRealisasi float64 `json:"total_realisasi"` TotalRealisasi float64 `json:"total_realisasi"`
@@ -179,12 +178,20 @@ func ToExpenseDetailDTO(e *entity.Expense) ExpenseDetailDTO {
var pengajuans []ExpenseNonstockDTO var pengajuans []ExpenseNonstockDTO
var realisasi []ExpenseRealizationDTO var realisasi []ExpenseRealizationDTO
if e.DocumentPath.Valid && e.DocumentPath.String != "" { // Map documents from Document service
json.Unmarshal([]byte(e.DocumentPath.String), &documents) for _, doc := range e.Documents {
documents = append(documents, DocumentDTO{
ID: uint64(doc.Id),
Path: doc.Path,
})
} }
if e.RealizationDocumentPath.Valid && e.RealizationDocumentPath.String != "" { // Map realization documents from Document service
json.Unmarshal([]byte(e.RealizationDocumentPath.String), &realizationDocs) for _, doc := range e.RealizationDocuments {
realizationDocs = append(realizationDocs, DocumentDTO{
ID: uint64(doc.Id),
Path: doc.Path,
})
} }
if len(e.Nonstocks) > 0 { if len(e.Nonstocks) > 0 {
+7 -1
View File
@@ -1,6 +1,7 @@
package expenses package expenses
import ( import (
"context"
"fmt" "fmt"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
@@ -31,15 +32,20 @@ func (ExpenseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *
approvalRepo := commonRepo.NewApprovalRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db)
realizationRepo := rExpense.NewExpenseRealizationRepository(db) realizationRepo := rExpense.NewExpenseRealizationRepository(db)
projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db)
documentRepo := commonRepo.NewDocumentRepository(db)
approvalSvc := commonSvc.NewApprovalService(approvalRepo) approvalSvc := commonSvc.NewApprovalService(approvalRepo)
documentSvc, err := commonSvc.NewDocumentServiceFromConfig(context.Background(), documentRepo)
if err != nil {
panic(fmt.Sprintf("failed to create document service: %v", err))
}
// Register workflow steps for EXPENSES approval // Register workflow steps for EXPENSES approval
if err := approvalSvc.RegisterWorkflowSteps(utils.ApprovalWorkflowExpense, utils.ExpenseApprovalSteps); err != nil { if err := approvalSvc.RegisterWorkflowSteps(utils.ApprovalWorkflowExpense, utils.ExpenseApprovalSteps); err != nil {
panic(fmt.Sprintf("failed to register expense approval workflow: %v", err)) panic(fmt.Sprintf("failed to register expense approval workflow: %v", err))
} }
expenseService := sExpense.NewExpenseService(expenseRepo, supplierRepo, nonstockRepo, approvalSvc, realizationRepo, projectFlockKandangRepo, validate) expenseService := sExpense.NewExpenseService(expenseRepo, supplierRepo, nonstockRepo, approvalSvc, realizationRepo, projectFlockKandangRepo, documentSvc, validate)
userService := sUser.NewUserService(userRepo, validate) userService := sUser.NewUserService(userRepo, validate)
ExpenseRoutes(router, userService, expenseService) ExpenseRoutes(router, userService, expenseService)
@@ -2,11 +2,8 @@ package service
import ( import (
"context" "context"
"database/sql"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"mime/multipart"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
@@ -49,9 +46,10 @@ type expenseService struct {
ApprovalSvc commonSvc.ApprovalService ApprovalSvc commonSvc.ApprovalService
RealizationRepository repository.ExpenseRealizationRepository RealizationRepository repository.ExpenseRealizationRepository
ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository 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, validate *validator.Validate) ExpenseService { 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{ return &expenseService{
Log: utils.Log, Log: utils.Log,
Validate: validate, Validate: validate,
@@ -61,6 +59,7 @@ func NewExpenseService(repo repository.ExpenseRepository, supplierRepo supplierR
ApprovalSvc: approvalSvc, ApprovalSvc: approvalSvc,
RealizationRepository: realizationRepo, RealizationRepository: realizationRepo,
ProjectFlockKandangRepo: projectFlockKandangRepo, ProjectFlockKandangRepo: projectFlockKandangRepo,
DocumentSvc: documentSvc,
} }
} }
@@ -72,7 +71,13 @@ func (s expenseService) withRelations(db *gorm.DB) *gorm.DB {
Preload("Nonstocks.Realization"). Preload("Nonstocks.Realization").
Preload("Nonstocks.ProjectFlockKandang.Kandang.Location"). Preload("Nonstocks.ProjectFlockKandang.Kandang.Location").
Preload("Nonstocks.Kandang"). Preload("Nonstocks.Kandang").
Preload("Nonstocks.Kandang.Location") 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) { func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expenseDto.ExpenseListDTO, int64, error) {
@@ -269,9 +274,23 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create initial approval") return fiber.NewError(fiber.StatusInternalServerError, "Failed to create initial approval")
} }
if len(req.Documents) > 0 { if s.DocumentSvc != nil && len(req.Documents) > 0 {
if err := s.processDocuments(c, expenseRepoTx, uint(expense.Id), req.Documents, false); err != nil { documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents))
return err 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")
} }
} }
@@ -527,9 +546,23 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
} }
} }
if len(req.Documents) > 0 { if s.DocumentSvc != nil && len(req.Documents) > 0 {
if err := s.processDocuments(c, expenseRepoTx, id, req.Documents, false); err != nil { documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents))
return err 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")
} }
} }
@@ -658,9 +691,24 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update realization date") return fiber.NewError(fiber.StatusInternalServerError, "Failed to update realization date")
} }
if len(req.Documents) > 0 { if s.DocumentSvc != nil && len(req.Documents) > 0 {
if err := s.processDocuments(c, expenseRepoTx, expenseID, req.Documents, true); err != nil { documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents))
return err 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")
} }
} }
@@ -833,9 +881,24 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va
} }
} }
if len(req.Documents) > 0 { if s.DocumentSvc != nil && len(req.Documents) > 0 {
if err := s.processDocuments(c, expenseRepoTx, expenseID, req.Documents, true); err != nil { documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents))
return err 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")
} }
} }
@@ -870,79 +933,6 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va
return responseDTO, nil 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 { func (s *expenseService) DeleteDocument(ctx *fiber.Ctx, expenseID uint, documentID uint64, isRealization bool) error {
if err := commonSvc.EnsureRelations(ctx.Context(), if err := commonSvc.EnsureRelations(ctx.Context(),
@@ -951,62 +941,40 @@ func (s *expenseService) DeleteDocument(ctx *fiber.Ctx, expenseID uint, document
return err return err
} }
if err := s.Repository.DB().WithContext(ctx.Context()).Transaction(func(tx *gorm.DB) error { if s.DocumentSvc == nil {
expenseRepoTx := repository.NewExpenseRepository(tx) return fiber.NewError(fiber.StatusInternalServerError, "Document service not available")
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 // Verify document exists and belongs to the expense
var fieldName string var documentableType string
if isRealization { if isRealization {
fieldName = "realization_document_path" documentableType = string(utils.DocumentableTypeExpenseRealization)
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 { } else {
fieldName = "document_path" documentableType = string(utils.DocumentableTypeExpense)
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") documents, err := s.DocumentSvc.ListByTarget(ctx.Context(), documentableType, uint64(expenseID))
} if err != nil {
} return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve documents")
} }
var updatedDocuments []expenseDto.DocumentDTO
documentFound := false documentFound := false
var documentIDsToDelete []uint
for _, doc := range existingDocuments { for _, doc := range documents {
if doc.ID == documentID { if uint64(doc.Id) == documentID {
documentFound = true documentFound = true
continue documentIDsToDelete = append(documentIDsToDelete, doc.Id)
break
} }
updatedDocuments = append(updatedDocuments, doc)
} }
if !documentFound { if !documentFound {
return fiber.NewError(fiber.StatusNotFound, "Document not found") return fiber.NewError(fiber.StatusNotFound, "Document not found")
} }
documentJSON, err := json.Marshal(updatedDocuments) // Delete document from database and storage
if err != nil { if err := s.DocumentSvc.DeleteDocuments(ctx.Context(), documentIDsToDelete, true); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to save documents") return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete document")
}
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 return nil
+7
View File
@@ -1,6 +1,7 @@
package purchases package purchases
import ( import (
"context"
"fmt" "fmt"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
@@ -41,6 +42,11 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
approvalRepo := commonRepo.NewApprovalRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db)
approvalService := commonSvc.NewApprovalService(approvalRepo) approvalService := commonSvc.NewApprovalService(approvalRepo)
documentRepo := commonRepo.NewDocumentRepository(db)
documentSvc, err := commonSvc.NewDocumentServiceFromConfig(context.Background(), documentRepo)
if err != nil {
panic(fmt.Sprintf("failed to create document service: %v", err))
}
if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowPurchase, utils.PurchaseApprovalSteps); err != nil { if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowPurchase, utils.PurchaseApprovalSteps); err != nil {
panic(fmt.Sprintf("failed to register purchase approval workflow: %v", err)) panic(fmt.Sprintf("failed to register purchase approval workflow: %v", err))
} }
@@ -54,6 +60,7 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
approvalService, approvalService,
expenseRealizationRepo, expenseRealizationRepo,
projectFlockKandangRepository, projectFlockKandangRepository,
documentSvc,
validate, validate,
) )
expenseBridge := service.NewExpenseBridge( expenseBridge := service.NewExpenseBridge(
+4
View File
@@ -409,8 +409,12 @@ type DocumentableType string
const ( const (
DocumentTypeTransfer DocumentType = "STOCK_TRANSFER_DOCUMENT" DocumentTypeTransfer DocumentType = "STOCK_TRANSFER_DOCUMENT"
DocumentTypeExpense DocumentType = "EXPENSE_DOCUMENT"
DocumentTypeExpenseRealization DocumentType = "EXPENSE_REALIZATION_DOCUMENT"
DocumentableTypeTransfer DocumentableType = "STOCK_TRANSFER" DocumentableTypeTransfer DocumentableType = "STOCK_TRANSFER"
DocumentableTypeExpense DocumentableType = "EXPENSE"
DocumentableTypeExpenseRealization DocumentableType = "EXPENSE_REALIZATION"
) )
// ------------------------------------------------------------------- // -------------------------------------------------------------------