Merge branch 'dev/teguh' into 'feat/BE/Sprint-5'

[FIX/BE][US#116,164/TASK#260,261,262,263,264,265,266,267,268] : Creating BOP Migration And All API Needed on bop an bop realization module

See merge request mbugroup/lti-api!70
This commit is contained in:
Hafizh A. Y.
2025-11-21 04:30:51 +00:00
19 changed files with 1752 additions and 157 deletions
@@ -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)
@@ -1 +1,3 @@
DROP TABLE IF EXISTS expense_nonstocks;
DROP TABLE IF EXISTS expense_nonstocks;
DROP SEQUENCE expenses_ref_seq;
@@ -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);
CREATE INDEX idx_expense_nonstocks_nonstock_id ON expense_nonstocks (nonstock_id);
@@ -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);
CREATE INDEX idx_expense_realizations_date ON expense_realizations (realization_date);
+15 -13
View File
@@ -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"`
+11 -18
View File
@@ -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"`
}
+8 -13
View File
@@ -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"`
@@ -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",
})
}
+277 -24
View File
@@ -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
}
+23 -2
View File
@@ -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)
}
@@ -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
}
@@ -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
}
@@ -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
}
+7
View File
@@ -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)
}
File diff suppressed because it is too large Load Diff
@@ -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"`
}
@@ -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
}
@@ -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
+6 -2
View File
@@ -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",
}
// -------------------------------------------------------------------