mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 13:31:56 +00:00
Feat[BE-261,265]: add category request body on create on
This commit is contained in:
@@ -82,6 +82,7 @@ func (u *ExpenseController) CreateOne(c *fiber.Ctx) error {
|
|||||||
req := new(validation.Create)
|
req := new(validation.Create)
|
||||||
|
|
||||||
req.TransactionDate = c.FormValue("transaction_date")
|
req.TransactionDate = c.FormValue("transaction_date")
|
||||||
|
req.Category = c.FormValue("category")
|
||||||
|
|
||||||
supplierID, err := strconv.ParseUint(c.FormValue("supplier_id"), 10, 64)
|
supplierID, err := strconv.ParseUint(c.FormValue("supplier_id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
package dto
|
package dto
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||||
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
|
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
|
||||||
|
locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto"
|
||||||
nonstockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/dto"
|
nonstockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/dto"
|
||||||
supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto"
|
supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto"
|
||||||
userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
|
userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
|
||||||
@@ -14,12 +16,14 @@ import (
|
|||||||
// === Base DTO ===
|
// === Base DTO ===
|
||||||
|
|
||||||
type ExpenseBaseDTO struct {
|
type ExpenseBaseDTO struct {
|
||||||
Id uint64 `json:"id"`
|
Id uint64 `json:"id"`
|
||||||
ReferenceNumber string `json:"reference_number"`
|
ReferenceNumber string `json:"reference_number"`
|
||||||
PoNumber *string `json:"po_number,omitempty"`
|
PoNumber *string `json:"po_number"`
|
||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
ExpenseDate time.Time `json:"expense_date"`
|
Documents []string `json:"documents,omitempty"`
|
||||||
GrandTotal float64 `json:"grand_total"`
|
ExpenseDate time.Time `json:"expense_date"`
|
||||||
|
GrandTotal float64 `json:"grand_total"`
|
||||||
|
Location *locationDTO.LocationBaseDTO `json:"location,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// === List DTO (untuk GetAll) ===
|
// === List DTO (untuk GetAll) ===
|
||||||
@@ -110,13 +114,33 @@ func getStringValue(s *string) string {
|
|||||||
// === Mapper Functions ===
|
// === Mapper Functions ===
|
||||||
|
|
||||||
func ToExpenseBaseDTO(e *entity.Expense) ExpenseBaseDTO {
|
func ToExpenseBaseDTO(e *entity.Expense) ExpenseBaseDTO {
|
||||||
|
var documents []string
|
||||||
|
var location *locationDTO.LocationBaseDTO
|
||||||
|
|
||||||
|
// Parse document paths from JSON if available
|
||||||
|
if e.DocumentPath.Valid && e.DocumentPath.String != "" {
|
||||||
|
if err := json.Unmarshal([]byte(e.DocumentPath.String), &documents); err == nil {
|
||||||
|
// Successfully parsed documents
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get location from the first kandang if available
|
||||||
|
if len(e.Nonstocks) > 0 && e.Nonstocks[0].ProjectFlockKandang != nil {
|
||||||
|
if e.Nonstocks[0].ProjectFlockKandang.Kandang.Location.Id != 0 {
|
||||||
|
mapped := locationDTO.ToLocationBaseDTO(e.Nonstocks[0].ProjectFlockKandang.Kandang.Location)
|
||||||
|
location = &mapped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return ExpenseBaseDTO{
|
return ExpenseBaseDTO{
|
||||||
Id: e.Id,
|
Id: e.Id,
|
||||||
ReferenceNumber: getStringValue(e.ReferenceNumber),
|
ReferenceNumber: getStringValue(e.ReferenceNumber),
|
||||||
PoNumber: e.PoNumber,
|
PoNumber: e.PoNumber, // Keep as pointer to allow null in JSON
|
||||||
Category: e.Category,
|
Category: e.Category,
|
||||||
|
Documents: documents,
|
||||||
ExpenseDate: e.ExpenseDate,
|
ExpenseDate: e.ExpenseDate,
|
||||||
GrandTotal: e.GrandTotal,
|
GrandTotal: e.GrandTotal,
|
||||||
|
Location: location,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
@@ -65,7 +66,7 @@ func (s expenseService) withRelations(db *gorm.DB) *gorm.DB {
|
|||||||
Preload("CreatedUser").
|
Preload("CreatedUser").
|
||||||
Preload("Supplier").
|
Preload("Supplier").
|
||||||
Preload("Nonstocks", func(db *gorm.DB) *gorm.DB {
|
Preload("Nonstocks", func(db *gorm.DB) *gorm.DB {
|
||||||
return db.Preload("Nonstock").Preload("Realization").Preload("ProjectFlockKandang")
|
return db.Preload("Nonstock").Preload("Realization").Preload("ProjectFlockKandang.Kandang.Location")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,15 +151,6 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get supplier for category
|
|
||||||
supplierEntity, err := s.SupplierRepo.GetByID(c.Context(), uint(req.SupplierID), nil)
|
|
||||||
if err != nil {
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, costPerKandang := range req.CostPerKandangs {
|
for _, costPerKandang := range req.CostPerKandangs {
|
||||||
for _, costItem := range costPerKandang.CostItems {
|
for _, costItem := range costPerKandang.CostItems {
|
||||||
nonstockId := uint(costItem.NonstockID)
|
nonstockId := uint(costItem.NonstockID)
|
||||||
@@ -220,7 +212,7 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen
|
|||||||
expense = &entity.Expense{
|
expense = &entity.Expense{
|
||||||
ReferenceNumber: &referenceNumber,
|
ReferenceNumber: &referenceNumber,
|
||||||
PoNumber: req.PoNumber,
|
PoNumber: req.PoNumber,
|
||||||
Category: supplierEntity.Category,
|
Category: req.Category,
|
||||||
SupplierId: &req.SupplierID,
|
SupplierId: &req.SupplierID,
|
||||||
ExpenseDate: expenseDate,
|
ExpenseDate: expenseDate,
|
||||||
GrandTotal: grandTotal,
|
GrandTotal: grandTotal,
|
||||||
@@ -276,12 +268,36 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen
|
|||||||
&approvalAction,
|
&approvalAction,
|
||||||
createdByUint,
|
createdByUint,
|
||||||
nil); err != nil {
|
nil); err != nil {
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create initial approval")
|
||||||
|
}
|
||||||
|
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create initial approval")
|
// Handle documents - save file paths as JSON array
|
||||||
} // TODO: Handle documents (save file references)
|
if len(req.Documents) > 0 {
|
||||||
|
documentPaths := make([]string, len(req.Documents))
|
||||||
|
for i, doc := range req.Documents {
|
||||||
|
// Generate dummy path for each document
|
||||||
|
// In real implementation, this would save the file and return the actual path
|
||||||
|
documentPath := fmt.Sprintf("/documents/expenses/%d_%d_%s", expense.Id, i+1, doc.Filename)
|
||||||
|
documentPaths[i] = documentPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save document paths as JSON in expense record
|
||||||
|
documentPathsJSON, err := json.Marshal(documentPaths)
|
||||||
|
if err != nil {
|
||||||
|
s.Log.Errorf("Failed to marshal document paths: %+v", err)
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to save document references")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := expenseRepoTx.PatchOne(c.Context(), uint(expense.Id), map[string]interface{}{
|
||||||
|
"document_path": string(documentPathsJSON),
|
||||||
|
}, nil); err != nil {
|
||||||
|
s.Log.Errorf("Failed to save document paths: %+v", err)
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to save document references")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if fiberErr, ok := err.(*fiber.Error); ok {
|
if fiberErr, ok := err.(*fiber.Error); ok {
|
||||||
@@ -308,6 +324,23 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 updated (must be before Realisasi step)
|
||||||
|
if latestApproval != nil {
|
||||||
|
if latestApproval.StepNumber >= uint16(utils.ExpenseStepRealisasi) {
|
||||||
|
currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)]
|
||||||
|
s.Log.Errorf("Cannot update expense at %s step. Must be before Realisasi step", currentStepName)
|
||||||
|
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)
|
updateBody := make(map[string]any)
|
||||||
|
|
||||||
if req.SupplierID != nil {
|
if req.SupplierID != nil {
|
||||||
@@ -324,16 +357,135 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
|
|||||||
updateBody["supplier_id"] = *req.SupplierID
|
updateBody["supplier_id"] = *req.SupplierID
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(updateBody) == 0 {
|
if req.TransactionDate != nil {
|
||||||
|
// Parse transaction_date
|
||||||
|
expenseDate, err := utils.ParseDateString(*req.TransactionDate)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transaction_date format")
|
||||||
|
}
|
||||||
|
updateBody["expense_date"] = expenseDate
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.PoNumber != nil {
|
||||||
|
updateBody["po_number"] = *req.PoNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(updateBody) == 0 && req.CostPerKandang == nil {
|
||||||
return s.getExpenseWithDetails(c, id)
|
return s.getExpenseWithDetails(c, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil {
|
// Update expense using transaction
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
|
||||||
return nil, fiber.NewError(fiber.StatusNotFound, "Expense not found")
|
expenseRepoTx := repository.NewExpenseRepository(tx)
|
||||||
|
approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx))
|
||||||
|
expenseNonstockRepoTx := repository.NewExpenseNonstockRepository(tx)
|
||||||
|
|
||||||
|
// Update expense
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
s.Log.Errorf("Failed to update expense: %+v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
s.Log.Errorf("Failed to update expense: %+v", err)
|
|
||||||
return nil, err
|
// Update cost per kandang if provided
|
||||||
|
if req.CostPerKandang != nil {
|
||||||
|
// First, delete existing expense nonstocks using GORM
|
||||||
|
if err := tx.Where("expense_id = ?", id).Delete(&entity.ExpenseNonstock{}).Error; err != nil {
|
||||||
|
s.Log.Errorf("Failed to delete existing expense nonstocks: %+v", err)
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update expense items")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate new grand total
|
||||||
|
var grandTotal float64
|
||||||
|
for _, cpk := range *req.CostPerKandang {
|
||||||
|
for _, costItem := range cpk.CostItems {
|
||||||
|
grandTotal += costItem.TotalCost
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update expense grand total
|
||||||
|
if err := expenseRepoTx.PatchOne(c.Context(), id, map[string]interface{}{
|
||||||
|
"grand_total": grandTotal,
|
||||||
|
}, nil); err != nil {
|
||||||
|
s.Log.Errorf("Failed to update expense grand total: %+v", err)
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update expense grand total")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new expense nonstocks
|
||||||
|
for _, cpk := range *req.CostPerKandang {
|
||||||
|
// Get active project_flock_kandang for this kandang
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, costItem := range cpk.CostItems {
|
||||||
|
// Validate nonstock exists
|
||||||
|
nonstockId := uint(costItem.NonstockID)
|
||||||
|
if err := commonSvc.EnsureRelations(c.Context(),
|
||||||
|
commonSvc.RelationCheck{Name: "Nonstock", ID: &nonstockId, Exists: s.NonstockRepo.IdExists},
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
projectFlockKandangId := uint64(projectFlockKandang.Id)
|
||||||
|
expenseId := uint64(id)
|
||||||
|
expenseNonstock := &entity.ExpenseNonstock{
|
||||||
|
ExpenseId: &expenseId,
|
||||||
|
ProjectFlockKandangId: &projectFlockKandangId,
|
||||||
|
NonstockId: &costItem.NonstockID,
|
||||||
|
Qty: costItem.Quantity,
|
||||||
|
TotalPrice: costItem.TotalCost,
|
||||||
|
Note: &costItem.Notes,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := expenseNonstockRepoTx.CreateOne(c.Context(), expenseNonstock, nil); err != nil {
|
||||||
|
s.Log.Errorf("Failed to create expense nonstock: %+v", err)
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense cost item")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset approval step to Pengajuan
|
||||||
|
actorID := uint(1) // TODO: replace with authenticated user id
|
||||||
|
var approvalAction entity.ApprovalAction
|
||||||
|
|
||||||
|
// Check if latest approval was Updated, then use Updated action, otherwise use Created
|
||||||
|
if latestApproval != nil && latestApproval.Action != nil && *latestApproval.Action == entity.ApprovalActionUpdated {
|
||||||
|
approvalAction = entity.ApprovalActionUpdated
|
||||||
|
} else {
|
||||||
|
approvalAction = entity.ApprovalActionCreated
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := approvalSvcTx.CreateApproval(
|
||||||
|
c.Context(),
|
||||||
|
utils.ApprovalWorkflowExpense,
|
||||||
|
id,
|
||||||
|
utils.ExpenseStepPengajuan,
|
||||||
|
&approvalAction,
|
||||||
|
actorID,
|
||||||
|
nil); err != nil {
|
||||||
|
s.Log.Errorf("Failed to reset approval to Pengajuan for expense %d: %+v", id, err)
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to reset approval step")
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.getExpenseWithDetails(c, id)
|
return s.getExpenseWithDetails(c, id)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type ApprovalRequest struct {
|
|||||||
type Create struct {
|
type Create struct {
|
||||||
PoNumber *string `form:"po_number" validate:"omitempty,max=50"`
|
PoNumber *string `form:"po_number" validate:"omitempty,max=50"`
|
||||||
TransactionDate string `form:"transaction_date" validate:"required,datetime=2006-01-02"`
|
TransactionDate string `form:"transaction_date" validate:"required,datetime=2006-01-02"`
|
||||||
|
Category string `form:"category" validate:"required,oneof=BOP NON-BOP"`
|
||||||
SupplierID uint64 `form:"supplier_id" validate:"required,gt=0"`
|
SupplierID uint64 `form:"supplier_id" validate:"required,gt=0"`
|
||||||
Documents []*multipart.FileHeader `form:"documents" validate:"omitempty,dive"`
|
Documents []*multipart.FileHeader `form:"documents" validate:"omitempty,dive"`
|
||||||
CostPerKandangs []CostPerKandang `form:"cost_per_kandangs" validate:"required,min=1,dive"`
|
CostPerKandangs []CostPerKandang `form:"cost_per_kandangs" validate:"required,min=1,dive"`
|
||||||
|
|||||||
Reference in New Issue
Block a user