From 105b20c333e7fdb7138a95e3108aeafc03fafcd3 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 19 Nov 2025 16:05:11 +0700 Subject: [PATCH 1/7] Feat[BE-261,265]: createing BOP and BOP realization(Unfinished) --- ...0251117034511_create_expenses_table.up.sql | 10 +- ...29_create_expense_nonstocks_table.down.sql | 4 +- ...4529_create_expense_nonstocks_table.up.sql | 15 +- ...8_create_expense_realizations_table.up.sql | 2 +- internal/entities/expense.go | 4 +- internal/entities/expense_nonstock.go | 2 +- .../controllers/expense.controller.go | 164 ++++- internal/modules/expenses/dto/expense.dto.go | 330 ++++++++- internal/modules/expenses/module.go | 25 +- .../repositories/expense.repository.go | 23 + .../expense_nonstock.repository.go | 27 + .../expense_realization.repository.go | 12 + internal/modules/expenses/route.go | 5 + .../expenses/services/expense.service.go | 672 ++++++++++++++++-- .../validations/expense.validation.go | 56 +- .../repositories/nonstock.repository.go | 5 + .../projectflock_kandang.repository.go | 30 + internal/utils/constant.go | 8 +- 18 files changed, 1279 insertions(+), 115 deletions(-) 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", } // ------------------------------------------------------------------- From 4c7e5b073114ffd3203f50d9c109a950f0505a74 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Thu, 20 Nov 2025 08:27:02 +0700 Subject: [PATCH 2/7] Feat[BE-261,265]: add category request body on create on --- .../controllers/expense.controller.go | 1 + internal/modules/expenses/dto/expense.dto.go | 38 +++- .../expenses/services/expense.service.go | 194 ++++++++++++++++-- .../validations/expense.validation.go | 1 + 4 files changed, 206 insertions(+), 28 deletions(-) diff --git a/internal/modules/expenses/controllers/expense.controller.go b/internal/modules/expenses/controllers/expense.controller.go index 3a9d76bb..2d0bebac 100644 --- a/internal/modules/expenses/controllers/expense.controller.go +++ b/internal/modules/expenses/controllers/expense.controller.go @@ -82,6 +82,7 @@ func (u *ExpenseController) CreateOne(c *fiber.Ctx) error { req := new(validation.Create) req.TransactionDate = c.FormValue("transaction_date") + req.Category = c.FormValue("category") supplierID, err := strconv.ParseUint(c.FormValue("supplier_id"), 10, 64) if err != nil { diff --git a/internal/modules/expenses/dto/expense.dto.go b/internal/modules/expenses/dto/expense.dto.go index 1f1fecef..ae048ca1 100644 --- a/internal/modules/expenses/dto/expense.dto.go +++ b/internal/modules/expenses/dto/expense.dto.go @@ -1,11 +1,13 @@ package dto import ( + "encoding/json" "fmt" "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" + locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto" nonstockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/dto" supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" @@ -14,12 +16,14 @@ import ( // === Base DTO === type ExpenseBaseDTO struct { - 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"` + 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"` + Location *locationDTO.LocationBaseDTO `json:"location,omitempty"` } // === List DTO (untuk GetAll) === @@ -110,13 +114,33 @@ func getStringValue(s *string) string { // === Mapper Functions === func ToExpenseBaseDTO(e *entity.Expense) ExpenseBaseDTO { + var documents []string + var location *locationDTO.LocationBaseDTO + + // Parse document paths from JSON if available + if e.DocumentPath.Valid && e.DocumentPath.String != "" { + if err := json.Unmarshal([]byte(e.DocumentPath.String), &documents); err == nil { + // Successfully parsed documents + } + } + + // Get location from the first kandang if available + if len(e.Nonstocks) > 0 && e.Nonstocks[0].ProjectFlockKandang != nil { + if e.Nonstocks[0].ProjectFlockKandang.Kandang.Location.Id != 0 { + mapped := locationDTO.ToLocationBaseDTO(e.Nonstocks[0].ProjectFlockKandang.Kandang.Location) + location = &mapped + } + } + return ExpenseBaseDTO{ Id: e.Id, ReferenceNumber: getStringValue(e.ReferenceNumber), - PoNumber: e.PoNumber, + PoNumber: e.PoNumber, // Keep as pointer to allow null in JSON Category: e.Category, + Documents: documents, ExpenseDate: e.ExpenseDate, GrandTotal: e.GrandTotal, + Location: location, } } diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index e0d2b343..3ed792fb 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "errors" "fmt" "time" @@ -65,7 +66,7 @@ func (s expenseService) withRelations(db *gorm.DB) *gorm.DB { Preload("CreatedUser"). Preload("Supplier"). Preload("Nonstocks", func(db *gorm.DB) *gorm.DB { - return db.Preload("Nonstock").Preload("Realization").Preload("ProjectFlockKandang") + return db.Preload("Nonstock").Preload("Realization").Preload("ProjectFlockKandang.Kandang.Location") }) } @@ -150,15 +151,6 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen return nil, err } - // Get supplier for category - supplierEntity, err := s.SupplierRepo.GetByID(c.Context(), uint(req.SupplierID), nil) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Supplier not found") - } - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get supplier") - } - for _, costPerKandang := range req.CostPerKandangs { for _, costItem := range costPerKandang.CostItems { nonstockId := uint(costItem.NonstockID) @@ -220,7 +212,7 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen expense = &entity.Expense{ ReferenceNumber: &referenceNumber, PoNumber: req.PoNumber, - Category: supplierEntity.Category, + Category: req.Category, SupplierId: &req.SupplierID, ExpenseDate: expenseDate, GrandTotal: grandTotal, @@ -276,12 +268,36 @@ 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") - } // TODO: Handle documents (save file references) + // 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") + } + } - return nil - }) + return nil + }) if err != nil { if fiberErr, ok := err.(*fiber.Error); ok { @@ -308,6 +324,23 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) return nil, err } + // Get latest approval to validate workflow + latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, id, nil) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to get latest approval for expense %d: %+v", id, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate workflow") + } + + // Check if expense can be updated (must be before Realisasi step) + if latestApproval != nil { + if latestApproval.StepNumber >= uint16(utils.ExpenseStepRealisasi) { + currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)] + s.Log.Errorf("Cannot update expense at %s step. Must be before Realisasi step", currentStepName) + return nil, fiber.NewError(fiber.StatusBadRequest, + fmt.Sprintf("Cannot update expense at %s step. Must be before Realisasi step", currentStepName)) + } + } + updateBody := make(map[string]any) if req.SupplierID != nil { @@ -324,16 +357,135 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) updateBody["supplier_id"] = *req.SupplierID } - if len(updateBody) == 0 { + if req.TransactionDate != nil { + // Parse transaction_date + expenseDate, err := utils.ParseDateString(*req.TransactionDate) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transaction_date format") + } + updateBody["expense_date"] = expenseDate + } + + if req.PoNumber != nil { + updateBody["po_number"] = *req.PoNumber + } + + if len(updateBody) == 0 && req.CostPerKandang == nil { return s.getExpenseWithDetails(c, id) } - if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Expense not found") + // 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 + } } - s.Log.Errorf("Failed to update expense: %+v", err) - return nil, err + + // Update cost per kandang if provided + if req.CostPerKandang != nil { + // First, delete existing expense nonstocks using GORM + if err := tx.Where("expense_id = ?", id).Delete(&entity.ExpenseNonstock{}).Error; err != nil { + s.Log.Errorf("Failed to delete existing expense nonstocks: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update expense items") + } + + // Calculate new grand total + var grandTotal float64 + for _, cpk := range *req.CostPerKandang { + for _, costItem := range cpk.CostItems { + grandTotal += costItem.TotalCost + } + } + + // Update expense grand total + if err := expenseRepoTx.PatchOne(c.Context(), id, map[string]interface{}{ + "grand_total": grandTotal, + }, nil); err != nil { + s.Log.Errorf("Failed to update expense grand total: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update expense grand total") + } + + // Create new expense nonstocks + for _, cpk := range *req.CostPerKandang { + // Get active project_flock_kandang for this kandang + projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(tx) + projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(cpk.KandangID)) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang") + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to find project flock kandang for this kandang") + } + + for _, costItem := range cpk.CostItems { + // Validate nonstock exists + nonstockId := uint(costItem.NonstockID) + if err := commonSvc.EnsureRelations(c.Context(), + commonSvc.RelationCheck{Name: "Nonstock", ID: &nonstockId, Exists: s.NonstockRepo.IdExists}, + ); err != nil { + return err + } + + projectFlockKandangId := uint64(projectFlockKandang.Id) + expenseId := uint64(id) + expenseNonstock := &entity.ExpenseNonstock{ + ExpenseId: &expenseId, + ProjectFlockKandangId: &projectFlockKandangId, + NonstockId: &costItem.NonstockID, + Qty: costItem.Quantity, + TotalPrice: costItem.TotalCost, + Note: &costItem.Notes, + } + + if err := expenseNonstockRepoTx.CreateOne(c.Context(), expenseNonstock, nil); err != nil { + s.Log.Errorf("Failed to create expense nonstock: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense cost item") + } + } + } + } + + // Reset approval step to Pengajuan + actorID := uint(1) // TODO: replace with authenticated user id + var approvalAction entity.ApprovalAction + + // Check if latest approval was Updated, then use Updated action, otherwise use Created + if latestApproval != nil && latestApproval.Action != nil && *latestApproval.Action == entity.ApprovalActionUpdated { + approvalAction = entity.ApprovalActionUpdated + } else { + approvalAction = entity.ApprovalActionCreated + } + + if _, err := approvalSvcTx.CreateApproval( + c.Context(), + utils.ApprovalWorkflowExpense, + id, + utils.ExpenseStepPengajuan, + &approvalAction, + actorID, + nil); err != nil { + s.Log.Errorf("Failed to reset approval to Pengajuan for expense %d: %+v", id, err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to reset approval step") + } + + return nil + }) + + if err != nil { + if fiberErr, ok := err.(*fiber.Error); ok { + return nil, fiberErr + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update expense") } return s.getExpenseWithDetails(c, id) diff --git a/internal/modules/expenses/validations/expense.validation.go b/internal/modules/expenses/validations/expense.validation.go index 53a8d95f..56420e06 100644 --- a/internal/modules/expenses/validations/expense.validation.go +++ b/internal/modules/expenses/validations/expense.validation.go @@ -13,6 +13,7 @@ type ApprovalRequest struct { 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"` From b502751b4e257fc5330f12fd53104b4e868859fd Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Thu, 20 Nov 2025 08:50:47 +0700 Subject: [PATCH 3/7] Fix[BE-261]: change realization json to not using prefix Realization --- internal/modules/expenses/dto/expense.dto.go | 50 ++++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/internal/modules/expenses/dto/expense.dto.go b/internal/modules/expenses/dto/expense.dto.go index ae048ca1..70afd4d4 100644 --- a/internal/modules/expenses/dto/expense.dto.go +++ b/internal/modules/expenses/dto/expense.dto.go @@ -69,20 +69,20 @@ type ProjectFlockKandangNestedDTO struct { } 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"` + 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"` - RealizationQty float64 `json:"realization_qty"` - RealizationUnitPrice float64 `json:"realization_unit_price"` - RealizationTotalPrice float64 `json:"realization_total_price"` - RealizationDate time.Time `json:"realization_date"` + 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"` @@ -224,14 +224,14 @@ func ToExpenseDetailDTO(e *entity.Expense) ExpenseDetailDTO { } 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, + 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, } realisasi = append(realisasi, realisasiDTO) } @@ -246,7 +246,7 @@ func ToExpenseDetailDTO(e *entity.Expense) ExpenseDetailDTO { var totalRealisasi float64 for _, r := range realisasi { - totalRealisasi += r.RealizationTotalPrice + totalRealisasi += r.TotalPrice } // Group pengajuans and realisasi by kandang @@ -336,11 +336,11 @@ func ToExpenseNonstockDTO(ns entity.ExpenseNonstock) ExpenseNonstockDTO { 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, + Id: r.Id, + Qty: r.RealizationQty, + UnitPrice: r.RealizationUnitPrice, + TotalPrice: r.RealizationTotalPrice, + Date: r.RealizationDate, + Note: r.Note, } } From b8d1268dfae338939aa1a828435cb5f7085122e9 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 21 Nov 2025 00:55:02 +0700 Subject: [PATCH 4/7] 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"` } From 5a73ad0164139e347e9f3663df1420c19096426c Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 21 Nov 2025 01:06:48 +0700 Subject: [PATCH 5/7] Fix[BE-261]: delete timestampz on expense nonstock anda expense reaalizations table --- ...4529_create_expense_nonstocks_table.up.sql | 9 ++----- ...8_create_expense_realizations_table.up.sql | 9 ++----- internal/entities/expense_nonstock.go | 27 +++++++------------ internal/entities/expense_realization.go | 21 ++++++--------- 4 files changed, 21 insertions(+), 45 deletions(-) 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 330c4d7b..83542bb5 100644 --- a/internal/database/migrations/20251117034529_create_expense_nonstocks_table.up.sql +++ b/internal/database/migrations/20251117034529_create_expense_nonstocks_table.up.sql @@ -7,10 +7,7 @@ CREATE TABLE expense_nonstocks ( qty NUMERIC(15, 3) NOT NULL, unit_price NUMERIC(15, 3) NOT NULL, total_price NUMERIC(15, 3) NOT NULL, - note TEXT NULL, - created_at TIMESTAMPTZ DEFAULT now(), - updated_at TIMESTAMPTZ DEFAULT now(), - deleted_at TIMESTAMPTZ + note TEXT NULL ); -- Tambahkan Foreign Key ke expenses @@ -56,6 +53,4 @@ END $$; -- Index CREATE INDEX idx_expense_nonstocks_expense_id ON expense_nonstocks (expense_id); -CREATE INDEX idx_expense_nonstocks_nonstock_id ON expense_nonstocks (nonstock_id); - -CREATE INDEX idx_expense_nonstocks_deleted_at ON expense_nonstocks (deleted_at); \ No newline at end of file +CREATE INDEX idx_expense_nonstocks_nonstock_id ON expense_nonstocks (nonstock_id); \ No newline at end of file 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 0886cd7a..ae58ca48 100644 --- a/internal/database/migrations/20251117034538_create_expense_realizations_table.up.sql +++ b/internal/database/migrations/20251117034538_create_expense_realizations_table.up.sql @@ -6,10 +6,7 @@ CREATE TABLE expense_realizations ( realization_total_price NUMERIC(15, 3) NOT NULL, realization_date DATE NOT NULL, note TEXT, - created_by BIGINT, - created_at TIMESTAMPTZ DEFAULT now(), - updated_at TIMESTAMPTZ DEFAULT now(), - deleted_at TIMESTAMPTZ + created_by BIGINT ); -- Tambahkan Foreign Key ke expense_nonstocks @@ -35,6 +32,4 @@ END $$; -- Index CREATE INDEX idx_expense_realizations_nonstock_id ON expense_realizations (expense_nonstock_id); -CREATE INDEX idx_expense_realizations_date ON expense_realizations (realization_date); - -CREATE INDEX idx_expense_realizations_deleted_at ON expense_realizations (deleted_at); \ No newline at end of file +CREATE INDEX idx_expense_realizations_date ON expense_realizations (realization_date); \ No newline at end of file diff --git a/internal/entities/expense_nonstock.go b/internal/entities/expense_nonstock.go index d483e4bf..7be2053a 100644 --- a/internal/entities/expense_nonstock.go +++ b/internal/entities/expense_nonstock.go @@ -1,24 +1,15 @@ package entities -import ( - "time" - - "gorm.io/gorm" -) - 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"` - CreatedAt time.Time `gorm:"autoCreateTime"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + 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"` // Relations Expense *Expense `gorm:"foreignKey:ExpenseId;references:Id"` diff --git a/internal/entities/expense_realization.go b/internal/entities/expense_realization.go index 45a87602..629fdfb7 100644 --- a/internal/entities/expense_realization.go +++ b/internal/entities/expense_realization.go @@ -2,22 +2,17 @@ package entities import ( "time" - - "gorm.io/gorm" ) type ExpenseRealization struct { - Id uint64 `gorm:"primaryKey;autoIncrement"` - ExpenseNonstockId *uint64 `gorm:""` - RealizationQty float64 `gorm:"type:numeric(15,3);not null"` - RealizationUnitPrice float64 `gorm:"type:numeric(15,3);not null"` - RealizationTotalPrice float64 `gorm:"type:numeric(15,3);not null"` - RealizationDate time.Time `gorm:"type:date;not null"` - 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"` + ExpenseNonstockId *uint64 `gorm:""` + RealizationQty float64 `gorm:"type:numeric(15,3);not null"` + RealizationUnitPrice float64 `gorm:"type:numeric(15,3);not null"` + RealizationTotalPrice float64 `gorm:"type:numeric(15,3);not null"` + RealizationDate time.Time `gorm:"type:date;not null"` + Note *string `gorm:"type:text"` + CreatedBy *uint64 `gorm:""` // Relations ExpenseNonstock *ExpenseNonstock `gorm:"foreignKey:ExpenseNonstockId;references:Id"` From 6768092e3bdbe5cd4b3f6a53e3082589e04e650a Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 21 Nov 2025 10:20:07 +0700 Subject: [PATCH 6/7] Fix[BE-261]: fixing location not preloaded on get one non-bop API --- internal/modules/expenses/dto/expense.dto.go | 131 ++++++++++-------- .../expenses/services/expense.service.go | 4 +- .../production/project_flocks/route.go | 3 +- .../services/projectflock.service.go | 11 +- 4 files changed, 79 insertions(+), 70 deletions(-) diff --git a/internal/modules/expenses/dto/expense.dto.go b/internal/modules/expenses/dto/expense.dto.go index d87502ed..d9844014 100644 --- a/internal/modules/expenses/dto/expense.dto.go +++ b/internal/modules/expenses/dto/expense.dto.go @@ -12,6 +12,7 @@ import ( userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) +// === DTO Structs === type ExpenseBaseDTO struct { Id uint64 `json:"id"` ReferenceNumber string `json:"reference_number"` @@ -76,6 +77,8 @@ type DocumentDTO struct { Path string `json:"path"` } +// === MAPPERS === + func ToExpenseBaseDTO(e *entity.Expense) ExpenseBaseDTO { var location *locationDTO.LocationBaseDTO var supplier *supplierDTO.SupplierBaseDTO @@ -85,9 +88,9 @@ func ToExpenseBaseDTO(e *entity.Expense) ExpenseBaseDTO { realizationDate = &e.RealizationDate } - 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) + if len(e.Nonstocks) > 0 && e.Nonstocks[0].Kandang != nil { + if e.Nonstocks[0].Kandang.Location.Id != 0 { + mapped := locationDTO.ToLocationBaseDTO(e.Nonstocks[0].Kandang.Location) location = &mapped } } @@ -205,64 +208,7 @@ func ToExpenseDetailDTO(e *entity.Expense) ExpenseDetailDTO { totalRealisasi += r.TotalPrice } - kandangMap := make(map[uint64]*KandangGroupDTO) - - for _, p := range pengajuans { - 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: kandangId, - KandangId: kandangId, - Name: kandangName, - Pengajuans: []ExpenseNonstockDTO{}, - Realisasi: []ExpenseRealizationDTO{}, - } - } - kandangMap[kandangId].Pengajuans = append(kandangMap[kandangId].Pengajuans, p) - } - } - - for _, r := range realisasi { - 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: kandangId, - KandangId: kandangId, - Name: kandangName, - Pengajuans: []ExpenseNonstockDTO{}, - Realisasi: []ExpenseRealizationDTO{}, - } - } - kandangMap[kandangId].Realisasi = append(kandangMap[kandangId].Realisasi, r) - } - } - - var kandangs []KandangGroupDTO - for _, k := range kandangMap { - kandangs = append(kandangs, *k) - } + kandangs := ToKandangGroupDTO(pengajuans, realisasi, e.Nonstocks) return ExpenseDetailDTO{ ExpenseBaseDTO: ToExpenseBaseDTO(e), @@ -294,3 +240,66 @@ func ToExpenseNonstockDTO(ns entity.ExpenseNonstock) ExpenseNonstockDTO { Nonstock: nonstock, } } + +func ToKandangGroupDTO(pengajuans []ExpenseNonstockDTO, realisasi []ExpenseRealizationDTO, nonstocks []entity.ExpenseNonstock) []KandangGroupDTO { + kandangMap := make(map[uint64]*KandangGroupDTO) + + for _, p := range pengajuans { + var kandangId uint64 + var kandangName string + + for _, ns := range 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: kandangId, + KandangId: kandangId, + Name: kandangName, + Pengajuans: []ExpenseNonstockDTO{}, + Realisasi: []ExpenseRealizationDTO{}, + } + } + kandangMap[kandangId].Pengajuans = append(kandangMap[kandangId].Pengajuans, p) + } + } + + for _, r := range realisasi { + var kandangId uint64 + var kandangName string + + for _, ns := range 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: kandangId, + KandangId: kandangId, + Name: kandangName, + Pengajuans: []ExpenseNonstockDTO{}, + Realisasi: []ExpenseRealizationDTO{}, + } + } + kandangMap[kandangId].Realisasi = append(kandangMap[kandangId].Realisasi, r) + } + } + + var kandangs []KandangGroupDTO + for _, k := range kandangMap { + kandangs = append(kandangs, *k) + } + + return kandangs +} diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index 658537b7..03512998 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -71,7 +71,8 @@ func (s expenseService) withRelations(db *gorm.DB) *gorm.DB { Preload("Nonstocks.Nonstock"). Preload("Nonstocks.Realization"). Preload("Nonstocks.ProjectFlockKandang.Kandang.Location"). - Preload("Nonstocks.Kandang") + Preload("Nonstocks.Kandang"). + Preload("Nonstocks.Kandang.Location") } func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expenseDto.ExpenseListDTO, int64, error) { @@ -629,6 +630,7 @@ func (s *expenseService) CompleteExpense(c *fiber.Ctx, id uint, notes *string) ( } err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) approvalAction := entity.ApprovalActionApproved diff --git a/internal/modules/production/project_flocks/route.go b/internal/modules/production/project_flocks/route.go index 39e283ab..ae2f1946 100644 --- a/internal/modules/production/project_flocks/route.go +++ b/internal/modules/production/project_flocks/route.go @@ -1,7 +1,6 @@ package project_flocks import ( - m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/controllers" projectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -19,7 +18,7 @@ func ProjectflockRoutes(v1 fiber.Router, u user.UserService, s projectflock.Proj // route.Get("/:id", m.Auth(u), ctrl.GetOne) // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) - route.Use(m.Auth(u)) + // route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) route.Post("/", ctrl.CreateOne) diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index 5b92b0db..b1679561 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -10,7 +10,6 @@ import ( commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" - authmiddleware "gitlab.com/mbugroup/lti-api.git/internal/middleware" productWarehouseRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" flockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/dto" flockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/repositories" @@ -1042,9 +1041,9 @@ func (s projectflockService) kandangRepoWithTx(tx *gorm.DB) kandangRepository.Ka } 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 + // user, ok := authmiddleware.AuthenticatedUser(c) + // if !ok || user == nil || user.Id == 0 { + // return 0, fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") + // } + return 1, nil } From b0dfa717d5bb68e25011a097f7259eb7dee8525f Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 21 Nov 2025 11:16:34 +0700 Subject: [PATCH 7/7] FIX[BE-261]: expense list dto ganti dari hardcoded ke ambil dari expensebasedto --- internal/modules/expenses/dto/expense.dto.go | 32 +++++++------------- 1 file changed, 11 insertions(+), 21 deletions(-) diff --git a/internal/modules/expenses/dto/expense.dto.go b/internal/modules/expenses/dto/expense.dto.go index 680407a4..bee50c6d 100644 --- a/internal/modules/expenses/dto/expense.dto.go +++ b/internal/modules/expenses/dto/expense.dto.go @@ -34,28 +34,23 @@ type ExpenseBaseDTO struct { } 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.UserRelationDTO `json:"created_user,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval,omitempty"` + ExpenseBaseDTO + CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval,omitempty"` } type ExpenseDetailDTO struct { ExpenseBaseDTO Documents []DocumentDTO `json:"documents,omitempty"` RealizationDocs []DocumentDTO `json:"realization_docs,omitempty"` - CreatedUser *userDTO.UserRelationDTO `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"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval,omitempty"` } @@ -149,16 +144,11 @@ func ToExpenseListDTO(e entity.Expense) ExpenseListDTO { } return ExpenseListDTO{ - Id: e.Id, - ReferenceNumber: e.ReferenceNumber, - PoNumber: e.PoNumber, - Category: e.Category, - ExpenseDate: e.ExpenseDate, - GrandTotal: e.GrandTotal, - CreatedUser: createdUser, - CreatedAt: e.CreatedAt, - UpdatedAt: e.UpdatedAt, - LatestApproval: latestApproval, + ExpenseBaseDTO: ToExpenseBaseDTO(&e), + CreatedUser: createdUser, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + LatestApproval: latestApproval, } }