From b8d1268dfae338939aa1a828435cb5f7085122e9 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 21 Nov 2025 00:55:02 +0700 Subject: [PATCH] Feat[BE-261]: creating multiple Approval API, Update API, Delete API and some logic Adjustment --- ...0251117034511_create_expenses_table.up.sql | 2 + internal/entities/expense.go | 28 +- internal/entities/expense_nonstock.go | 6 +- .../controllers/expense.controller.go | 156 +++- internal/modules/expenses/dto/expense.dto.go | 290 +++---- internal/modules/expenses/route.go | 6 +- .../expenses/services/expense.service.go | 787 +++++++++++------- .../validations/expense.validation.go | 72 +- 8 files changed, 797 insertions(+), 550 deletions(-) diff --git a/internal/database/migrations/20251117034511_create_expenses_table.up.sql b/internal/database/migrations/20251117034511_create_expenses_table.up.sql index 819e25f4..8949d931 100644 --- a/internal/database/migrations/20251117034511_create_expenses_table.up.sql +++ b/internal/database/migrations/20251117034511_create_expenses_table.up.sql @@ -7,7 +7,9 @@ CREATE TABLE expenses ( ), po_number VARCHAR(50) NULL, document_path JSON, + realization_document_path JSON, expense_date DATE NOT NULL, + realization_date DATE, grand_total NUMERIC(15, 3) DEFAULT 0, note TEXT, created_by BIGINT, diff --git a/internal/entities/expense.go b/internal/entities/expense.go index b665a599..74998e6a 100644 --- a/internal/entities/expense.go +++ b/internal/entities/expense.go @@ -8,19 +8,21 @@ import ( ) type Expense struct { - Id uint64 `gorm:"primaryKey;autoIncrement"` - ReferenceNumber *string `gorm:"type:varchar(50);uniqueIndex"` - SupplierId *uint64 `gorm:""` - Category string `gorm:"type:varchar(50);not null"` - PoNumber *string `gorm:"type:varchar(50)"` - DocumentPath sql.NullString `gorm:"type:json"` - ExpenseDate time.Time `gorm:"type:date;not null"` - GrandTotal float64 `gorm:"type:numeric(15,3);default:0"` - Note *string `gorm:"type:text"` - CreatedBy *uint64 `gorm:""` - CreatedAt time.Time `gorm:"autoCreateTime"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + Id uint64 `gorm:"primaryKey;autoIncrement"` + ReferenceNumber string `gorm:"type:varchar(50);uniqueIndex"` + SupplierId uint64 `gorm:""` + Category string `gorm:"type:varchar(50);not null"` + PoNumber string `gorm:"type:varchar(50)"` + DocumentPath sql.NullString `gorm:"type:json"` // Dokumen pengajuan + RealizationDocumentPath sql.NullString `gorm:"type:json;column:realization_document_path"` // Dokumen realisasi + RealizationDate time.Time `gorm:"type:date;column:realization_date"` // Tanggal realisasi + ExpenseDate time.Time `gorm:"type:date;not null"` + GrandTotal float64 `gorm:"type:numeric(15,3);default:0"` + Note string `gorm:"type:text"` + CreatedBy uint64 `gorm:""` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // Relations Supplier *Supplier `gorm:"foreignKey:SupplierId;references:Id"` diff --git a/internal/entities/expense_nonstock.go b/internal/entities/expense_nonstock.go index ae2d02fe..d483e4bf 100644 --- a/internal/entities/expense_nonstock.go +++ b/internal/entities/expense_nonstock.go @@ -10,11 +10,12 @@ type ExpenseNonstock struct { Id uint64 `gorm:"primaryKey;autoIncrement"` ExpenseId *uint64 `gorm:""` ProjectFlockKandangId *uint64 `gorm:""` + KandangId *uint64 `gorm:""` NonstockId *uint64 `gorm:""` Qty float64 `gorm:"type:numeric(15,3);not null"` UnitPrice float64 `gorm:"type:numeric(15,3);not null"` TotalPrice float64 `gorm:"type:numeric(15,3);not null"` - Note *string `gorm:"type:text"` + Note string `gorm:"type:text"` CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` @@ -22,6 +23,7 @@ type ExpenseNonstock struct { // Relations Expense *Expense `gorm:"foreignKey:ExpenseId;references:Id"` ProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:ProjectFlockKandangId;references:Id"` + Kandang *Kandang `gorm:"foreignKey:KandangId;references:Id"` Nonstock *Nonstock `gorm:"foreignKey:NonstockId;references:Id"` - Realization *ExpenseRealization `gorm:"foreignKey:ExpenseNonstockId;references:Id"` + Realization *ExpenseRealization `gorm:"foreignKey:ExpenseNonstockId;references:Id"` } diff --git a/internal/modules/expenses/controllers/expense.controller.go b/internal/modules/expenses/controllers/expense.controller.go index 2d0bebac..08256b24 100644 --- a/internal/modules/expenses/controllers/expense.controller.go +++ b/internal/modules/expenses/controllers/expense.controller.go @@ -103,11 +103,23 @@ func (u *ExpenseController) CreateOne(c *fiber.Ctx) error { var singleCostPerKandang validation.CostPerKandang if err := json.Unmarshal([]byte(costPerKandangJSON), &singleCostPerKandang); err != nil { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid cost_per_kandang JSON: %v", err)) + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid cost_per_kandangs JSON: %v", err)) + } + + if singleCostPerKandang.KandangID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Field KandangID is required") } req.CostPerKandangs = []validation.CostPerKandang{singleCostPerKandang} + } else { + for i, costPerKandang := range req.CostPerKandangs { + if costPerKandang.KandangID == 0 { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Field KandangID is required for cost_per_kandangs[%d]", i)) + } + } } + } else { + return fiber.NewError(fiber.StatusBadRequest, "Field cost_per_kandangs is required") } result, err := u.ExpenseService.CreateOne(c, req) @@ -133,8 +145,30 @@ func (u *ExpenseController) UpdateOne(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") } - if err := c.BodyParser(req); err != nil { - return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + form, err := c.MultipartForm() + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid multipart form") + } + + req.Documents = form.File["documents"] + if transactionDate := c.FormValue("transaction_date"); transactionDate != "" { + req.TransactionDate = &transactionDate + } + + costPerKandangJSON := c.FormValue("cost_per_kandang") + if costPerKandangJSON != "" { + var costPerKandang []validation.CostPerKandang + if err := json.Unmarshal([]byte(costPerKandangJSON), &costPerKandang); err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid cost_per_kandang JSON: %v", err)) + } + + for i, costPerKandang := range costPerKandang { + if costPerKandang.KandangID == 0 { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Field KandangID is required for cost_per_kandang[%d]", i)) + } + } + + req.CostPerKandang = &costPerKandang } result, err := u.ExpenseService.UpdateOne(c, req, uint(id)) @@ -171,42 +205,45 @@ func (u *ExpenseController) DeleteOne(c *fiber.Ctx) error { }) } -func (u *ExpenseController) ApproveExpense(c *fiber.Ctx) error { - expenseID := c.Params("id") - id, err := strconv.Atoi(expenseID) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, "Invalid expense ID") - } +func (u *ExpenseController) Approval(c *fiber.Ctx) error { + req := new(validation.ApprovalRequest) - // Extract step from URL path (manager or finance) - path := c.Path() - var step string - if strings.Contains(path, "/approvals/manager") { - step = "Manager" - } else if strings.Contains(path, "/approvals/finance") { - step = "Finance" - } else { - return fiber.NewError(fiber.StatusBadRequest, "Invalid approval step") - } - - // Parse approval request - var req validation.ApprovalRequest - if err := c.BodyParser(&req); err != nil { + if err := c.BodyParser(req); err != nil { return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") } - // Approve expense - expense, err := u.ExpenseService.ApproveExpense(c, uint(id), step, req.Action, req.Notes) + path := c.Path() + approvalType := "" + if strings.Contains(path, "/approvals/manager") { + approvalType = "manager" + } else if strings.Contains(path, "/approvals/finance") { + approvalType = "finance" + } else { + return fiber.NewError(fiber.StatusBadRequest, "Invalid approval path") + } + + results, err := u.ExpenseService.Approval(c, req, approvalType) if err != nil { return err } + var ( + data interface{} + message = "Submit expense approval successfully" + ) + if len(results) == 1 { + data = results[0] + } else { + message = "Submit expense approvals successfully" + data = results + } + return c.Status(fiber.StatusOK). JSON(response.Success{ Code: fiber.StatusOK, Status: "success", - Message: "Approve expense successfully", - Data: expense, + Message: message, + Data: data, }) } @@ -224,8 +261,8 @@ func (u *ExpenseController) CreateRealization(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Invalid multipart form") } req.Documents = form.File["documents"] + req.RealizationDate = c.FormValue("realization_date") - // Parse realizations JSON realizationsJSON := c.FormValue("realizations") if realizationsJSON != "" { if err := json.Unmarshal([]byte(realizationsJSON), &req.Realizations); err != nil { @@ -255,8 +292,21 @@ func (u *ExpenseController) UpdateRealization(c *fiber.Ctx) error { } var req validation.UpdateRealization - if err := c.BodyParser(&req); err != nil { - return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + + form, err := c.MultipartForm() + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid multipart form") + } + + req.Documents = form.File["documents"] + + req.RealizationDate = c.FormValue("realization_date") + + realizationsJSON := c.FormValue("realizations") + if realizationsJSON != "" { + if err := json.Unmarshal([]byte(realizationsJSON), &req.Realizations); err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid realizations JSON: %v", err)) + } } expense, err := u.ExpenseService.UpdateRealization(c, uint(id), &req) @@ -293,3 +343,49 @@ func (u *ExpenseController) CompleteExpense(c *fiber.Ctx) error { Data: expense, }) } + +func (u *ExpenseController) DeleteDocument(c *fiber.Ctx) error { + expenseID, err := strconv.Atoi(c.Params("id")) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid expense ID") + } + + documentID, err := strconv.ParseUint(c.Params("documentId"), 10, 64) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid document ID") + } + + if err := u.ExpenseService.DeleteDocument(c, uint(expenseID), documentID, false); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete document successfully", + }) +} + +func (u *ExpenseController) DeleteRealizationDocument(c *fiber.Ctx) error { + expenseID, err := strconv.Atoi(c.Params("id")) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid expense ID") + } + + documentID, err := strconv.ParseUint(c.Params("documentId"), 10, 64) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid document ID") + } + + if err := u.ExpenseService.DeleteDocument(c, uint(expenseID), documentID, true); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete realization document successfully", + }) +} diff --git a/internal/modules/expenses/dto/expense.dto.go b/internal/modules/expenses/dto/expense.dto.go index 70afd4d4..d87502ed 100644 --- a/internal/modules/expenses/dto/expense.dto.go +++ b/internal/modules/expenses/dto/expense.dto.go @@ -2,7 +2,6 @@ package dto import ( "encoding/json" - "fmt" "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -13,21 +12,18 @@ import ( userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) -// === Base DTO === - type ExpenseBaseDTO struct { - Id uint64 `json:"id"` - ReferenceNumber string `json:"reference_number"` - PoNumber *string `json:"po_number"` - Category string `json:"category"` - Documents []string `json:"documents,omitempty"` - ExpenseDate time.Time `json:"expense_date"` - GrandTotal float64 `json:"grand_total"` + Id uint64 `json:"id"` + ReferenceNumber string `json:"reference_number"` + PoNumber string `json:"po_number"` + Category string `json:"category"` + Supplier *supplierDTO.SupplierBaseDTO `json:"supplier,omitempty"` + RealizationDate *time.Time `json:"realization_date,omitempty"` + ExpenseDate time.Time `json:"expense_date"` + GrandTotal float64 `json:"grand_total"` Location *locationDTO.LocationBaseDTO `json:"location,omitempty"` } -// === List DTO (untuk GetAll) === - type ExpenseListDTO struct { ExpenseBaseDTO CreatedUser *userDTO.UserBaseDTO `json:"created_user,omitempty"` @@ -36,95 +32,59 @@ type ExpenseListDTO struct { LatestApproval *approvalDTO.ApprovalBaseDTO `json:"latest_approval,omitempty"` } -// === Detail DTO (untuk GetOne) === - type ExpenseDetailDTO struct { ExpenseBaseDTO - Supplier *supplierDTO.SupplierBaseDTO `json:"supplier,omitempty"` - CreatedUser *userDTO.UserBaseDTO `json:"created_user,omitempty"` - Kandangs []KandangGroupDTO `json:"kandangs,omitempty"` - TotalPengajuan float64 `json:"total_pengajuan"` - TotalRealisasi float64 `json:"total_realisasi"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - LatestApproval *approvalDTO.ApprovalBaseDTO `json:"latest_approval,omitempty"` + Documents []DocumentDTO `json:"documents,omitempty"` + RealizationDocs []DocumentDTO `json:"realization_docs,omitempty"` + CreatedUser *userDTO.UserBaseDTO `json:"created_user,omitempty"` + Kandangs []KandangGroupDTO `json:"kandangs,omitempty"` + TotalPengajuan float64 `json:"total_pengajuan"` + TotalRealisasi float64 `json:"total_realisasi"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + LatestApproval *approvalDTO.ApprovalBaseDTO `json:"latest_approval,omitempty"` } -// === Nested DTO === - type ExpenseNonstockDTO struct { - Id uint64 `json:"id"` - Qty float64 `json:"qty"` - UnitPrice float64 `json:"unit_price"` - TotalPrice float64 `json:"total_price"` - Note *string `json:"note,omitempty"` - Nonstock *nonstockDTO.NonstockBaseDTO `json:"nonstock,omitempty"` - ProjectFlockKandang *ProjectFlockKandangNestedDTO `json:"project_flock_kandang,omitempty"` - Realization *ExpenseRealizationDTO `json:"realization,omitempty"` -} - -type ProjectFlockKandangNestedDTO struct { - Id uint64 `json:"id"` - KandangId uint64 `json:"kandang_id"` + Id uint64 `json:"id"` + Qty float64 `json:"qty"` + UnitPrice float64 `json:"unit_price"` + TotalPrice float64 `json:"total_price"` + Note *string `json:"note,omitempty"` + Nonstock *nonstockDTO.NonstockBaseDTO `json:"nonstock,omitempty"` } type ExpenseRealizationDTO struct { - Id uint64 `json:"id"` - Qty float64 `json:"qty"` - UnitPrice float64 `json:"unit_price"` - TotalPrice float64 `json:"total_price"` - Date time.Time `json:"date"` - Note *string `json:"note,omitempty"` -} - -type RealizationOnlyDTO struct { - Id uint64 `json:"id"` - Qty float64 `json:"qty"` - UnitPrice float64 `json:"unit_price"` - TotalPrice float64 `json:"total_price"` - Date time.Time `json:"date"` - Note *string `json:"note,omitempty"` - Nonstock *nonstockDTO.NonstockBaseDTO `json:"nonstock,omitempty"` - ProjectFlockKandang *ProjectFlockKandangNestedDTO `json:"project_flock_kandang,omitempty"` -} - -type KandangDTO struct { - Id uint64 `json:"id"` - KandangId uint64 `json:"kandang_id"` - Name string `json:"name,omitempty"` + Id uint64 `json:"id"` + Qty float64 `json:"qty"` + UnitPrice float64 `json:"unit_price"` + TotalPrice float64 `json:"total_price"` + Note *string `json:"note,omitempty"` + Nonstock *nonstockDTO.NonstockBaseDTO `json:"nonstock,omitempty"` } type KandangGroupDTO struct { - Id uint64 `json:"id"` - KandangId uint64 `json:"kandang_id"` - Name string `json:"name,omitempty"` - Pengajuans []ExpenseNonstockDTO `json:"pengajuans,omitempty"` - Realisasi []RealizationOnlyDTO `json:"realisasi,omitempty"` + Id uint64 `json:"id"` + KandangId uint64 `json:"kandang_id"` + Name string `json:"name,omitempty"` + Pengajuans []ExpenseNonstockDTO `json:"pengajuans,omitempty"` + Realisasi []ExpenseRealizationDTO `json:"realisasi,omitempty"` } -// === Helper Functions === - -func getStringValue(s *string) string { - if s == nil { - return "" - } - return *s +type DocumentDTO struct { + ID uint64 `json:"id"` + Path string `json:"path"` } -// === Mapper Functions === - func ToExpenseBaseDTO(e *entity.Expense) ExpenseBaseDTO { - var documents []string var location *locationDTO.LocationBaseDTO + var supplier *supplierDTO.SupplierBaseDTO - // 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 - } + var realizationDate *time.Time + if !e.RealizationDate.IsZero() { + realizationDate = &e.RealizationDate } - // 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) @@ -132,12 +92,18 @@ func ToExpenseBaseDTO(e *entity.Expense) ExpenseBaseDTO { } } + if e.Supplier != nil && e.Supplier.Id != 0 { + mapped := supplierDTO.ToSupplierBaseDTO(*e.Supplier) + supplier = &mapped + } + return ExpenseBaseDTO{ Id: e.Id, - ReferenceNumber: getStringValue(e.ReferenceNumber), - PoNumber: e.PoNumber, // Keep as pointer to allow null in JSON + ReferenceNumber: e.ReferenceNumber, + PoNumber: e.PoNumber, Category: e.Category, - Documents: documents, + Supplier: supplier, + RealizationDate: realizationDate, ExpenseDate: e.ExpenseDate, GrandTotal: e.GrandTotal, Location: location, @@ -175,18 +141,14 @@ func ToExpenseListDTOs(expenses []entity.Expense) []ExpenseListDTO { } func ToExpenseDetailDTO(e *entity.Expense) ExpenseDetailDTO { + var documents []DocumentDTO + var realizationDocs []DocumentDTO var createdUser *userDTO.UserBaseDTO if e.CreatedUser != nil && e.CreatedUser.Id != 0 { mapped := userDTO.ToUserBaseDTO(*e.CreatedUser) createdUser = &mapped } - var supplier *supplierDTO.SupplierBaseDTO - if e.Supplier != nil && e.Supplier.Id != 0 { - mapped := supplierDTO.ToSupplierBaseDTO(*e.Supplier) - supplier = &mapped - } - var latestApproval *approvalDTO.ApprovalBaseDTO if e.LatestApproval != nil { mapped := approvalDTO.ToApprovalDTO(*e.LatestApproval) @@ -194,51 +156,45 @@ func ToExpenseDetailDTO(e *entity.Expense) ExpenseDetailDTO { } var pengajuans []ExpenseNonstockDTO - var realisasi []RealizationOnlyDTO + var realisasi []ExpenseRealizationDTO + + if e.DocumentPath.Valid && e.DocumentPath.String != "" { + json.Unmarshal([]byte(e.DocumentPath.String), &documents) + } + + if e.RealizationDocumentPath.Valid && e.RealizationDocumentPath.String != "" { + json.Unmarshal([]byte(e.RealizationDocumentPath.String), &realizationDocs) + } if len(e.Nonstocks) > 0 { pengajuans = make([]ExpenseNonstockDTO, 0) - realisasi = make([]RealizationOnlyDTO, 0) + realisasi = make([]ExpenseRealizationDTO, 0) for _, ns := range e.Nonstocks { - // Create DTO without realization for pengajuans pengajuanDTO := ToExpenseNonstockDTO(ns) - pengajuanDTO.Realization = nil // Remove realization from pengajuan + pengajuans = append(pengajuans, pengajuanDTO) - // Create separate DTO with realization data if it exists if ns.Realization != nil && ns.Realization.Id != 0 { - // Create realization DTO with only realization data var nonstock *nonstockDTO.NonstockBaseDTO if ns.Nonstock != nil && ns.Nonstock.Id != 0 { mapped := nonstockDTO.ToNonstockBaseDTO(*ns.Nonstock) nonstock = &mapped } - var projectFlockKandang *ProjectFlockKandangNestedDTO - if ns.ProjectFlockKandang != nil && ns.ProjectFlockKandang.Id != 0 { - projectFlockKandang = &ProjectFlockKandangNestedDTO{ - Id: uint64(ns.ProjectFlockKandang.Id), - KandangId: uint64(ns.ProjectFlockKandang.KandangId), - } - } - - realisasiDTO := RealizationOnlyDTO{ - Id: ns.Realization.Id, - Qty: ns.Realization.RealizationQty, - UnitPrice: ns.Realization.RealizationUnitPrice, - TotalPrice: ns.Realization.RealizationTotalPrice, - Date: ns.Realization.RealizationDate, - Note: ns.Realization.Note, - Nonstock: nonstock, - ProjectFlockKandang: projectFlockKandang, + realisasiDTO := ExpenseRealizationDTO{ + Id: ns.Realization.Id, + Qty: ns.Realization.RealizationQty, + UnitPrice: ns.Realization.RealizationUnitPrice, + TotalPrice: ns.Realization.RealizationTotalPrice, + Note: ns.Realization.Note, + Nonstock: nonstock, } realisasi = append(realisasi, realisasiDTO) } } } - // Calculate total pengajuan and realisasi var totalPengajuan float64 for _, p := range pengajuans { totalPengajuan += p.TotalPrice @@ -249,55 +205,76 @@ func ToExpenseDetailDTO(e *entity.Expense) ExpenseDetailDTO { totalRealisasi += r.TotalPrice } - // Group pengajuans and realisasi by kandang kandangMap := make(map[uint64]*KandangGroupDTO) - // Process pengajuans for _, p := range pengajuans { - if p.ProjectFlockKandang != nil { - kandangId := p.ProjectFlockKandang.KandangId + var kandangId uint64 + var kandangName string + + for _, ns := range e.Nonstocks { + if ns.Id == p.Id && ns.Kandang != nil { + kandangId = uint64(ns.Kandang.Id) + kandangName = ns.Kandang.Name + break + } + } + + if kandangId > 0 { if kandangMap[kandangId] == nil { kandangMap[kandangId] = &KandangGroupDTO{ - Id: p.ProjectFlockKandang.Id, - KandangId: kandangId, - Name: fmt.Sprintf("Kandang %d", kandangId), + Id: kandangId, + KandangId: kandangId, + Name: kandangName, + Pengajuans: []ExpenseNonstockDTO{}, + Realisasi: []ExpenseRealizationDTO{}, } } kandangMap[kandangId].Pengajuans = append(kandangMap[kandangId].Pengajuans, p) } } - // Process realisasi for _, r := range realisasi { - if r.ProjectFlockKandang != nil { - kandangId := r.ProjectFlockKandang.KandangId + var kandangId uint64 + var kandangName string + + for _, ns := range e.Nonstocks { + if ns.Realization != nil && ns.Realization.Id == r.Id && ns.Kandang != nil { + kandangId = uint64(ns.Kandang.Id) + kandangName = ns.Kandang.Name + break + } + } + + if kandangId > 0 { if kandangMap[kandangId] == nil { kandangMap[kandangId] = &KandangGroupDTO{ - Id: r.ProjectFlockKandang.Id, - KandangId: kandangId, - Name: fmt.Sprintf("Kandang %d", kandangId), + Id: kandangId, + KandangId: kandangId, + Name: kandangName, + Pengajuans: []ExpenseNonstockDTO{}, + Realisasi: []ExpenseRealizationDTO{}, } } kandangMap[kandangId].Realisasi = append(kandangMap[kandangId].Realisasi, r) } } - // Convert map to slice var kandangs []KandangGroupDTO for _, k := range kandangMap { kandangs = append(kandangs, *k) } return ExpenseDetailDTO{ - ExpenseBaseDTO: ToExpenseBaseDTO(e), - Supplier: supplier, - CreatedUser: createdUser, - Kandangs: kandangs, - TotalPengajuan: totalPengajuan, - TotalRealisasi: totalRealisasi, - CreatedAt: e.CreatedAt, - UpdatedAt: e.UpdatedAt, - LatestApproval: latestApproval, + ExpenseBaseDTO: ToExpenseBaseDTO(e), + Documents: documents, + RealizationDocs: realizationDocs, + CreatedUser: createdUser, + Kandangs: kandangs, + TotalPengajuan: totalPengajuan, + TotalRealisasi: totalRealisasi, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + LatestApproval: latestApproval, } } @@ -308,39 +285,12 @@ func ToExpenseNonstockDTO(ns entity.ExpenseNonstock) ExpenseNonstockDTO { nonstock = &mapped } - var projectFlockKandang *ProjectFlockKandangNestedDTO - if ns.ProjectFlockKandang != nil && ns.ProjectFlockKandang.Id != 0 { - projectFlockKandang = &ProjectFlockKandangNestedDTO{ - Id: uint64(ns.ProjectFlockKandang.Id), - KandangId: uint64(ns.ProjectFlockKandang.KandangId), - } - } - - var realization *ExpenseRealizationDTO - if ns.Realization != nil && ns.Realization.Id != 0 { - mapped := ToExpenseRealizationDTO(*ns.Realization) - realization = &mapped - } - return ExpenseNonstockDTO{ - Id: ns.Id, - Qty: ns.Qty, - UnitPrice: ns.UnitPrice, - TotalPrice: ns.TotalPrice, - Note: ns.Note, - Nonstock: nonstock, - ProjectFlockKandang: projectFlockKandang, - Realization: realization, - } -} - -func ToExpenseRealizationDTO(r entity.ExpenseRealization) ExpenseRealizationDTO { - return ExpenseRealizationDTO{ - Id: r.Id, - Qty: r.RealizationQty, - UnitPrice: r.RealizationUnitPrice, - TotalPrice: r.RealizationTotalPrice, - Date: r.RealizationDate, - Note: r.Note, + Id: ns.Id, + Qty: ns.Qty, + UnitPrice: ns.UnitPrice, + TotalPrice: ns.TotalPrice, + Note: &ns.Note, + Nonstock: nonstock, } } diff --git a/internal/modules/expenses/route.go b/internal/modules/expenses/route.go index eafa5b7c..805cb886 100644 --- a/internal/modules/expenses/route.go +++ b/internal/modules/expenses/route.go @@ -25,9 +25,11 @@ func ExpenseRoutes(v1 fiber.Router, u user.UserService, s expense.ExpenseService route.Get("/:id", ctrl.GetOne) route.Patch("/:id", ctrl.UpdateOne) route.Delete("/:id", ctrl.DeleteOne) - route.Post("/:id/approvals/manager", ctrl.ApproveExpense) - route.Post("/:id/approvals/finance", ctrl.ApproveExpense) + route.Post("/approvals/manager", ctrl.Approval) + route.Post("/approvals/finance", ctrl.Approval) route.Post("/:id/realizations", ctrl.CreateRealization) route.Patch("/:id/realizations", ctrl.UpdateRealization) route.Post("/:id/complete", ctrl.CompleteExpense) + route.Delete("/:id/documents/:documentId", ctrl.DeleteDocument) + route.Delete("/:id/realization-documents/:documentId", ctrl.DeleteRealizationDocument) } diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index 3ed792fb..658537b7 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -2,9 +2,11 @@ package service import ( "context" + "database/sql" "encoding/json" "errors" "fmt" + "mime/multipart" "time" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" @@ -31,10 +33,11 @@ type ExpenseService interface { 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) + 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 { @@ -65,39 +68,14 @@ func (s expenseService) withRelations(db *gorm.DB) *gorm.DB { return db. Preload("CreatedUser"). Preload("Supplier"). - Preload("Nonstocks", func(db *gorm.DB) *gorm.DB { - return db.Preload("Nonstock").Preload("Realization").Preload("ProjectFlockKandang.Kandang.Location") - }) -} - -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 + Preload("Nonstocks.Nonstock"). + Preload("Nonstocks.Realization"). + Preload("Nonstocks.ProjectFlockKandang.Kandang.Location"). + Preload("Nonstocks.Kandang") } 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 } @@ -112,17 +90,16 @@ func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expens }) if err != nil { - s.Log.Errorf("Failed to get expenses: %+v", err) + return nil, 0, err } - // 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) + return nil, 0, err } expenses[i].LatestApproval = latestApproval } @@ -132,7 +109,26 @@ func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expens } func (s expenseService) GetOne(c *fiber.Ctx, id uint) (*expenseDto.ExpenseDetailDTO, error) { - return s.getExpenseWithDetails(c, id) + 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) { @@ -140,8 +136,8 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen 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) } @@ -195,6 +191,7 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen 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 { @@ -210,13 +207,13 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen createdBy := uint64(1) //todo get from auth expense = &entity.Expense{ - ReferenceNumber: &referenceNumber, + ReferenceNumber: referenceNumber, PoNumber: req.PoNumber, Category: req.Category, - SupplierId: &req.SupplierID, + SupplierId: req.SupplierID, ExpenseDate: expenseDate, GrandTotal: grandTotal, - CreatedBy: &createdBy, + CreatedBy: createdBy, } if err := expenseRepoTx.CreateOne(c.Context(), expense, nil); err != nil { @@ -227,31 +224,45 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen 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") - } + var projectFlockKandangId *uint64 - return fiber.NewError(fiber.StatusInternalServerError, "Failed to find project flock kandang for this kandang") + 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 { - projectFlockKandangId := uint64(projectFlockKandang.Id) 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, + ProjectFlockKandangId: projectFlockKandangId, + KandangId: kandangId, NonstockId: &nonstockId, Qty: costItem.Quantity, TotalPrice: costItem.TotalCost, - Note: &costItem.Notes, + Note: costItem.Notes, } if err := expenseNonstockRepoTx.CreateOne(c.Context(), expenseNonstock, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense cost item") } } @@ -268,36 +279,17 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen &approvalAction, createdByUint, 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 - 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") - } + if len(req.Documents) > 0 { + if err := s.processDocuments(c, expenseRepoTx, uint(expense.Id), req.Documents, false); err != nil { + return err } + } - return nil - }) + return nil + }) if err != nil { if fiberErr, ok := err.(*fiber.Error); ok { @@ -306,16 +298,18 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense") } - return s.getExpenseWithDetails(c, uint(expense.Id)) + 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 { - s.Log.Errorf("Validation failed for UpdateOne: %+v", err) return nil, 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)) @@ -324,41 +318,21 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) 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)) + 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.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 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") @@ -366,40 +340,36 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) updateBody["expense_date"] = expenseDate } - if req.PoNumber != nil { - updateBody["po_number"] = *req.PoNumber + 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 } - if len(updateBody) == 0 && req.CostPerKandang == nil { - return s.getExpenseWithDetails(c, id) - } - - // Update expense using transaction 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) - // 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 } } - // 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 { @@ -407,28 +377,40 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) } } - // 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)) + 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, "No active project flock kandang found for this kandang") + return fiber.NewError(fiber.StatusNotFound, "Expense not found") } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to find project flock kandang for this kandang") + 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 { - // Validate nonstock exists + nonstockId := uint(costItem.NonstockID) if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "Nonstock", ID: &nonstockId, Exists: s.NonstockRepo.IdExists}, @@ -436,46 +418,56 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) return err } - projectFlockKandangId := uint64(projectFlockKandang.Id) + 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, + ProjectFlockKandangId: projectFlockKandangId, + KandangId: kandangId, NonstockId: &costItem.NonstockID, Qty: costItem.Quantity, TotalPrice: costItem.TotalCost, - Note: &costItem.Notes, + 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 *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 _, 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") + + if len(req.Documents) > 0 { + if err := s.processDocuments(c, expenseRepoTx, id, req.Documents, false); err != nil { + return err + } } return nil @@ -488,11 +480,15 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update expense") } - return s.getExpenseWithDetails(c, id) + 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 { - // 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)) @@ -513,109 +509,6 @@ func (s expenseService) DeleteOne(c *fiber.Ctx, id uint) error { 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 @@ -628,7 +521,12 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va ); err != nil { return nil, err } - realizationDate := time.Now() + + 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 { @@ -636,17 +534,14 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va 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 - 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") + if err := s.validateExpenseNonstockRelation(c, expenseNonstockRepoTx, expenseID, expenseNonstockID); err != nil { + return err } _, err = realizationRepoTx.GetByExpenseNonstockID(c.Context(), uint64(expenseNonstockID)) @@ -671,6 +566,18 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va } } + 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(), @@ -689,11 +596,15 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va return nil, err } - return s.getExpenseWithDetails(c, expenseID) + 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) { - // 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)) @@ -704,27 +615,19 @@ func (s *expenseService) CompleteExpense(c *fiber.Ctx, id uint, notes *string) ( 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)) + 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 @@ -737,7 +640,7 @@ func (s *expenseService) CompleteExpense(c *fiber.Ctx, id uint, notes *string) ( &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") } @@ -748,7 +651,11 @@ func (s *expenseService) CompleteExpense(c *fiber.Ctx, id uint, notes *string) ( return nil, err } - return s.getExpenseWithDetails(c, id) + 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) { @@ -757,7 +664,6 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va 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)) @@ -766,46 +672,55 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va return nil, err } - // Use current date for realization date - realizationDate := time.Now() + 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("Cannot update realization at %s step. Must be at Realisasi step", 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 + } - // 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) + expenseRepoTx := repository.NewExpenseRepository(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") + if err := s.validateExpenseNonstockRelation(c, expenseNonstockRepoTx, expenseID, expenseNonstockID); err != nil { + return err } - // 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, + "realization_date": *realizationDate, } if realizationItem.Notes != nil { @@ -818,13 +733,275 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va } } + 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 + } + } + return nil }); err != nil { return nil, err } - // Return updated expense - return s.getExpenseWithDetails(c, expenseID) + 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: func(ctx context.Context, id uint) (bool, error) { + return s.Repository.IdExists(ctx, uint64(id)) + }}, + ); 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: func(ctx context.Context, id uint) (bool, error) { + return s.Repository.IdExists(ctx, uint64(id)) + }}, + ); 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) { @@ -845,11 +1022,29 @@ func (s *expenseService) generatePoNumber(ctx *gorm.DB, expenseID uint) (string, if err != nil { return "", err } - if expense.ReferenceNumber == nil { - return "", errors.New("reference number is required") - } - - poNum := fmt.Sprintf("PO-%s", *expense.ReferenceNumber) + 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 +// } diff --git a/internal/modules/expenses/validations/expense.validation.go b/internal/modules/expenses/validations/expense.validation.go index 56420e06..4e909b66 100644 --- a/internal/modules/expenses/validations/expense.validation.go +++ b/internal/modules/expenses/validations/expense.validation.go @@ -4,39 +4,31 @@ import ( "mime/multipart" ) -// ApprovalRequest is used for expense approval endpoints -type ApprovalRequest struct { - Action string `json:"action" validate:"required,oneof=APPROVED REJECTED"` - Notes *string `json:"notes"` -} - type Create struct { - PoNumber *string `form:"po_number" validate:"omitempty,max=50"` - 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"` - Documents []*multipart.FileHeader `form:"documents" validate:"omitempty,dive"` - CostPerKandangs []CostPerKandang `form:"cost_per_kandangs" validate:"required,min=1,dive"` + PoNumber string `form:"po_number" json:"po_number" validate:"omitempty,max=50"` + TransactionDate string `form:"transaction_date" json:"transaction_date" validate:"required,datetime=2006-01-02"` + Category string `form:"category" json:"category" validate:"required,oneof=BOP NON-BOP"` + SupplierID uint64 `form:"supplier_id" json:"supplier_id" validate:"required,gt=0"` + Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"` + CostPerKandangs []CostPerKandang `form:"cost_per_kandangs" json:"cost_per_kandangs" validate:"required,min=1,dive"` } type CostPerKandang struct { - KandangID uint64 `json:"kandang_id" form:"kandang_id" validate:"required,gt=0"` - CostItems []CostItem `json:"cost_items" form:"cost_items" validate:"required,min=1,dive"` + KandangID uint64 `form:"kandang_id" json:"kandang_id" validate:"required,gt=0"` + CostItems []CostItem `form:"cost_items" json:"cost_items" validate:"required,min=1,dive"` } type CostItem struct { - NonstockID uint64 `json:"nonstock_id" form:"nonstock_id" validate:"required,gt=0"` - Quantity float64 `json:"quantity" form:"quantity" validate:"required,gt=0"` - TotalCost float64 `json:"total_cost" form:"total_cost" validate:"required,gt=0"` - Notes string `json:"notes" form:"notes" validate:"required,max=500"` + NonstockID uint64 `form:"nonstock_id" json:"nonstock_id" validate:"required,gt=0"` + Quantity float64 `form:"quantity" json:"quantity" validate:"required,gt=0"` + TotalCost float64 `form:"total_cost" json:"total_cost" validate:"required,gt=0"` + Notes string `form:"notes" json:"notes" validate:"required,max=500"` } type Update struct { - PoNumber *string `json:"po_number,omitempty" validate:"omitempty,max=50"` - TransactionDate *string `json:"transaction_date,omitempty" validate:"omitempty,datetime=2006-01-02"` - SupplierID *uint64 `json:"supplier_id,omitempty" validate:"omitempty,gt=0"` - Documents *[]string `json:"documents,omitempty" validate:"omitempty,dive,max=255"` - CostPerKandang *[]CostPerKandang `json:"cost_per_kandang,omitempty" validate:"omitempty,min=1,dive"` + TransactionDate *string `form:"transaction_date" json:"transaction_date" validate:"omitempty,datetime=2006-01-02"` + CostPerKandang *[]CostPerKandang `form:"cost_per_kandang" json:"cost_per_kandang" validate:"omitempty,min=1,dive"` + Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"` } type Query struct { @@ -46,21 +38,27 @@ type Query struct { } type CreateRealization struct { - Documents []*multipart.FileHeader `form:"documents" validate:"omitempty,dive"` - Realizations []RealizationItem `json:"realizations" form:"realizations" validate:"required,min=1,dive"` -} - -type RealizationItem struct { - ExpenseNonstockID uint64 `json:"expense_nonstock_id" form:"expense_nonstock_id" validate:"required,gt=0"` - Qty float64 `json:"qty" form:"qty" validate:"required,gt=0"` - UnitPrice float64 `json:"unit_price" form:"unit_price" validate:"required,gt=0"` - TotalPrice float64 `json:"total_price" form:"total_price" validate:"required,gt=0"` - Notes *string `json:"notes" form:"notes" validate:"omitempty,max=500"` -} - -type CompleteExpense struct { + RealizationDate string `form:"realization_date" json:"realization_date" validate:"required,datetime=2006-01-02"` + Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"` + Realizations []RealizationItem `form:"realizations" json:"realizations" validate:"required,min=1,dive"` } type UpdateRealization struct { - Realizations []RealizationItem `json:"realizations" validate:"required,min=1,dive"` + RealizationDate string `form:"realization_date" json:"realization_date" validate:"omitempty,datetime=2006-01-02"` + Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"` + Realizations []RealizationItem `form:"realizations" json:"realizations" validate:"required,min=1,dive"` +} + +type RealizationItem struct { + ExpenseNonstockID uint64 `form:"expense_nonstock_id" json:"expense_nonstock_id" validate:"required,gt=0"` + Qty float64 `form:"qty" json:"qty" validate:"required,gt=0"` + UnitPrice float64 `form:"unit_price" json:"unit_price" validate:"required,gt=0"` + TotalPrice float64 `form:"total_price" json:"total_price" validate:"required,gt=0"` + Notes *string `form:"notes" json:"notes" validate:"omitempty,max=500"` +} + +type ApprovalRequest struct { + Action string `json:"action" form:"action" validate:"required,oneof=APPROVED REJECTED"` + ApprovableIds []uint `json:"approvable_ids" validate:"required,min=1,dive,gt=0"` + Notes *string `json:"notes" form:"notes"` }