diff --git a/internal/database/migrations/20251117034511_create_expenses_table.up.sql b/internal/database/migrations/20251117034511_create_expenses_table.up.sql index 054084e4..819e25f4 100644 --- a/internal/database/migrations/20251117034511_create_expenses_table.up.sql +++ b/internal/database/migrations/20251117034511_create_expenses_table.up.sql @@ -1,11 +1,11 @@ CREATE TABLE expenses ( id BIGSERIAL PRIMARY KEY, - reference_number VARCHAR, -- format => BOP-LTI-0001 = 0001 is increment + reference_number VARCHAR(50) UNIQUE NOT NULL, supplier_id BIGINT NULL, category VARCHAR(50) NOT NULL CHECK ( category IN ('BOP', 'NON-BOP') ), - po_number VARCHAR(50) UNIQUE NOT NULL, + po_number VARCHAR(50) NULL, document_path JSON, expense_date DATE NOT NULL, grand_total NUMERIC(15, 3) DEFAULT 0, @@ -16,6 +16,8 @@ CREATE TABLE expenses ( deleted_at TIMESTAMPTZ ); +CREATE SEQUENCE expenses_ref_seq INCREMENT BY 1 START WITH 1; + -- Tambahkan Foreign Key ke suppliers DO $$ BEGIN @@ -23,7 +25,9 @@ BEGIN ALTER TABLE expenses ADD CONSTRAINT fk_expenses_supplier_id FOREIGN KEY (supplier_id) REFERENCES suppliers(id); - END IF; + +END IF; + END $$; -- Tambahkan Foreign Key ke users (created_by) diff --git a/internal/database/migrations/20251117034529_create_expense_nonstocks_table.down.sql b/internal/database/migrations/20251117034529_create_expense_nonstocks_table.down.sql index 70fcf148..89bd80a6 100644 --- a/internal/database/migrations/20251117034529_create_expense_nonstocks_table.down.sql +++ b/internal/database/migrations/20251117034529_create_expense_nonstocks_table.down.sql @@ -1 +1,3 @@ -DROP TABLE IF EXISTS expense_nonstocks; \ No newline at end of file +DROP TABLE IF EXISTS expense_nonstocks; + +DROP SEQUENCE expenses_ref_seq; \ No newline at end of file diff --git a/internal/database/migrations/20251117034529_create_expense_nonstocks_table.up.sql b/internal/database/migrations/20251117034529_create_expense_nonstocks_table.up.sql index 5b0c2c16..330c4d7b 100644 --- a/internal/database/migrations/20251117034529_create_expense_nonstocks_table.up.sql +++ b/internal/database/migrations/20251117034529_create_expense_nonstocks_table.up.sql @@ -1,7 +1,8 @@ CREATE TABLE expense_nonstocks ( id BIGSERIAL PRIMARY KEY, - expense_id BIGINT, - project_flock_kandang_id BIGINT, + expense_id BIGINT NOT NULL, + project_flock_kandang_id BIGINT NULL, + kandang_id BIGINT NULL, nonstock_id BIGINT, qty NUMERIC(15, 3) NOT NULL, unit_price NUMERIC(15, 3) NOT NULL, @@ -32,6 +33,16 @@ BEGIN END IF; END $$; +-- Tambahkan Foreign key ke kandang_id +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'kandangs') THEN + ALTER TABLE expense_nonstocks + ADD CONSTRAINT fk_expense_nonstocks_kandang_id_2 + FOREIGN KEY (kandang_id) REFERENCES kandangs(id); + END IF; +END $$; + -- Tambahkan Foreign Key ke nonstocks DO $$ BEGIN diff --git a/internal/database/migrations/20251117034538_create_expense_realizations_table.up.sql b/internal/database/migrations/20251117034538_create_expense_realizations_table.up.sql index 4a8dc148..0886cd7a 100644 --- a/internal/database/migrations/20251117034538_create_expense_realizations_table.up.sql +++ b/internal/database/migrations/20251117034538_create_expense_realizations_table.up.sql @@ -1,6 +1,6 @@ CREATE TABLE expense_realizations ( id BIGSERIAL PRIMARY KEY, - expense_nonstock_id BIGINT, + expense_nonstock_id BIGINT UNIQUE, realization_qty NUMERIC(15, 3) NOT NULL, realization_unit_price NUMERIC(15, 3) NOT NULL, realization_total_price NUMERIC(15, 3) NOT NULL, diff --git a/internal/entities/expense.go b/internal/entities/expense.go index 286eaf51..b665a599 100644 --- a/internal/entities/expense.go +++ b/internal/entities/expense.go @@ -9,10 +9,10 @@ import ( type Expense struct { Id uint64 `gorm:"primaryKey;autoIncrement"` - ReferenceNumber *string `gorm:"type:varchar(50)"` + ReferenceNumber *string `gorm:"type:varchar(50);uniqueIndex"` SupplierId *uint64 `gorm:""` Category string `gorm:"type:varchar(50);not null"` - PoNumber string `gorm:"uniqueIndex;not null;type:varchar(50)"` + 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"` diff --git a/internal/entities/expense_nonstock.go b/internal/entities/expense_nonstock.go index eb27efef..ae2d02fe 100644 --- a/internal/entities/expense_nonstock.go +++ b/internal/entities/expense_nonstock.go @@ -23,5 +23,5 @@ type ExpenseNonstock struct { Expense *Expense `gorm:"foreignKey:ExpenseId;references:Id"` ProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:ProjectFlockKandangId;references:Id"` Nonstock *Nonstock `gorm:"foreignKey:NonstockId;references:Id"` - Realizations []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 074f2f0a..3a9d76bb 100644 --- a/internal/modules/expenses/controllers/expense.controller.go +++ b/internal/modules/expenses/controllers/expense.controller.go @@ -1,8 +1,11 @@ package controller import ( + "encoding/json" + "fmt" "math" "strconv" + "strings" "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/dto" service "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/services" @@ -29,7 +32,7 @@ func (u *ExpenseController) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), } - if query.Page < 1 || query.Limit < 1 { + if query.Page < 1 || query.Limit < 1 { return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") } @@ -49,7 +52,7 @@ func (u *ExpenseController) GetAll(c *fiber.Ctx) error { TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), TotalResults: totalResults, }, - Data: dto.ToExpenseListDTOs(result), + Data: result, }) } @@ -71,15 +74,39 @@ func (u *ExpenseController) GetOne(c *fiber.Ctx) error { Code: fiber.StatusOK, Status: "success", Message: "Get expense successfully", - Data: dto.ToExpenseListDTO(*result), + Data: result, }) } func (u *ExpenseController) CreateOne(c *fiber.Ctx) error { req := new(validation.Create) - if err := c.BodyParser(req); err != nil { - return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + req.TransactionDate = c.FormValue("transaction_date") + + supplierID, err := strconv.ParseUint(c.FormValue("supplier_id"), 10, 64) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid supplier_id format") + } + req.SupplierID = supplierID + + form, err := c.MultipartForm() + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid multipart form") + } + req.Documents = form.File["documents"] + + costPerKandangJSON := c.FormValue("cost_per_kandangs") + if costPerKandangJSON != "" { + + if err := json.Unmarshal([]byte(costPerKandangJSON), &req.CostPerKandangs); err != nil { + + 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)) + } + + req.CostPerKandangs = []validation.CostPerKandang{singleCostPerKandang} + } } result, err := u.ExpenseService.CreateOne(c, req) @@ -92,7 +119,7 @@ func (u *ExpenseController) CreateOne(c *fiber.Ctx) error { Code: fiber.StatusCreated, Status: "success", Message: "Create expense successfully", - Data: dto.ToExpenseListDTO(*result), + Data: result, }) } @@ -119,7 +146,7 @@ func (u *ExpenseController) UpdateOne(c *fiber.Ctx) error { Code: fiber.StatusOK, Status: "success", Message: "Update expense successfully", - Data: dto.ToExpenseListDTO(*result), + Data: result, }) } @@ -142,3 +169,126 @@ func (u *ExpenseController) DeleteOne(c *fiber.Ctx) error { Message: "Delete expense successfully", }) } + +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") + } + + // 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 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + // Approve expense + expense, err := u.ExpenseService.ApproveExpense(c, uint(id), step, req.Action, req.Notes) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Approve expense successfully", + Data: expense, + }) +} + +func (u *ExpenseController) CreateRealization(c *fiber.Ctx) error { + expenseID := c.Params("id") + id, err := strconv.Atoi(expenseID) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid expense ID") + } + + req := new(validation.CreateRealization) + + form, err := c.MultipartForm() + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid multipart form") + } + req.Documents = form.File["documents"] + + // Parse realizations JSON + 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.CreateRealization(c, uint(id), req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create realization successfully", + Data: expense, + }) +} + +func (u *ExpenseController) UpdateRealization(c *fiber.Ctx) error { + expenseID := c.Params("id") + id, err := strconv.Atoi(expenseID) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid expense ID") + } + + var req validation.UpdateRealization + if err := c.BodyParser(&req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + expense, err := u.ExpenseService.UpdateRealization(c, uint(id), &req) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Update realization successfully", + Data: expense, + }) +} + +func (u *ExpenseController) CompleteExpense(c *fiber.Ctx) error { + expenseID := c.Params("id") + id, err := strconv.Atoi(expenseID) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid expense ID") + } + + expense, err := u.ExpenseService.CompleteExpense(c, uint(id), nil) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Complete expense successfully", + Data: expense, + }) +} diff --git a/internal/modules/expenses/dto/expense.dto.go b/internal/modules/expenses/dto/expense.dto.go index b5fca80f..1f1fecef 100644 --- a/internal/modules/expenses/dto/expense.dto.go +++ b/internal/modules/expenses/dto/expense.dto.go @@ -1,68 +1,322 @@ package dto import ( + "fmt" "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/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" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) -// === DTO Structs === +// === Base DTO === type ExpenseBaseDTO struct { - Id uint64 `json:"id"` - PoNumber string `json:"po_number"` - 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,omitempty"` + Category string `json:"category"` + ExpenseDate time.Time `json:"expense_date"` + GrandTotal float64 `json:"grand_total"` } +// === List DTO (untuk GetAll) === + type ExpenseListDTO struct { - Id uint64 `json:"id"` - ReferenceNumber string `json:"reference_number"` - PoNumber string `json:"po_number"` - Category string `json:"category"` - ExpenseDate time.Time `json:"expense_date"` - GrandTotal float64 `json:"grand_total"` - CreatedUser *userDTO.UserBaseDTO `json:"created_user,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ExpenseBaseDTO + CreatedUser *userDTO.UserBaseDTO `json:"created_user,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + 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"` +} + +// === 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"` +} + +type ExpenseRealizationDTO struct { + Id uint64 `json:"id"` + RealizationQty float64 `json:"realization_qty"` + RealizationUnitPrice float64 `json:"realization_unit_price"` + RealizationTotalPrice float64 `json:"realization_total_price"` + RealizationDate time.Time `json:"realization_date"` + Note *string `json:"note,omitempty"` +} + +type RealizationOnlyDTO struct { + Id uint64 `json:"id"` + RealizationQty float64 `json:"realization_qty"` + RealizationUnitPrice float64 `json:"realization_unit_price"` + RealizationTotalPrice float64 `json:"realization_total_price"` + RealizationDate time.Time `json:"realization_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"` +} + +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"` +} + +// === Helper Functions === + +func getStringValue(s *string) string { + if s == nil { + return "" + } + return *s } // === Mapper Functions === -func ToExpenseBaseDTO(e entity.Expense) ExpenseBaseDTO { +func ToExpenseBaseDTO(e *entity.Expense) ExpenseBaseDTO { return ExpenseBaseDTO{ - Id: e.Id, - PoNumber: e.PoNumber, - ExpenseDate: e.ExpenseDate, - GrandTotal: e.GrandTotal, - } -} - -func ToExpenseListDTO(e entity.Expense) ExpenseListDTO { - var createdUser *userDTO.UserBaseDTO - if e.CreatedUser.Id != 0 { - mapped := userDTO.ToUserBaseDTO(*e.CreatedUser) - createdUser = &mapped - } - - return ExpenseListDTO{ Id: e.Id, - ReferenceNumber: *e.ReferenceNumber, + ReferenceNumber: getStringValue(e.ReferenceNumber), PoNumber: e.PoNumber, Category: e.Category, ExpenseDate: e.ExpenseDate, GrandTotal: e.GrandTotal, - CreatedAt: e.CreatedAt, - UpdatedAt: e.UpdatedAt, - CreatedUser: createdUser, } } -func ToExpenseListDTOs(e []entity.Expense) []ExpenseListDTO { - result := make([]ExpenseListDTO, len(e)) - for i, r := range e { - result[i] = ToExpenseListDTO(r) +func ToExpenseListDTO(e *entity.Expense) ExpenseListDTO { + var createdUser *userDTO.UserBaseDTO + if e.CreatedUser != nil && e.CreatedUser.Id != 0 { + mapped := userDTO.ToUserBaseDTO(*e.CreatedUser) + createdUser = &mapped + } + + var latestApproval *approvalDTO.ApprovalBaseDTO + if e.LatestApproval != nil { + mapped := approvalDTO.ToApprovalDTO(*e.LatestApproval) + latestApproval = &mapped + } + + return ExpenseListDTO{ + ExpenseBaseDTO: ToExpenseBaseDTO(e), + CreatedUser: createdUser, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + LatestApproval: latestApproval, + } +} + +func ToExpenseListDTOs(expenses []entity.Expense) []ExpenseListDTO { + result := make([]ExpenseListDTO, len(expenses)) + for i, expense := range expenses { + result[i] = ToExpenseListDTO(&expense) } return result } + +func ToExpenseDetailDTO(e *entity.Expense) ExpenseDetailDTO { + 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) + latestApproval = &mapped + } + + var pengajuans []ExpenseNonstockDTO + var realisasi []RealizationOnlyDTO + + if len(e.Nonstocks) > 0 { + pengajuans = make([]ExpenseNonstockDTO, 0) + realisasi = make([]RealizationOnlyDTO, 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, + RealizationQty: ns.Realization.RealizationQty, + RealizationUnitPrice: ns.Realization.RealizationUnitPrice, + RealizationTotalPrice: ns.Realization.RealizationTotalPrice, + RealizationDate: ns.Realization.RealizationDate, + Note: ns.Realization.Note, + Nonstock: nonstock, + ProjectFlockKandang: projectFlockKandang, + } + realisasi = append(realisasi, realisasiDTO) + } + } + } + + // Calculate total pengajuan and realisasi + var totalPengajuan float64 + for _, p := range pengajuans { + totalPengajuan += p.TotalPrice + } + + var totalRealisasi float64 + for _, r := range realisasi { + totalRealisasi += r.RealizationTotalPrice + } + + // 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 + if kandangMap[kandangId] == nil { + kandangMap[kandangId] = &KandangGroupDTO{ + Id: p.ProjectFlockKandang.Id, + KandangId: kandangId, + Name: fmt.Sprintf("Kandang %d", kandangId), + } + } + kandangMap[kandangId].Pengajuans = append(kandangMap[kandangId].Pengajuans, p) + } + } + + // Process realisasi + for _, r := range realisasi { + if r.ProjectFlockKandang != nil { + kandangId := r.ProjectFlockKandang.KandangId + if kandangMap[kandangId] == nil { + kandangMap[kandangId] = &KandangGroupDTO{ + Id: r.ProjectFlockKandang.Id, + KandangId: kandangId, + Name: fmt.Sprintf("Kandang %d", kandangId), + } + } + 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, + } +} + +func ToExpenseNonstockDTO(ns entity.ExpenseNonstock) ExpenseNonstockDTO { + 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), + } + } + + 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, + RealizationQty: r.RealizationQty, + RealizationUnitPrice: r.RealizationUnitPrice, + RealizationTotalPrice: r.RealizationTotalPrice, + RealizationDate: r.RealizationDate, + Note: r.Note, + } +} diff --git a/internal/modules/expenses/module.go b/internal/modules/expenses/module.go index c9b2ab66..2f71a349 100644 --- a/internal/modules/expenses/module.go +++ b/internal/modules/expenses/module.go @@ -1,15 +1,25 @@ package expenses import ( + "fmt" + "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "gorm.io/gorm" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" rExpense "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" sExpense "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/services" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + rNonstock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/repositories" + rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" + rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + + "gitlab.com/mbugroup/lti-api.git/internal/utils" ) type ExpenseModule struct{} @@ -17,10 +27,21 @@ type ExpenseModule struct{} func (ExpenseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { expenseRepo := rExpense.NewExpenseRepository(db) userRepo := rUser.NewUserRepository(db) + supplierRepo := rSupplier.NewSupplierRepository(db) + nonstockRepo := rNonstock.NewNonstockRepository(db) + approvalRepo := commonRepo.NewApprovalRepository(db) + realizationRepo := rExpense.NewExpenseRealizationRepository(db) + projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) - expenseService := sExpense.NewExpenseService(expenseRepo, validate) + approvalSvc := commonSvc.NewApprovalService(approvalRepo) + + // Register workflow steps for EXPENSES approval + if err := approvalSvc.RegisterWorkflowSteps(utils.ApprovalWorkflowExpense, utils.ExpenseApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register expense approval workflow: %v", err)) + } + + expenseService := sExpense.NewExpenseService(expenseRepo, supplierRepo, nonstockRepo, approvalSvc, realizationRepo, projectFlockKandangRepo, validate) userService := sUser.NewUserService(userRepo, validate) ExpenseRoutes(router, userService, expenseService) } - diff --git a/internal/modules/expenses/repositories/expense.repository.go b/internal/modules/expenses/repositories/expense.repository.go index 8cc580be..588583da 100644 --- a/internal/modules/expenses/repositories/expense.repository.go +++ b/internal/modules/expenses/repositories/expense.repository.go @@ -11,6 +11,8 @@ import ( type ExpenseRepository interface { repository.BaseRepository[entity.Expense] IdExists(ctx context.Context, id uint64) (bool, error) + GetNextSequence(ctx context.Context) (int, error) + GetWithSupplier(ctx context.Context, id uint64) (*entity.Expense, error) } type ExpenseRepositoryImpl struct { @@ -26,3 +28,24 @@ func NewExpenseRepository(db *gorm.DB) ExpenseRepository { func (r *ExpenseRepositoryImpl) IdExists(ctx context.Context, id uint64) (bool, error) { return repository.Exists[entity.Expense](ctx, r.DB(), uint(id)) } + +func (r *ExpenseRepositoryImpl) GetNextSequence(ctx context.Context) (int, error) { + var sequence int + err := r.DB().Raw("SELECT nextval('expenses_ref_seq')").Scan(&sequence).Error + if err != nil { + return 0, err + } + return sequence, nil +} + +func (r *ExpenseRepositoryImpl) GetWithSupplier(ctx context.Context, id uint64) (*entity.Expense, error) { + var expense entity.Expense + err := r.DB().WithContext(ctx). + Where("id = ?", id). + Preload("Supplier"). + First(&expense).Error + if err != nil { + return nil, err + } + return &expense, nil +} diff --git a/internal/modules/expenses/repositories/expense_nonstock.repository.go b/internal/modules/expenses/repositories/expense_nonstock.repository.go index 257a3034..acda512b 100644 --- a/internal/modules/expenses/repositories/expense_nonstock.repository.go +++ b/internal/modules/expenses/repositories/expense_nonstock.repository.go @@ -11,6 +11,8 @@ import ( type ExpenseNonstockRepository interface { repository.BaseRepository[entity.ExpenseNonstock] IdExists(ctx context.Context, id uint64) (bool, error) + GetByExpenseID(ctx context.Context, expenseID uint64, id uint64) (bool, error) + GetWithRelations(ctx context.Context, id uint64) (*entity.ExpenseNonstock, error) } type ExpenseNonstockRepositoryImpl struct { @@ -26,3 +28,28 @@ func NewExpenseNonstockRepository(db *gorm.DB) ExpenseNonstockRepository { func (r *ExpenseNonstockRepositoryImpl) IdExists(ctx context.Context, id uint64) (bool, error) { return repository.Exists[entity.ExpenseNonstock](ctx, r.DB(), uint(id)) } + +func (r *ExpenseNonstockRepositoryImpl) GetByExpenseID(ctx context.Context, expenseID uint64, id uint64) (bool, error) { + var count int64 + err := r.DB().WithContext(ctx).Model(&entity.ExpenseNonstock{}). + Where("id = ? AND expense_id = ?", id, expenseID). + Count(&count).Error + if err != nil { + return false, err + } + return count > 0, nil +} + +func (r *ExpenseNonstockRepositoryImpl) GetWithRelations(ctx context.Context, id uint64) (*entity.ExpenseNonstock, error) { + var expenseNonstock entity.ExpenseNonstock + err := r.DB().WithContext(ctx). + Where("id = ?", id). + Preload("Nonstock", func(db *gorm.DB) *gorm.DB { + return db.Preload("Suppliers") + }). + First(&expenseNonstock).Error + if err != nil { + return nil, err + } + return &expenseNonstock, nil +} diff --git a/internal/modules/expenses/repositories/expense_realization.repository.go b/internal/modules/expenses/repositories/expense_realization.repository.go index 0245f8a2..77f075f7 100644 --- a/internal/modules/expenses/repositories/expense_realization.repository.go +++ b/internal/modules/expenses/repositories/expense_realization.repository.go @@ -11,6 +11,7 @@ import ( type ExpenseRealizationRepository interface { repository.BaseRepository[entity.ExpenseRealization] IdExists(ctx context.Context, id uint64) (bool, error) + GetByExpenseNonstockID(ctx context.Context, expenseNonstockID uint64) (*entity.ExpenseRealization, error) } type ExpenseRealizationRepositoryImpl struct { @@ -26,3 +27,14 @@ func NewExpenseRealizationRepository(db *gorm.DB) ExpenseRealizationRepository { func (r *ExpenseRealizationRepositoryImpl) IdExists(ctx context.Context, id uint64) (bool, error) { return repository.Exists[entity.ExpenseRealization](ctx, r.DB(), uint(id)) } + +func (r *ExpenseRealizationRepositoryImpl) GetByExpenseNonstockID(ctx context.Context, expenseNonstockID uint64) (*entity.ExpenseRealization, error) { + var realization entity.ExpenseRealization + err := r.DB().WithContext(ctx). + Where("expense_nonstock_id = ?", expenseNonstockID). + First(&realization).Error + if err != nil { + return nil, err + } + return &realization, nil +} diff --git a/internal/modules/expenses/route.go b/internal/modules/expenses/route.go index 49a4e7c5..eafa5b7c 100644 --- a/internal/modules/expenses/route.go +++ b/internal/modules/expenses/route.go @@ -25,4 +25,9 @@ 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("/:id/realizations", ctrl.CreateRealization) + route.Patch("/:id/realizations", ctrl.UpdateRealization) + route.Post("/:id/complete", ctrl.CompleteExpense) } diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index 6f794e54..e0d2b343 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -1,12 +1,22 @@ package service import ( + "context" "errors" + "fmt" + "time" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + expenseDto "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/dto" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/validations" + nonstockRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/repositories" + supplierRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" + projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" + approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -15,33 +25,78 @@ import ( ) type ExpenseService interface { - GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Expense, int64, error) - GetOne(ctx *fiber.Ctx, id uint) (*entity.Expense, error) - CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Expense, error) - UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Expense, error) + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]expenseDto.ExpenseListDTO, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*expenseDto.ExpenseDetailDTO, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*expenseDto.ExpenseDetailDTO, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*expenseDto.ExpenseDetailDTO, error) DeleteOne(ctx *fiber.Ctx, id uint) error + ApproveExpense(ctx *fiber.Ctx, id uint, step string, action string, notes *string) (*expenseDto.ExpenseDetailDTO, error) + CreateRealization(ctx *fiber.Ctx, expenseID uint, req *validation.CreateRealization) (*expenseDto.ExpenseDetailDTO, error) + CompleteExpense(ctx *fiber.Ctx, id uint, notes *string) (*expenseDto.ExpenseDetailDTO, error) + UpdateRealization(ctx *fiber.Ctx, expenseID uint, req *validation.UpdateRealization) (*expenseDto.ExpenseDetailDTO, error) } type expenseService struct { - Log *logrus.Logger - Validate *validator.Validate - Repository repository.ExpenseRepository + Log *logrus.Logger + Validate *validator.Validate + Repository repository.ExpenseRepository + SupplierRepo supplierRepo.SupplierRepository + NonstockRepo nonstockRepo.NonstockRepository + ApprovalSvc commonSvc.ApprovalService + RealizationRepository repository.ExpenseRealizationRepository + ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository } -func NewExpenseService(repo repository.ExpenseRepository, validate *validator.Validate) ExpenseService { +func NewExpenseService(repo repository.ExpenseRepository, supplierRepo supplierRepo.SupplierRepository, nonstockRepo nonstockRepo.NonstockRepository, approvalSvc commonSvc.ApprovalService, realizationRepo repository.ExpenseRealizationRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, validate *validator.Validate) ExpenseService { return &expenseService{ - Log: utils.Log, - Validate: validate, - Repository: repo, + Log: utils.Log, + Validate: validate, + Repository: repo, + SupplierRepo: supplierRepo, + NonstockRepo: nonstockRepo, + ApprovalSvc: approvalSvc, + RealizationRepository: realizationRepo, + ProjectFlockKandangRepo: projectFlockKandangRepo, } } func (s expenseService) withRelations(db *gorm.DB) *gorm.DB { - return db.Preload("CreatedUser") + return db. + Preload("CreatedUser"). + Preload("Supplier"). + Preload("Nonstocks", func(db *gorm.DB) *gorm.DB { + return db.Preload("Nonstock").Preload("Realization").Preload("ProjectFlockKandang") + }) } -func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Expense, int64, error) { +func (s expenseService) getExpenseWithDetails(c *fiber.Ctx, expenseId uint) (*expenseDto.ExpenseDetailDTO, error) { + expense, err := s.Repository.GetByID(c.Context(), expenseId, s.withRelations) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Expense not found for ID %d: %+v", expenseId, err) + return nil, fiber.NewError(fiber.StatusNotFound, "Expense not found") + } + s.Log.Errorf("Failed to get expense for ID %d: %+v", expenseId, err) + return nil, err + } + + // Load latest approval with ActionUser + latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, expenseId, func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + }) + if err != nil { + // Don't fail if approval loading fails, just log + s.Log.Warnf("Failed to load approval for expense %d: %+v", expenseId, err) + } + expense.LatestApproval = latestApproval + + responseDTO := expenseDto.ToExpenseDetailDTO(expense) + return &responseDTO, nil +} + +func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expenseDto.ExpenseListDTO, int64, error) { if err := s.Validate.Struct(params); err != nil { + s.Log.Errorf("Validation failed for GetAll: %+v", err) return nil, 0, err } @@ -50,7 +105,7 @@ func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity expenses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.Search != "" { - return db.Where("name LIKE ?", "%"+params.Search+"%") + return db.Where("category LIKE ?", "%"+params.Search+"%") } return db.Order("created_at DESC").Order("updated_at DESC") }) @@ -59,57 +114,218 @@ func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity s.Log.Errorf("Failed to get expenses: %+v", err) return nil, 0, err } - return expenses, total, nil + + // Load approvals for each expense + for i := range expenses { + latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, uint(expenses[i].Id), func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + }) + if err != nil { + s.Log.Warnf("Failed to load approval for expense %d: %+v", expenses[i].Id, err) + } + expenses[i].LatestApproval = latestApproval + } + + result := expenseDto.ToExpenseListDTOs(expenses) + return result, total, nil } -func (s expenseService) GetOne(c *fiber.Ctx, id uint) (*entity.Expense, error) { - expense, err := s.Repository.GetByID(c.Context(), id, s.withRelations) - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Expense not found") +func (s expenseService) GetOne(c *fiber.Ctx, id uint) (*expenseDto.ExpenseDetailDTO, error) { + return s.getExpenseWithDetails(c, id) +} + +func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expenseDto.ExpenseDetailDTO, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err } + + // Validate supplier exists using common service + supplierID := uint(req.SupplierID) + supplierExistsFunc := func(ctx context.Context, id uint) (bool, error) { + return commonRepo.Exists[entity.Supplier](ctx, s.SupplierRepo.DB(), id) + } + if err := commonSvc.EnsureRelations(c.Context(), + commonSvc.RelationCheck{Name: "Supplier", ID: &supplierID, Exists: supplierExistsFunc}, + ); err != nil { + return nil, err + } + + // Get supplier for category + supplierEntity, err := s.SupplierRepo.GetByID(c.Context(), uint(req.SupplierID), nil) if err != nil { - s.Log.Errorf("Failed get expense by id: %+v", err) - return nil, err + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Supplier not found") + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get supplier") } - return expense, nil + + for _, costPerKandang := range req.CostPerKandangs { + for _, costItem := range costPerKandang.CostItems { + nonstockId := uint(costItem.NonstockID) + + if err := commonSvc.EnsureRelations(c.Context(), + commonSvc.RelationCheck{Name: "Nonstock", ID: &nonstockId, Exists: s.NonstockRepo.IdExists}, + ); err != nil { + return nil, err + } + + nonstockEntity, err := s.NonstockRepo.GetByID(c.Context(), nonstockId, func(db *gorm.DB) *gorm.DB { + return db.Preload("Suppliers") + }) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Nonstock not found") + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get nonstock") + } + + supplierFound := false + for _, sn := range nonstockEntity.Suppliers { + if uint64(sn.Id) == req.SupplierID { + supplierFound = true + break + } + } + if !supplierFound { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Nonstock ID %d does not belong to supplier ID %d", nonstockId, req.SupplierID)) + } + } + } + + expenseDate, err := utils.ParseDateString(req.TransactionDate) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transaction_date format") + } + + var expense *entity.Expense + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + + expenseRepoTx := repository.NewExpenseRepository(dbTransaction) + expenseNonstockRepoTx := repository.NewExpenseNonstockRepository(dbTransaction) + approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) + + referenceNumber, err := s.generateReferenceNumber(dbTransaction) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to generate reference number") + } + + var grandTotal float64 + for _, costPerKandang := range req.CostPerKandangs { + for _, costItem := range costPerKandang.CostItems { + grandTotal += costItem.TotalCost + } + } + + createdBy := uint64(1) //todo get from auth + expense = &entity.Expense{ + ReferenceNumber: &referenceNumber, + PoNumber: req.PoNumber, + Category: supplierEntity.Category, + SupplierId: &req.SupplierID, + ExpenseDate: expenseDate, + GrandTotal: grandTotal, + CreatedBy: &createdBy, + } + + if err := expenseRepoTx.CreateOne(c.Context(), expense, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense") + } + + if len(req.CostPerKandangs) > 0 { + + for _, costPerKandang := range req.CostPerKandangs { + + projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(dbTransaction) + projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(costPerKandang.KandangID)) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang") + } + + return fiber.NewError(fiber.StatusInternalServerError, "Failed to find project flock kandang for this kandang") + } + + for _, costItem := range costPerKandang.CostItems { + + projectFlockKandangId := uint64(projectFlockKandang.Id) + nonstockId := costItem.NonstockID + expenseNonstock := &entity.ExpenseNonstock{ + ExpenseId: &expense.Id, + ProjectFlockKandangId: &projectFlockKandangId, + NonstockId: &nonstockId, + Qty: costItem.Quantity, + TotalPrice: costItem.TotalCost, + Note: &costItem.Notes, + } + + if err := expenseNonstockRepoTx.CreateOne(c.Context(), expenseNonstock, nil); err != nil { + + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense cost item") + } + } + } + } + + approvalAction := entity.ApprovalActionCreated + createdByUint := uint(createdBy) + if _, err := approvalSvcTx.CreateApproval( + c.Context(), + utils.ApprovalWorkflowExpense, + uint(expense.Id), + utils.ExpenseStepPengajuan, + &approvalAction, + createdByUint, + nil); err != nil { + + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create initial approval") + } // TODO: Handle documents (save file references) + + return nil + }) + + if err != nil { + if fiberErr, ok := err.(*fiber.Error); ok { + return nil, fiberErr + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense") + } + + return s.getExpenseWithDetails(c, uint(expense.Id)) } -func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Expense, error) { +func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*expenseDto.ExpenseDetailDTO, error) { if err := s.Validate.Struct(req); err != nil { + s.Log.Errorf("Validation failed for UpdateOne: %+v", err) return nil, err } - createdBy := uint64(1) - createBody := &entity.Expense{ - PoNumber: req.PoNumber, - Category: req.Category, - CreatedBy: &createdBy, - } - - if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { - s.Log.Errorf("Failed to create expense: %+v", err) - return nil, err - } - - return s.GetOne(c, uint(createBody.Id)) -} - -func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Expense, error) { - if err := s.Validate.Struct(req); err != nil { + // Validate expense exists using common service + if err := commonSvc.EnsureRelations(c.Context(), + commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: func(ctx context.Context, id uint) (bool, error) { + return s.Repository.IdExists(ctx, uint64(id)) + }}, + ); err != nil { return nil, err } updateBody := make(map[string]any) - if req.PoNumber != nil { - updateBody["po_number"] = *req.PoNumber - } - if req.Category != nil { - updateBody["category"] = *req.Category + if req.SupplierID != nil { + // Validate supplier exists using common service + supplierID := uint(*req.SupplierID) + if err := commonSvc.EnsureRelations(c.Context(), + commonSvc.RelationCheck{Name: "Supplier", ID: &supplierID, Exists: func(ctx context.Context, id uint) (bool, error) { + return commonRepo.Exists[entity.Supplier](ctx, s.SupplierRepo.DB(), id) + }}, + ); err != nil { + return nil, err + } + + updateBody["supplier_id"] = *req.SupplierID } if len(updateBody) == 0 { - return s.GetOne(c, id) + return s.getExpenseWithDetails(c, id) } if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { @@ -120,16 +336,368 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) return nil, err } - return s.GetOne(c, id) + return s.getExpenseWithDetails(c, id) } func (s expenseService) DeleteOne(c *fiber.Ctx, id uint) error { - if err := s.Repository.DeleteOne(c.Context(), id); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "Expense not found") - } - s.Log.Errorf("Failed to delete expense: %+v", err) + // Validate expense exists using common service + if err := commonSvc.EnsureRelations(c.Context(), + commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: func(ctx context.Context, id uint) (bool, error) { + return s.Repository.IdExists(ctx, uint64(id)) + }}, + ); err != nil { return err } + + if err := s.Repository.DeleteOne(c.Context(), id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Expense not found for ID %d: %+v", id, err) + return fiber.NewError(fiber.StatusNotFound, "Expense not found") + } + s.Log.Errorf("Failed to delete expense for ID %d: %+v", id, err) + return err + } + s.Log.Infof("Successfully deleted expense with ID %d", id) return nil } + +func (s *expenseService) ApproveExpense(c *fiber.Ctx, id uint, stepName string, action string, notes *string) (*expenseDto.ExpenseDetailDTO, error) { + + if err := commonSvc.EnsureRelations(c.Context(), + commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: func(ctx context.Context, id uint) (bool, error) { + return s.Repository.IdExists(ctx, uint64(id)) + }}, + ); err != nil { + return nil, err + } + + actorID := uint(1) // TODO: replace with authenticated user id + + var stepNumber approvalutils.ApprovalStep + switch stepName { + case "Manager": + stepNumber = utils.ExpenseStepManager + case "Finance": + stepNumber = utils.ExpenseStepFinance + default: + s.Log.Errorf("Invalid approval step: %s", stepName) + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid approval step") + } + + var expectedPreviousStep uint16 + switch stepName { + case "Manager": + expectedPreviousStep = uint16(utils.ExpenseStepPengajuan) + case "Finance": + expectedPreviousStep = uint16(utils.ExpenseStepManager) + } + + latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, id, nil) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate workflow") + } + + if latestApproval == nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "No approval found. Please create expense first.") + } + + if latestApproval.StepNumber != expectedPreviousStep { + + expectedStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(expectedPreviousStep)] + currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)] + + return nil, fiber.NewError(fiber.StatusBadRequest, + fmt.Sprintf("Cannot approve at step %s. Latest approval is at %s step. Expected previous step: %s", + stepName, currentStepName, expectedStepName)) + } + + var approvalAction entity.ApprovalAction + switch action { + case "APPROVED": + approvalAction = entity.ApprovalActionApproved + case "REJECTED": + approvalAction = entity.ApprovalActionRejected + default: + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid approval action") + } + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + + approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) + expenseRepoTx := repository.NewExpenseRepository(tx) + + if _, err := approvalSvc.CreateApproval( + c.Context(), + utils.ApprovalWorkflowExpense, + id, + stepNumber, + &approvalAction, + actorID, + notes); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create approval") + } + + if stepName == "Finance" && action == "APPROVED" { + poNumber, err := s.generatePoNumber(tx, id) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to generate PO number") + } + + updateData := map[string]interface{}{ + "po_number": poNumber, + } + if err := expenseRepoTx.PatchOne(c.Context(), id, updateData, nil); err != nil { + + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update PO number") + } + } + + return nil + }) + if err != nil { + if fiberErr, ok := err.(*fiber.Error); ok { + return nil, fiberErr + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to Approve") + } + + return s.getExpenseWithDetails(c, id) +} + +func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *validation.CreateRealization) (*expenseDto.ExpenseDetailDTO, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + if err := commonSvc.EnsureRelations(c.Context(), + commonSvc.RelationCheck{Name: "Expense", ID: &expenseID, Exists: func(ctx context.Context, id uint) (bool, error) { + return s.Repository.IdExists(ctx, uint64(id)) + }}, + ); err != nil { + return nil, err + } + realizationDate := time.Now() + createdBy := uint64(1) // TODO: replace with authenticated user id + + if err := s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + + approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) + realizationRepoTx := repository.NewExpenseRealizationRepository(tx) + expenseNonstockRepoTx := repository.NewExpenseNonstockRepository(tx) + + for _, realizationItem := range req.Realizations { + + expenseNonstockID := realizationItem.ExpenseNonstockID + + belongsToExpense, err := expenseNonstockRepoTx.GetByExpenseID(c.Context(), uint64(expenseID), uint64(expenseNonstockID)) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate ExpenseNonstock relation") + } + if !belongsToExpense { + return fiber.NewError(fiber.StatusNotFound, "ExpenseNonstock not found or does not belong to this expense") + } + + _, err = realizationRepoTx.GetByExpenseNonstockID(c.Context(), uint64(expenseNonstockID)) + if err == nil { + return fiber.NewError(fiber.StatusBadRequest, "Realization already exists for this expense nonstock") + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to check existing realization") + } + + realization := &entity.ExpenseRealization{ + ExpenseNonstockId: &expenseNonstockID, + RealizationQty: realizationItem.Qty, + RealizationUnitPrice: realizationItem.UnitPrice, + RealizationTotalPrice: realizationItem.TotalPrice, + RealizationDate: realizationDate, + Note: realizationItem.Notes, + CreatedBy: &createdBy, + } + + if err := realizationRepoTx.CreateOne(c.Context(), realization, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create realization") + } + } + + approvalAction := entity.ApprovalActionCreated + if _, err := approvalSvc.CreateApproval( + c.Context(), + utils.ApprovalWorkflowExpense, + expenseID, + utils.ExpenseStepRealisasi, + &approvalAction, + uint(createdBy), + nil); err != nil { + + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create realization approval") + } + + return nil + }); err != nil { + return nil, err + } + + return s.getExpenseWithDetails(c, expenseID) +} + +func (s *expenseService) CompleteExpense(c *fiber.Ctx, id uint, notes *string) (*expenseDto.ExpenseDetailDTO, error) { + // Validate expense exists using common service + if err := commonSvc.EnsureRelations(c.Context(), + commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: func(ctx context.Context, id uint) (bool, error) { + return s.Repository.IdExists(ctx, uint64(id)) + }}, + ); err != nil { + return nil, err + } + + actorID := uint(1) // TODO: replace with authenticated user id + + // Get latest approval to validate workflow + latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, id, nil) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to get latest approval for expense %d: %+v", id, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate workflow") + } + + // Check if expense can be completed (must be at Realisasi step) + if latestApproval == nil { + s.Log.Errorf("No approval found for expense %d", id) + return nil, fiber.NewError(fiber.StatusBadRequest, "No approval found. Please create expense first.") + } + + if latestApproval.StepNumber != uint16(utils.ExpenseStepRealisasi) { + currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)] + s.Log.Errorf("Cannot complete expense at step %s. Must be at Realisasi step", currentStepName) + return nil, fiber.NewError(fiber.StatusBadRequest, + fmt.Sprintf("Cannot complete expense at %s step. Must be at Realisasi step", currentStepName)) + } + + // Create approval for Selesai step (step 5) using transaction + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) + approvalAction := entity.ApprovalActionApproved + + if _, err := approvalSvc.CreateApproval( + c.Context(), + utils.ApprovalWorkflowExpense, + id, + utils.ExpenseStepSelesai, + &approvalAction, + actorID, + notes); err != nil { + s.Log.Errorf("Failed to create Selesai approval for expense %d: %+v", id, err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to complete expense") + } + + return nil + }) + + if err != nil { + return nil, err + } + + return s.getExpenseWithDetails(c, id) +} + +func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *validation.UpdateRealization) (*expenseDto.ExpenseDetailDTO, error) { + if err := s.Validate.Struct(req); err != nil { + s.Log.Errorf("Validation failed for UpdateRealization: %+v", err) + return nil, err + } + + // Validate Expense exists using common service + if err := commonSvc.EnsureRelations(c.Context(), + commonSvc.RelationCheck{Name: "Expense", ID: &expenseID, Exists: func(ctx context.Context, id uint) (bool, error) { + return s.Repository.IdExists(ctx, uint64(id)) + }}, + ); err != nil { + return nil, err + } + + // Use current date for realization date + realizationDate := time.Now() + + // Update realizations using transaction + if err := s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + realizationRepoTx := repository.NewExpenseRealizationRepository(tx) + expenseNonstockRepoTx := repository.NewExpenseNonstockRepository(tx) + + // Process each realization item + for _, realizationItem := range req.Realizations { + // Validate ExpenseNonstock exists and belongs to this expense + expenseNonstockID := realizationItem.ExpenseNonstockID + + belongsToExpense, err := expenseNonstockRepoTx.GetByExpenseID(c.Context(), uint64(expenseID), uint64(expenseNonstockID)) + if err != nil { + s.Log.Errorf("Failed to validate ExpenseNonstock relation: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate ExpenseNonstock relation") + } + if !belongsToExpense { + s.Log.Errorf("ExpenseNonstock not found or does not belong to expense %d", expenseID) + return fiber.NewError(fiber.StatusNotFound, "ExpenseNonstock not found or does not belong to this expense") + } + + // Get existing realization + existingRealization, err := realizationRepoTx.GetByExpenseNonstockID(c.Context(), uint64(expenseNonstockID)) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Realization not found for expense nonstock %d", expenseNonstockID) + return fiber.NewError(fiber.StatusNotFound, "Realization not found for this expense nonstock") + } + s.Log.Errorf("Failed to get existing realization: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get existing realization") + } + + // Update realization + updateData := map[string]interface{}{ + "realization_qty": realizationItem.Qty, + "realization_unit_price": realizationItem.UnitPrice, + "realization_total_price": realizationItem.TotalPrice, + "realization_date": realizationDate, + } + + if realizationItem.Notes != nil { + updateData["note"] = *realizationItem.Notes + } + + if err := realizationRepoTx.PatchOne(c.Context(), uint(existingRealization.Id), updateData, nil); err != nil { + s.Log.Errorf("Failed to update realization: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update realization") + } + } + + return nil + }); err != nil { + return nil, err + } + + // Return updated expense + return s.getExpenseWithDetails(c, expenseID) +} + +func (s *expenseService) generateReferenceNumber(ctx *gorm.DB) (string, error) { + + sequence, err := s.Repository.GetNextSequence(ctx.Statement.Context) + if err != nil { + return "", err + } + refNum := fmt.Sprintf("BOP-LTI-%05d", sequence) + + return refNum, nil +} + +func (s *expenseService) generatePoNumber(ctx *gorm.DB, expenseID uint) (string, error) { + + expenseRepoTx := repository.NewExpenseRepository(ctx) + expense, err := expenseRepoTx.GetByID(context.Background(), uint(expenseID), nil) + if err != nil { + return "", err + } + if expense.ReferenceNumber == nil { + return "", errors.New("reference number is required") + } + + poNum := fmt.Sprintf("PO-%s", *expense.ReferenceNumber) + + return poNum, nil +} diff --git a/internal/modules/expenses/validations/expense.validation.go b/internal/modules/expenses/validations/expense.validation.go index 8155d76f..53a8d95f 100644 --- a/internal/modules/expenses/validations/expense.validation.go +++ b/internal/modules/expenses/validations/expense.validation.go @@ -1,13 +1,41 @@ package validation +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 `json:"po_number" validate:"required,max=50"` - Category string `json:"category" validate:"required,max=50"` + PoNumber *string `form:"po_number" validate:"omitempty,max=50"` + TransactionDate string `form:"transaction_date" validate:"required,datetime=2006-01-02"` + 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"` +} + +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"` +} + +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"` } type Update struct { - PoNumber *string `json:"po_number,omitempty" validate:"omitempty,max=50"` - Category *string `json:"category,omitempty" validate:"omitempty,max=50"` + 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"` } type Query struct { @@ -15,3 +43,23 @@ type Query struct { Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` Search string `query:"search" validate:"omitempty,max=50"` } + +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 { +} + +type UpdateRealization struct { + Realizations []RealizationItem `json:"realizations" validate:"required,min=1,dive"` +} diff --git a/internal/modules/master/nonstocks/repositories/nonstock.repository.go b/internal/modules/master/nonstocks/repositories/nonstock.repository.go index affcbc4b..01188980 100644 --- a/internal/modules/master/nonstocks/repositories/nonstock.repository.go +++ b/internal/modules/master/nonstocks/repositories/nonstock.repository.go @@ -18,6 +18,7 @@ type NonstockRepository interface { SyncFlags(ctx context.Context, tx *gorm.DB, nonstockID uint, flags []string) error DeleteFlags(ctx context.Context, tx *gorm.DB, nonstockID uint) error GetFlags(ctx context.Context, nonstockID uint) ([]entity.Flag, error) + IdExists(ctx context.Context, id uint) (bool, error) } type NonstockRepositoryImpl struct { @@ -34,6 +35,10 @@ func (r *NonstockRepositoryImpl) NameExists(ctx context.Context, name string, ex return repository.ExistsByName[entity.Nonstock](ctx, r.DB(), name, excludeID) } +func (r *NonstockRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.Nonstock](ctx, r.DB(), id) +} + func (r *NonstockRepositoryImpl) SyncSuppliersDiff(ctx context.Context, tx *gorm.DB, nonstockID uint, supplierIDs []uint) error { db := tx if db == nil { diff --git a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go index a2ab8ebe..1fffe73b 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go @@ -13,6 +13,7 @@ import ( type ProjectFlockKandangRepository interface { GetByID(ctx context.Context, id uint) (*entity.ProjectFlockKandang, error) GetByProjectFlockAndKandang(ctx context.Context, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, error) + GetActiveByKandangID(ctx context.Context, kandangID uint) (*entity.ProjectFlockKandang, error) CreateMany(ctx context.Context, records []*entity.ProjectFlockKandang) error DeleteMany(ctx context.Context, projectFlockID uint, kandangIDs []uint) error GetAll(ctx context.Context, offset int, limit int, modifier func(*gorm.DB) *gorm.DB) ([]entity.ProjectFlockKandang, int64, error) @@ -220,6 +221,35 @@ func (r *projectFlockKandangRepositoryImpl) GetByProjectFlockAndKandang(ctx cont return record, nil } +func (r *projectFlockKandangRepositoryImpl) GetActiveByKandangID(ctx context.Context, kandangID uint) (*entity.ProjectFlockKandang, error) { + record := new(entity.ProjectFlockKandang) + if err := r.db.WithContext(ctx). + Joins("JOIN project_flocks ON project_flocks.id = project_flock_kandangs.project_flock_id"). + Joins(` + INNER JOIN ( + SELECT DISTINCT ON (approvable_id) approvable_id, step_name, action_at + FROM approvals + WHERE approvable_type = 'PROJECT_FLOCKS' + ORDER BY approvable_id, action_at DESC + ) latest_approval ON latest_approval.approvable_id = project_flocks.id + `). + Where("project_flock_kandangs.kandang_id = ?", kandangID). + Where("LOWER(latest_approval.step_name) = LOWER(?)", "Aktif"). + Order("project_flock_kandangs.id DESC"). + Preload("ProjectFlock"). + Preload("ProjectFlock.Fcr"). + Preload("ProjectFlock.Area"). + Preload("ProjectFlock.Location"). + Preload("ProjectFlock.CreatedUser"). + Preload("ProjectFlock.Kandangs"). + Preload("ProjectFlock.KandangHistory"). + Preload("Kandang"). + First(record).Error; err != nil { + return nil, err + } + return record, nil +} + func (r *projectFlockKandangRepositoryImpl) ListExistingKandangIDs(ctx context.Context, projectFlockID uint, kandangIDs []uint) ([]uint, error) { if len(kandangIDs) == 0 { return nil, nil diff --git a/internal/utils/constant.go b/internal/utils/constant.go index d1cb7415..98381df6 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -260,12 +260,16 @@ const ( ExpenseStepPengajuan approvalutils.ApprovalStep = 1 ExpenseStepManager approvalutils.ApprovalStep = 2 ExpenseStepFinance approvalutils.ApprovalStep = 3 + ExpenseStepRealisasi approvalutils.ApprovalStep = 4 + ExpenseStepSelesai approvalutils.ApprovalStep = 5 ) var ExpenseApprovalSteps = map[approvalutils.ApprovalStep]string{ ExpenseStepPengajuan: "Pengajuan", - ExpenseStepManager: "Manager", - ExpenseStepFinance: "Finance", + ExpenseStepManager: "Approval Manager", + ExpenseStepFinance: "Approval Finance", + ExpenseStepRealisasi: "Realisasi", + ExpenseStepSelesai: "Selesai", } // -------------------------------------------------------------------