feat[BE]: enhance expense management with location and project flock integration, including updates to migrations, entities, services, and validations

This commit is contained in:
aguhh18
2025-12-28 08:13:50 +07:00
parent 85dc0ecd13
commit ec6da57510
10 changed files with 197 additions and 69 deletions
@@ -90,6 +90,12 @@ func (u *ExpenseController) CreateOne(c *fiber.Ctx) error {
}
req.SupplierID = supplierID
locationID, err := strconv.ParseUint(c.FormValue("location_id"), 10, 64)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid location_id format")
}
req.LocationID = locationID
form, err := c.MultipartForm()
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid multipart form")
@@ -106,17 +112,7 @@ func (u *ExpenseController) CreateOne(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid expense_nonstocks JSON: %v", err))
}
if singleExpenseNonstock.KandangID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Field KandangID is required")
}
req.ExpenseNonstocks = []validation.ExpenseNonstock{singleExpenseNonstock}
} else {
for i, expenseNonstock := range req.ExpenseNonstocks {
if expenseNonstock.KandangID == 0 {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Field KandangID is required for expense_nonstocks[%d]", i))
}
}
}
} else {
return fiber.NewError(fiber.StatusBadRequest, "Field expense_nonstocks is required")
@@ -171,6 +167,15 @@ func (u *ExpenseController) UpdateOne(c *fiber.Ctx) error {
req.SupplierID = &supplierID
}
locationIDVal := c.FormValue("location_id")
if locationIDVal != "" {
locationID, err := strconv.ParseUint(locationIDVal, 10, 64)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid location_id format")
}
req.LocationID = &locationID
}
expenseNonstocksJSON := c.FormValue("expense_nonstocks")
if expenseNonstocksJSON != "" {
var expenseNonstocks []validation.ExpenseNonstock
@@ -178,12 +183,6 @@ func (u *ExpenseController) UpdateOne(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid expense_nonstocks JSON: %v", err))
}
for i, expenseNonstock := range expenseNonstocks {
if expenseNonstock.KandangID == 0 {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Field KandangID is required for expense_nonstocks[%d]", i))
}
}
req.ExpenseNonstocks = &expenseNonstocks
}
+18 -5
View File
@@ -76,7 +76,6 @@ type ExpenseRealizationDTO struct {
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"`
@@ -178,7 +177,6 @@ func ToExpenseDetailDTO(e *entity.Expense) ExpenseDetailDTO {
var pengajuans []ExpenseNonstockDTO
var realisasi []ExpenseRealizationDTO
// Map documents from Document service
for _, doc := range e.Documents {
documents = append(documents, DocumentDTO{
ID: uint64(doc.Id),
@@ -186,7 +184,6 @@ func ToExpenseDetailDTO(e *entity.Expense) ExpenseDetailDTO {
})
}
// Map realization documents from Document service
for _, doc := range e.RealizationDocuments {
realizationDocs = append(realizationDocs, DocumentDTO{
ID: uint64(doc.Id),
@@ -271,6 +268,8 @@ func ToExpenseNonstockDTO(ns entity.ExpenseNonstock) ExpenseNonstockDTO {
func ToKandangGroupDTO(pengajuans []ExpenseNonstockDTO, realisasi []ExpenseRealizationDTO, nonstocks []entity.ExpenseNonstock) []KandangGroupDTO {
kandangMap := make(map[uint64]*KandangGroupDTO)
var directPengajuans []ExpenseNonstockDTO
var directRealisasi []ExpenseRealizationDTO
for _, p := range pengajuans {
var kandangId uint64
@@ -287,16 +286,19 @@ func ToKandangGroupDTO(pengajuans []ExpenseNonstockDTO, realisasi []ExpenseReali
}
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)
} else {
directPengajuans = append(directPengajuans, p)
}
}
@@ -316,13 +318,24 @@ func ToKandangGroupDTO(pengajuans []ExpenseNonstockDTO, realisasi []ExpenseReali
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)
} else {
}
}
// If there are direct expenses (without kandang), add them as a special entry with id=0
if len(directPengajuans) > 0 || len(directRealisasi) > 0 {
kandangMap[0] = &KandangGroupDTO{
Id: 0,
Name: "",
Pengajuans: directPengajuans,
Realisasi: directRealisasi,
}
}
@@ -2,6 +2,7 @@ package service
import (
"context"
"encoding/json"
"errors"
"fmt"
@@ -144,11 +145,8 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen
supplierID := uint(req.SupplierID)
supplierExistsFunc := func(ctx context.Context, id uint) (bool, error) {
return commonRepo.Exists[entity.Supplier](ctx, s.SupplierRepo.DB(), id)
}
if err := commonSvc.EnsureRelations(c.Context(),
commonSvc.RelationCheck{Name: "Supplier", ID: &supplierID, Exists: supplierExistsFunc},
commonSvc.RelationCheck{Name: "Supplier", ID: &supplierID, Exists: s.SupplierRepo.IdExists},
); err != nil {
return nil, err
}
@@ -199,11 +197,47 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen
return fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context")
}
createdBy := uint64(actorID)
hasKandang := false
for _, ens := range req.ExpenseNonstocks {
if ens.KandangID != nil {
hasKandang = true
break
}
}
var projectFlockIdJSON *string
if !hasKandang && req.Category == string(utils.ExpenseCategoryBOP) {
projectFlockRepoTx := projectFlockKandangRepo.NewProjectflockRepository(dbTransaction)
activeProjectFlocks, err := projectFlockRepoTx.GetActiveByLocationID(c.Context(), req.LocationID)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get active project flocks for location")
}
if len(activeProjectFlocks) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "No active project flocks found for this location")
}
projectFlockIDs := make([]uint64, len(activeProjectFlocks))
for i, pf := range activeProjectFlocks {
projectFlockIDs[i] = uint64(pf.Id)
}
projectFlockIdsJSON, err := json.Marshal(projectFlockIDs)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to marshal project_flock_ids")
}
jsonStr := string(projectFlockIdsJSON)
projectFlockIdJSON = &jsonStr
}
expense = &entity.Expense{
ReferenceNumber: referenceNumber,
PoNumber: req.PoNumber,
Category: req.Category,
SupplierId: req.SupplierID,
LocationId: req.LocationID,
ProjectFlockId: projectFlockIdJSON,
TransactionDate: expenseDate,
CreatedBy: createdBy,
}
@@ -216,35 +250,36 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen
for _, expenseNonstock := range req.ExpenseNonstocks {
isAttachingToKandang := (expenseNonstock.KandangID != nil)
var projectFlockKandangId *uint64
var kandangId *uint64
if req.Category == string(utils.ExpenseCategoryBOP) {
if isAttachingToKandang {
kandangId = expenseNonstock.KandangID
projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(expenseNonstock.KandangID))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang")
if req.Category == string(utils.ExpenseCategoryBOP) {
projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(*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")
}
return fiber.NewError(fiber.StatusInternalServerError, "Failed to find project flock kandang for this kandang")
id := uint64(projectFlockKandang.Id)
projectFlockKandangId = &id
}
id := uint64(projectFlockKandang.Id)
projectFlockKandangId = &id
} else {
kandangId = nil
projectFlockKandangId = nil
}
for _, costItem := range expenseNonstock.CostItems {
nonstockId := costItem.NonstockID
var kandangId *uint64
if req.Category == string(utils.ExpenseCategoryNonBOP) {
id := uint64(expenseNonstock.KandangID)
kandangId = &id
} else if req.Category == string(utils.ExpenseCategoryBOP) {
if projectFlockKandangId != nil {
kandangId = &expenseNonstock.KandangID
}
}
expenseNonstock := &entity.ExpenseNonstock{
newExpenseNonstock := &entity.ExpenseNonstock{
ExpenseId: &expense.Id,
ProjectFlockKandangId: projectFlockKandangId,
KandangId: kandangId,
@@ -254,7 +289,7 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen
Notes: costItem.Notes,
}
if err := expenseNonstockRepoTx.CreateOne(c.Context(), expenseNonstock, nil); err != nil {
if err := expenseNonstockRepoTx.CreateOne(c.Context(), newExpenseNonstock, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense cost item")
}
}
@@ -361,6 +396,11 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
updateBody["supplier_id"] = *req.SupplierID
}
if req.LocationID != nil {
locationID := uint(*req.LocationID)
updateBody["location_id"] = locationID
}
if len(updateBody) == 0 && req.ExpenseNonstocks == nil && len(req.Documents) == 0 {
responseDTO, err := s.GetOne(c, id)
@@ -475,18 +515,26 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
for _, expenseNonstock := range *req.ExpenseNonstocks {
var projectFlockKandangId *uint64
var kandangId *uint64
if updatedExpense.Category == string(utils.ExpenseCategoryBOP) {
projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(tx)
projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(expenseNonstock.KandangID))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang")
// Check if attaching to kandang
if expenseNonstock.KandangID != nil {
kandangId = expenseNonstock.KandangID
if updatedExpense.Category == string(utils.ExpenseCategoryBOP) {
// BOP with kandang: Get active project flock kandang
projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(tx)
projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(*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")
}
return fiber.NewError(fiber.StatusInternalServerError, "Failed to find project flock kandang for this kandang")
id := uint64(projectFlockKandang.Id)
projectFlockKandangId = &id
}
id := uint64(projectFlockKandang.Id)
projectFlockKandangId = &id
// NON-BOP: projectFlockKandangId stays nil
}
for _, costItem := range expenseNonstock.CostItems {
@@ -498,18 +546,8 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
return err
}
var kandangId *uint64
if updatedExpense.Category == string(utils.ExpenseCategoryNonBOP) {
id := uint64(expenseNonstock.KandangID)
kandangId = &id
} else if updatedExpense.Category == string(utils.ExpenseCategoryBOP) {
if projectFlockKandangId != nil {
kandangId = &expenseNonstock.KandangID
}
}
expenseId := uint64(id)
expenseNonstock := &entity.ExpenseNonstock{
newExpenseNonstock := &entity.ExpenseNonstock{
ExpenseId: &expenseId,
ProjectFlockKandangId: projectFlockKandangId,
KandangId: kandangId,
@@ -519,7 +557,7 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
Notes: costItem.Notes,
}
if err := expenseNonstockRepoTx.CreateOne(c.Context(), expenseNonstock, nil); err != nil {
if err := expenseNonstockRepoTx.CreateOne(c.Context(), newExpenseNonstock, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense cost item")
}
}
@@ -9,12 +9,13 @@ type Create struct {
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"`
LocationID uint64 `form:"location_id" json:"location_id" validate:"required,gt=0"`
Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"`
ExpenseNonstocks []ExpenseNonstock `form:"expense_nonstocks" json:"expense_nonstocks" validate:"required,min=1,dive"`
}
type ExpenseNonstock struct {
KandangID uint64 `form:"kandang_id" json:"kandang_id" validate:"required,gt=0"`
KandangID *uint64 `form:"kandang_id" json:"kandang_id" validate:"omitempty"`
CostItems []CostItem `form:"cost_items" json:"cost_items" validate:"required,min=1,dive"`
}
@@ -22,13 +23,14 @@ 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"`
Price float64 `form:"price" json:"price" validate:"required,gt=0"`
Notes string `form:"notes" json:"notes" validate:"required,max=500"`
Notes string `form:"notes" json:"notes" validate:"omitempty,max=500"`
}
type Update struct {
TransactionDate *string `form:"transaction_date" json:"transaction_date" validate:"omitempty,datetime=2006-01-02"`
Category *string `form:"category" json:"category" validate:"omitempty,oneof=BOP NON-BOP"`
SupplierID *uint64 `form:"supplier_id" json:"supplier_id" validate:"omitempty,gt=0"`
LocationID *uint64 `form:"location_id" json:"location_id" validate:"omitempty,gt=0"`
Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"`
ExpenseNonstocks *[]ExpenseNonstock `form:"expense_nonstocks" json:"expense_nonstocks" validate:"omitempty,min=1,dive"`
}