diff --git a/internal/database/migrations/20251117034511_create_expenses_table.up.sql b/internal/database/migrations/20251117034511_create_expenses_table.up.sql index 054084e4..8949d931 100644 --- a/internal/database/migrations/20251117034511_create_expenses_table.up.sql +++ b/internal/database/migrations/20251117034511_create_expenses_table.up.sql @@ -1,13 +1,15 @@ 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, + realization_document_path JSON, expense_date DATE NOT NULL, + realization_date DATE, grand_total NUMERIC(15, 3) DEFAULT 0, note TEXT, created_by BIGINT, @@ -16,6 +18,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 +27,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..83542bb5 100644 --- a/internal/database/migrations/20251117034529_create_expense_nonstocks_table.up.sql +++ b/internal/database/migrations/20251117034529_create_expense_nonstocks_table.up.sql @@ -1,15 +1,13 @@ 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, 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 @@ -32,6 +30,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 @@ -45,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 4a8dc148..ae58ca48 100644 --- a/internal/database/migrations/20251117034538_create_expense_realizations_table.up.sql +++ b/internal/database/migrations/20251117034538_create_expense_realizations_table.up.sql @@ -1,15 +1,12 @@ 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, 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.go b/internal/entities/expense.go index 286eaf51..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)"` - SupplierId *uint64 `gorm:""` - Category string `gorm:"type:varchar(50);not null"` - PoNumber string `gorm:"uniqueIndex;not null;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 eb27efef..7be2053a 100644 --- a/internal/entities/expense_nonstock.go +++ b/internal/entities/expense_nonstock.go @@ -1,27 +1,20 @@ package entities -import ( - "time" - - "gorm.io/gorm" -) - type ExpenseNonstock struct { - Id uint64 `gorm:"primaryKey;autoIncrement"` - ExpenseId *uint64 `gorm:""` - ProjectFlockKandangId *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"` ProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:ProjectFlockKandangId;references:Id"` + Kandang *Kandang `gorm:"foreignKey:KandangId;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/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"` diff --git a/internal/modules/expenses/controllers/expense.controller.go b/internal/modules/expenses/controllers/expense.controller.go index 074f2f0a..08256b24 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,52 @@ 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") + req.Category = c.FormValue("category") + + 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_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) @@ -92,7 +132,7 @@ func (u *ExpenseController) CreateOne(c *fiber.Ctx) error { Code: fiber.StatusCreated, Status: "success", Message: "Create expense successfully", - Data: dto.ToExpenseListDTO(*result), + Data: result, }) } @@ -105,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)) @@ -119,7 +181,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 +204,188 @@ func (u *ExpenseController) DeleteOne(c *fiber.Ctx) error { Message: "Delete expense successfully", }) } + +func (u *ExpenseController) Approval(c *fiber.Ctx) error { + req := new(validation.ApprovalRequest) + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + 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: message, + Data: data, + }) +} + +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"] + 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.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 + + 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) + 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, + }) +} + +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 21403ed9..bee50c6d 100644 --- a/internal/modules/expenses/dto/expense.dto.go +++ b/internal/modules/expenses/dto/expense.dto.go @@ -1,9 +1,14 @@ package dto import ( + "encoding/json" "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" ) @@ -16,19 +21,71 @@ type ExpenseRelationDTO struct { GrandTotal float64 `json:"grand_total"` } -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"` +type ExpenseBaseDTO struct { + Id uint64 `json:"id"` + ReferenceNumber string `json:"reference_number"` + PoNumber string `json:"po_number"` + Category string `json:"category"` + Supplier *supplierDTO.SupplierRelationDTO `json:"supplier,omitempty"` + RealizationDate *time.Time `json:"realization_date,omitempty"` + ExpenseDate time.Time `json:"expense_date"` + GrandTotal float64 `json:"grand_total"` + Location *locationDTO.LocationRelationDTO `json:"location,omitempty"` } -// === Mapper Functions === +type ExpenseListDTO struct { + 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"` + 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"` +} + +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.NonstockRelationDTO `json:"nonstock,omitempty"` +} + +type ExpenseRealizationDTO 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.NonstockRelationDTO `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 []ExpenseRealizationDTO `json:"realisasi,omitempty"` +} + +type DocumentDTO struct { + ID uint64 `json:"id"` + Path string `json:"path"` +} + +// === MAPPERS === func ToExpenseRelationDTO(e entity.Expense) ExpenseRelationDTO { return ExpenseRelationDTO{ @@ -39,6 +96,40 @@ func ToExpenseRelationDTO(e entity.Expense) ExpenseRelationDTO { } } +func ToExpenseBaseDTO(e *entity.Expense) ExpenseBaseDTO { + var location *locationDTO.LocationRelationDTO + var supplier *supplierDTO.SupplierRelationDTO + + var realizationDate *time.Time + if !e.RealizationDate.IsZero() { + realizationDate = &e.RealizationDate + } + + if len(e.Nonstocks) > 0 && e.Nonstocks[0].Kandang != nil { + if e.Nonstocks[0].Kandang.Location.Id != 0 { + mapped := locationDTO.ToLocationRelationDTO(e.Nonstocks[0].Kandang.Location) + location = &mapped + } + } + + if e.Supplier != nil && e.Supplier.Id != 0 { + mapped := supplierDTO.ToSupplierRelationDTO(*e.Supplier) + supplier = &mapped + } + + return ExpenseBaseDTO{ + Id: e.Id, + ReferenceNumber: e.ReferenceNumber, + PoNumber: e.PoNumber, + Category: e.Category, + Supplier: supplier, + RealizationDate: realizationDate, + ExpenseDate: e.ExpenseDate, + GrandTotal: e.GrandTotal, + Location: location, + } +} + func ToExpenseListDTO(e entity.Expense) ExpenseListDTO { var createdUser *userDTO.UserRelationDTO if e.CreatedUser.Id != 0 { @@ -46,23 +137,185 @@ func ToExpenseListDTO(e entity.Expense) ExpenseListDTO { createdUser = &mapped } + var latestApproval *approvalDTO.ApprovalRelationDTO + if e.LatestApproval != nil { + mapped := approvalDTO.ToApprovalDTO(*e.LatestApproval) + latestApproval = &mapped + } + return ExpenseListDTO{ - Id: e.Id, - ReferenceNumber: *e.ReferenceNumber, - PoNumber: e.PoNumber, - Category: e.Category, - ExpenseDate: e.ExpenseDate, - GrandTotal: e.GrandTotal, - CreatedAt: e.CreatedAt, - UpdatedAt: e.UpdatedAt, - CreatedUser: createdUser, + ExpenseBaseDTO: ToExpenseBaseDTO(&e), + CreatedUser: createdUser, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + LatestApproval: latestApproval, } } -func ToExpenseListDTOs(e []entity.Expense) []ExpenseListDTO { - result := make([]ExpenseListDTO, len(e)) - for i, r := range e { - result[i] = ToExpenseListDTO(r) +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 documents []DocumentDTO + var realizationDocs []DocumentDTO + var createdUser *userDTO.UserRelationDTO + if e.CreatedUser != nil && e.CreatedUser.Id != 0 { + mapped := userDTO.ToUserRelationDTO(*e.CreatedUser) + createdUser = &mapped + } + + var latestApproval *approvalDTO.ApprovalRelationDTO + if e.LatestApproval != nil { + mapped := approvalDTO.ToApprovalDTO(*e.LatestApproval) + latestApproval = &mapped + } + + var pengajuans []ExpenseNonstockDTO + 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([]ExpenseRealizationDTO, 0) + + for _, ns := range e.Nonstocks { + pengajuanDTO := ToExpenseNonstockDTO(ns) + + pengajuans = append(pengajuans, pengajuanDTO) + + if ns.Realization != nil && ns.Realization.Id != 0 { + var nonstock *nonstockDTO.NonstockRelationDTO + if ns.Nonstock != nil && ns.Nonstock.Id != 0 { + mapped := nonstockDTO.ToNonstockRelationDTO(*ns.Nonstock) + nonstock = &mapped + } + + 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) + } + } + } + + var totalPengajuan float64 + for _, p := range pengajuans { + totalPengajuan += p.TotalPrice + } + + var totalRealisasi float64 + for _, r := range realisasi { + totalRealisasi += r.TotalPrice + } + kandangs := ToKandangGroupDTO(pengajuans, realisasi, e.Nonstocks) + + return ExpenseDetailDTO{ + ExpenseBaseDTO: ToExpenseBaseDTO(e), + Documents: documents, + RealizationDocs: realizationDocs, + 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.NonstockRelationDTO + if ns.Nonstock != nil && ns.Nonstock.Id != 0 { + mapped := nonstockDTO.ToNonstockRelationDTO(*ns.Nonstock) + nonstock = &mapped + } + + return ExpenseNonstockDTO{ + Id: ns.Id, + Qty: ns.Qty, + UnitPrice: ns.UnitPrice, + TotalPrice: ns.TotalPrice, + Note: &ns.Note, + 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/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..805cb886 100644 --- a/internal/modules/expenses/route.go +++ b/internal/modules/expenses/route.go @@ -25,4 +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("/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 6f794e54..0d0779f0 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -1,12 +1,25 @@ package service import ( + "context" + "database/sql" + "encoding/json" "errors" + "fmt" + "mime/multipart" + "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,32 +28,54 @@ 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 + 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 { - 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.Nonstock"). + Preload("Nonstocks.Realization"). + Preload("Nonstocks.ProjectFlockKandang.Kandang.Location"). + Preload("Nonstocks.Kandang"). + Preload("Nonstocks.Kandang.Location") } -func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Expense, int64, error) { +func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expenseDto.ExpenseListDTO, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err } @@ -50,86 +85,958 @@ 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") }) if err != nil { - s.Log.Errorf("Failed to get expenses: %+v", err) + return nil, 0, err } - return expenses, total, nil + + 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 { + return nil, 0, 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) { +func (s expenseService) GetOne(c *fiber.Ctx, id uint) (*expenseDto.ExpenseDetailDTO, 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") - } if err != nil { - s.Log.Errorf("Failed get expense by id: %+v", err) + if errors.Is(err, gorm.ErrRecordNotFound) { + + return nil, fiber.NewError(fiber.StatusNotFound, "Expense not found") + } return nil, err } - return expense, nil + + 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) (*entity.Expense, error) { +func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expenseDto.ExpenseDetailDTO, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } - createdBy := uint64(1) - createBody := &entity.Expense{ - PoNumber: req.PoNumber, - Category: req.Category, - CreatedBy: &createdBy, - } + supplierID := uint(req.SupplierID) - if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { - s.Log.Errorf("Failed to create expense: %+v", err) + 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 } - return s.GetOne(c, uint(createBody.Id)) + 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 + } + + supplierFound, err := s.NonstockRepo.IsNonstockAssociatedWithSupplier(c.Context(), nonstockId, req.SupplierID) + 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 check nonstock-supplier relation") + } + 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)) + projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(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: req.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 { + + var projectFlockKandangId *uint64 + + 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 { + + 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, + KandangId: kandangId, + 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") + } + + if len(req.Documents) > 0 { + if err := s.processDocuments(c, expenseRepoTx, uint(expense.Id), req.Documents, false); err != nil { + return err + } + } + + 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") + } + + 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) (*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 { return nil, err } + 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 + } + + 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 { + if latestApproval.StepNumber >= uint16(utils.ExpenseStepRealisasi) { + currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)] + 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.PoNumber != nil { - updateBody["po_number"] = *req.PoNumber - } - if req.Category != nil { - updateBody["category"] = *req.Category - } - - if len(updateBody) == 0 { - return s.GetOne(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") + if req.TransactionDate != nil { + expenseDate, err := utils.ParseDateString(*req.TransactionDate) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transaction_date format") } - s.Log.Errorf("Failed to update expense: %+v", err) + updateBody["expense_date"] = expenseDate + } + + 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 + } + + 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) + + 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") + } + return err + } + } + + if req.CostPerKandang != nil { + + if err := tx.Where("expense_id = ?", id).Delete(&entity.ExpenseNonstock{}).Error; err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update expense items") + } + + var grandTotal float64 + for _, cpk := range *req.CostPerKandang { + for _, costItem := range cpk.CostItems { + grandTotal += costItem.TotalCost + } + } + + if err := expenseRepoTx.PatchOne(c.Context(), id, map[string]interface{}{ + "grand_total": grandTotal, + }, nil); err != nil { + + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update expense grand total") + } + + for _, cpk := range *req.CostPerKandang { + 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, "Expense not found") + } + 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 { + + nonstockId := uint(costItem.NonstockID) + if err := commonSvc.EnsureRelations(c.Context(), + commonSvc.RelationCheck{Name: "Nonstock", ID: &nonstockId, Exists: s.NonstockRepo.IdExists}, + ); err != nil { + return err + } + + 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, + KandangId: kandangId, + NonstockId: &costItem.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") + } + } + } + } + + actorID := uint(1) // TODO: replace with authenticated user id + 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 len(req.Documents) > 0 { + if err := s.processDocuments(c, expenseRepoTx, id, req.Documents, false); err != nil { + return err + } + } + + 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") + } + + responseDTO, err := s.GetOne(c, id) + if err != nil { return nil, err } - - return s.GetOne(c, id) + return responseDTO, nil } func (s expenseService) DeleteOne(c *fiber.Ctx, id uint) 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 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: %+v", err) + 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) 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, 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 { + + 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 + + if err := s.validateExpenseNonstockRelation(c, expenseNonstockRepoTx, expenseID, expenseNonstockID); err != nil { + return err + } + + _, 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") + } + } + + 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(), + 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 + } + + 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) { + + 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 + + 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 != uint16(utils.ExpenseStepRealisasi) { + currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)] + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot complete expense at %s step. Must be at Realisasi step", currentStepName)) + } + + 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 { + + return fiber.NewError(fiber.StatusInternalServerError, "Failed to complete expense") + } + + return nil + }) + + if err != nil { + return nil, err + } + + 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) { + if err := s.Validate.Struct(req); err != nil { + s.Log.Errorf("Validation failed for UpdateRealization: %+v", err) + 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 + } + + 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 + } + + 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) + + for _, realizationItem := range req.Realizations { + + expenseNonstockID := realizationItem.ExpenseNonstockID + + if err := s.validateExpenseNonstockRelation(c, expenseNonstockRepoTx, expenseID, expenseNonstockID); err != nil { + return err + } + + existingRealization, err := realizationRepoTx.GetByExpenseNonstockID(c.Context(), uint64(expenseNonstockID)) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + + return fiber.NewError(fiber.StatusNotFound, "Realization not found for this expense nonstock") + } + + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get existing 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") + } + } + + 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 + } + + 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) { + + 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 + } + + 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 8155d76f..4e909b66 100644 --- a/internal/modules/expenses/validations/expense.validation.go +++ b/internal/modules/expenses/validations/expense.validation.go @@ -1,13 +1,34 @@ package validation +import ( + "mime/multipart" +) + 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" 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 `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 `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"` - Category *string `json:"category,omitempty" validate:"omitempty,max=50"` + 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 { @@ -15,3 +36,29 @@ 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 { + 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 { + 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"` +} diff --git a/internal/modules/master/nonstocks/repositories/nonstock.repository.go b/internal/modules/master/nonstocks/repositories/nonstock.repository.go index 25a36e92..aeff162f 100644 --- a/internal/modules/master/nonstocks/repositories/nonstock.repository.go +++ b/internal/modules/master/nonstocks/repositories/nonstock.repository.go @@ -18,6 +18,8 @@ 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) + IsNonstockAssociatedWithSupplier(ctx context.Context, nonstockID uint, supplierID uint64) (bool, error) } type NonstockRepositoryImpl struct { @@ -34,6 +36,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 { @@ -170,3 +176,15 @@ func (r *NonstockRepositoryImpl) GetFlags(ctx context.Context, nonstockID uint) } return flags, nil } + +func (r *NonstockRepositoryImpl) IsNonstockAssociatedWithSupplier(ctx context.Context, nonstockID uint, supplierID uint64) (bool, error) { + var count int64 + if err := r.DB().WithContext(ctx). + Model(&entity.NonstockSupplier{}). + Where("nonstock_id = ? AND supplier_id = ?", nonstockID, supplierID). + Count(&count). + Error; err != nil { + return false, err + } + return count > 0, 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 1771a906..76f23b39 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) @@ -221,6 +222,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", } // -------------------------------------------------------------------