FIX[BE]: period without autoincrement

This commit is contained in:
ragilap
2025-10-16 15:30:36 +07:00
parent 6c7ab8a0f8
commit 3ec05eb76f
16 changed files with 533 additions and 27 deletions
@@ -0,0 +1 @@
DROP INDEX IF EXISTS project_flocks_flock_period_unique;
@@ -0,0 +1,3 @@
CREATE UNIQUE INDEX project_flocks_flock_period_unique
ON project_flocks (flock_id, period)
WHERE deleted_at IS NULL;
+2 -2
View File
@@ -8,12 +8,12 @@ import (
type ProjectFlock struct {
Id uint `gorm:"primaryKey"`
FlockId uint `gorm:"not null"`
FlockId uint `gorm:"not null;uniqueIndex:idx_project_flocks_flock_period,priority:1"`
AreaId uint `gorm:"not null"`
ProductCategoryId uint `gorm:"not null"`
FcrId uint `gorm:"not null"`
LocationId uint `gorm:"not null"`
Period int `gorm:"not null"`
Period int `gorm:"not null;uniqueIndex:idx_project_flocks_flock_period,priority:2"`
CreatedBy uint `gorm:"not null"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
+18
View File
@@ -0,0 +1,18 @@
package entities
import (
"time"
"gorm.io/gorm"
)
type Recording struct {
Id uint `gorm:"primaryKey"`
Name string `gorm:"not null;uniqueIndex:idx_name,where:deleted_at IS NULL"`
CreatedBy uint `gorm:"not null"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"`
}
@@ -57,7 +57,6 @@ func (r *ProjectflockRepositoryImpl) GetMaxPeriodByFlock(ctx context.Context, fl
var max int
if err := r.DB().WithContext(ctx).
Model(&entity.ProjectFlock{}).
Unscoped().
Where("flock_id = ?", flockID).
Select("COALESCE(MAX(period), 0)").
Scan(&max).Error; err != nil {
@@ -15,6 +15,7 @@ import (
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type ProjectflockService interface {
@@ -30,12 +31,12 @@ type projectflockService struct {
Log *logrus.Logger
Validate *validator.Validate
Repository repository.ProjectflockRepository
FlockRepo flockRepository.FlockRepository
FlockRepo flockRepository.FlockRepository
KandangRepo kandangRepository.KandangRepository
}
type FlockPeriodSummary struct {
Flock entity.Flock
Flock entity.Flock
NextPeriod int
}
@@ -49,7 +50,7 @@ func NewProjectflockService(
Log: utils.Log,
Validate: validate,
Repository: repo,
FlockRepo: flockRepo,
FlockRepo: flockRepo,
KandangRepo: kandangRepo,
}
}
@@ -127,19 +128,33 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (*
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to start transaction")
}
var nextPeriod int
periodQuery := tx.Model(&entity.ProjectFlock{}).
Where("flock_id = ?", req.FlockId).
Clauses(clause.Locking{Strength: "UPDATE"})
if err := periodQuery.Select("COALESCE(MAX(period), 0)").Scan(&nextPeriod).Error; err != nil {
tx.Rollback()
s.Log.Errorf("Failed to determine next period for flock %d: %+v", req.FlockId, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to determine next period")
}
nextPeriod++
projectRepo := s.Repository.WithTx(tx)
createBody := &entity.ProjectFlock{
FlockId: req.FlockId,
FlockId: req.FlockId,
AreaId: req.AreaId,
ProductCategoryId: req.ProductCategoryId,
FcrId: req.FcrId,
LocationId: req.LocationId,
Period: req.Period,
Period: nextPeriod,
CreatedBy: 1,
}
if err := projectRepo.CreateOne(c.Context(), createBody, nil); err != nil {
tx.Rollback()
if errors.Is(err, gorm.ErrDuplicatedKey) {
return nil, fiber.NewError(fiber.StatusConflict, "Project flock period already exists")
}
s.Log.Errorf("Failed to create projectflock: %+v", err)
return nil, err
}
@@ -353,7 +368,7 @@ func (s projectflockService) GetFlockPeriodSummary(c *fiber.Ctx, flockID uint) (
}
return &FlockPeriodSummary{
Flock: *flock,
Flock: *flock,
NextPeriod: maxPeriod + 1,
}, nil
}
@@ -1,17 +1,16 @@
package validation
type Create struct {
FlockId uint `json:"flock_id" validate:"required_strict,number,gt=0"`
FlockId uint `json:"flock_id" validate:"required_strict,number,gt=0"`
AreaId uint `json:"area_id" validate:"required_strict,number,gt=0"`
ProductCategoryId uint `json:"product_category_id" validate:"required_strict,number,gt=0"`
FcrId uint `json:"fcr_id" validate:"required_strict,number,gt=0"`
LocationId uint `json:"location_id" validate:"required_strict,number,gt=0"`
Period int `json:"period" validate:"required_strict,number,gt=0"`
KandangIds []uint `json:"kandang_ids" validate:"required,min=1,dive,gt=0"`
}
type Update struct {
FlockId *uint `json:"flock_id,omitempty" validate:"omitempty,number,gt=0"`
FlockId *uint `json:"flock_id,omitempty" validate:"omitempty,number,gt=0"`
AreaId *uint `json:"area_id,omitempty" validate:"omitempty,number,gt=0"`
ProductCategoryId *uint `json:"product_category_id,omitempty" validate:"omitempty,number,gt=0"`
FcrId *uint `json:"fcr_id,omitempty" validate:"omitempty,number,gt=0"`
@@ -0,0 +1,140 @@
package controller
import (
"math"
"strconv"
"gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations"
"gitlab.com/mbugroup/lti-api.git/internal/response"
"github.com/gofiber/fiber/v2"
)
type RecordingController struct {
RecordingService service.RecordingService
}
func NewRecordingController(recordingService service.RecordingService) *RecordingController {
return &RecordingController{
RecordingService: recordingService,
}
}
func (u *RecordingController) GetAll(c *fiber.Ctx) error {
query := &validation.Query{
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""),
}
result, totalResults, err := u.RecordingService.GetAll(c, query)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.SuccessWithPaginate[dto.RecordingListDTO]{
Code: fiber.StatusOK,
Status: "success",
Message: "Get all recordings successfully",
Meta: response.Meta{
Page: query.Page,
Limit: query.Limit,
TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))),
TotalResults: totalResults,
},
Data: dto.ToRecordingListDTOs(result),
})
}
func (u *RecordingController) GetOne(c *fiber.Ctx) error {
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
result, err := u.RecordingService.GetOne(c, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Get recording successfully",
Data: dto.ToRecordingListDTO(*result),
})
}
func (u *RecordingController) CreateOne(c *fiber.Ctx) error {
req := new(validation.Create)
if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
result, err := u.RecordingService.CreateOne(c, req)
if err != nil {
return err
}
return c.Status(fiber.StatusCreated).
JSON(response.Success{
Code: fiber.StatusCreated,
Status: "success",
Message: "Create recording successfully",
Data: dto.ToRecordingListDTO(*result),
})
}
func (u *RecordingController) UpdateOne(c *fiber.Ctx) error {
req := new(validation.Update)
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
result, err := u.RecordingService.UpdateOne(c, req, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Update recording successfully",
Data: dto.ToRecordingListDTO(*result),
})
}
func (u *RecordingController) DeleteOne(c *fiber.Ctx) error {
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
if err := u.RecordingService.DeleteOne(c, uint(id)); err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Common{
Code: fiber.StatusOK,
Status: "success",
Message: "Delete recording successfully",
})
}
@@ -0,0 +1,64 @@
package dto
import (
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
)
// === DTO Structs ===
type RecordingBaseDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
}
type RecordingListDTO struct {
RecordingBaseDTO
CreatedUser *userDTO.UserBaseDTO `json:"created_user"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type RecordingDetailDTO struct {
RecordingListDTO
}
// === Mapper Functions ===
func ToRecordingBaseDTO(e entity.Recording) RecordingBaseDTO {
return RecordingBaseDTO{
Id: e.Id,
Name: e.Name,
}
}
func ToRecordingListDTO(e entity.Recording) RecordingListDTO {
var createdUser *userDTO.UserBaseDTO
if e.CreatedUser.Id != 0 {
mapped := userDTO.ToUserBaseDTO(e.CreatedUser)
createdUser = &mapped
}
return RecordingListDTO{
RecordingBaseDTO: ToRecordingBaseDTO(e),
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
CreatedUser: createdUser,
}
}
func ToRecordingListDTOs(e []entity.Recording) []RecordingListDTO {
result := make([]RecordingListDTO, len(e))
for i, r := range e {
result[i] = ToRecordingListDTO(r)
}
return result
}
func ToRecordingDetailDTO(e entity.Recording) RecordingDetailDTO {
return RecordingDetailDTO{
RecordingListDTO: ToRecordingListDTO(e),
}
}
@@ -0,0 +1,26 @@
package recordings
import (
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
sRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services"
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
)
type RecordingModule struct{}
func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
recordingRepo := rRecording.NewRecordingRepository(db)
userRepo := rUser.NewUserRepository(db)
recordingService := sRecording.NewRecordingService(recordingRepo, validate)
userService := sUser.NewUserService(userRepo, validate)
RecordingRoutes(router, userService, recordingService)
}
@@ -0,0 +1,21 @@
package repository
import (
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
"gorm.io/gorm"
)
type RecordingRepository interface {
repository.BaseRepository[entity.Recording]
}
type RecordingRepositoryImpl struct {
*repository.BaseRepositoryImpl[entity.Recording]
}
func NewRecordingRepository(db *gorm.DB) RecordingRepository {
return &RecordingRepositoryImpl{
BaseRepositoryImpl: repository.NewBaseRepository[entity.Recording](db),
}
}
@@ -0,0 +1,28 @@
package recordings
import (
// m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
controller "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/controllers"
recording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services"
user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
"github.com/gofiber/fiber/v2"
)
func RecordingRoutes(v1 fiber.Router, u user.UserService, s recording.RecordingService) {
ctrl := controller.NewRecordingController(s)
route := v1.Group("/recordings")
// route.Get("/", m.Auth(u), ctrl.GetAll)
// route.Post("/", m.Auth(u), ctrl.CreateOne)
// route.Get("/:id", m.Auth(u), ctrl.GetOne)
// route.Patch("/:id", m.Auth(u), ctrl.UpdateOne)
// route.Delete("/:id", m.Auth(u), ctrl.DeleteOne)
route.Get("/", ctrl.GetAll)
route.Post("/", ctrl.CreateOne)
route.Get("/:id", ctrl.GetOne)
route.Patch("/:id", ctrl.UpdateOne)
route.Delete("/:id", ctrl.DeleteOne)
}
@@ -0,0 +1,129 @@
package service
import (
"errors"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
)
type RecordingService interface {
GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Recording, int64, error)
GetOne(ctx *fiber.Ctx, id uint) (*entity.Recording, error)
CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Recording, error)
UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Recording, error)
DeleteOne(ctx *fiber.Ctx, id uint) error
}
type recordingService struct {
Log *logrus.Logger
Validate *validator.Validate
Repository repository.RecordingRepository
}
func NewRecordingService(repo repository.RecordingRepository, validate *validator.Validate) RecordingService {
return &recordingService{
Log: utils.Log,
Validate: validate,
Repository: repo,
}
}
func (s recordingService) withRelations(db *gorm.DB) *gorm.DB {
return db.Preload("CreatedUser")
}
func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Recording, int64, error) {
if err := s.Validate.Struct(params); err != nil {
return nil, 0, err
}
offset := (params.Page - 1) * params.Limit
recordings, 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.Order("created_at DESC").Order("updated_at DESC")
})
if err != nil {
s.Log.Errorf("Failed to get recordings: %+v", err)
return nil, 0, err
}
return recordings, total, nil
}
func (s recordingService) GetOne(c *fiber.Ctx, id uint) (*entity.Recording, error) {
recording, err := s.Repository.GetByID(c.Context(), id, s.withRelations)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Recording not found")
}
if err != nil {
s.Log.Errorf("Failed get recording by id: %+v", err)
return nil, err
}
return recording, nil
}
func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Recording, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
createBody := &entity.Recording{
Name: req.Name,
}
if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil {
s.Log.Errorf("Failed to create recording: %+v", err)
return nil, err
}
return s.GetOne(c, createBody.Id)
}
func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Recording, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
updateBody := make(map[string]any)
if req.Name != nil {
updateBody["name"] = *req.Name
}
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, "Recording not found")
}
s.Log.Errorf("Failed to update recording: %+v", err)
return nil, err
}
return s.GetOne(c, id)
}
func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error {
if err := s.Repository.DeleteOne(c.Context(), id); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "Recording not found")
}
s.Log.Errorf("Failed to delete recording: %+v", err)
return err
}
return nil
}
@@ -0,0 +1,15 @@
package validation
type Create struct {
Name string `json:"name" validate:"required_strict,min=3"`
}
type Update struct {
Name *string `json:"name,omitempty" validate:"omitempty"`
}
type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
Search string `query:"search" validate:"omitempty,max=50"`
}
+2
View File
@@ -8,6 +8,7 @@ import (
"gorm.io/gorm"
projectflocks "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks"
recordings "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings"
// MODULE IMPORTS
)
@@ -16,6 +17,7 @@ func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Valida
allModules := []modules.Module{
projectflocks.ProjectflockModule{},
recordings.RecordingModule{},
// MODULE REGISTRY
}
@@ -22,12 +22,11 @@ func TestProjectFlockSummary(t *testing.T) {
kandangID := createKandang(t, app, "Kandang Summary", locationID, 1)
createPayload := map[string]any{
"flock_id": flockID,
"flock_id": flockID,
"area_id": areaID,
"product_category_id": categoryID,
"fcr_id": fcrID,
"location_id": locationID,
"period": 1,
"kandang_ids": []uint{kandangID},
}
resp, body := doJSONRequest(t, app, http.MethodPost, "/api/production/project_flocks", createPayload)
@@ -37,14 +36,9 @@ func TestProjectFlockSummary(t *testing.T) {
var createResp struct {
Data struct {
Id uint `json:"id"`
FlockId uint `json:"flock_id"`
AreaId uint `json:"area_id"`
ProductCategoryId uint `json:"product_category_id"`
FcrId uint `json:"fcr_id"`
LocationId uint `json:"location_id"`
Period int `json:"period"`
Flock struct {
Id uint `json:"id"`
Period int `json:"period"`
Flock struct {
Id uint `json:"id"`
Name string `json:"name"`
} `json:"flock"`
@@ -82,18 +76,47 @@ func TestProjectFlockSummary(t *testing.T) {
if err := json.Unmarshal(body, &createResp); err != nil {
t.Fatalf("failed to parse create response: %v", err)
}
if createResp.Data.FlockId != flockID || createResp.Data.Flock.Name == "" {
if createResp.Data.Flock.Id != flockID || createResp.Data.Flock.Name == "" {
t.Fatalf("expected flock detail to be present, got %+v", createResp.Data.Flock)
}
if createResp.Data.AreaId != areaID || createResp.Data.Area.Name == "" {
if createResp.Data.Area.Id != areaID || createResp.Data.Area.Name == "" {
t.Fatalf("expected area detail to be present, got %+v", createResp.Data.Area)
}
if createResp.Data.LocationId != locationID || createResp.Data.Location.Name == "" {
if createResp.Data.Location.Id != locationID || createResp.Data.Location.Name == "" {
t.Fatalf("expected location detail to be present, got %+v", createResp.Data.Location)
}
if len(createResp.Data.Kandangs) != 1 || createResp.Data.Kandangs[0].Id != kandangID {
t.Fatalf("expected kandang detail to be present, got %+v", createResp.Data.Kandangs)
}
if createResp.Data.Period != 1 {
t.Fatalf("expected period 1 to be assigned automatically, got %d", createResp.Data.Period)
}
secondKandangID := createKandang(t, app, "Kandang Summary 2", locationID, 1)
secondPayload := map[string]any{
"flock_id": flockID,
"area_id": areaID,
"product_category_id": categoryID,
"fcr_id": fcrID,
"location_id": locationID,
"kandang_ids": []uint{secondKandangID},
}
resp, body = doJSONRequest(t, app, http.MethodPost, "/api/production/project_flocks", secondPayload)
if resp.StatusCode != fiber.StatusCreated {
t.Fatalf("expected 201 when creating second project flock, got %d: %s", resp.StatusCode, string(body))
}
var createRespSecond struct {
Data struct {
Id uint `json:"id"`
Period int `json:"period"`
} `json:"data"`
}
if err := json.Unmarshal(body, &createRespSecond); err != nil {
t.Fatalf("failed to parse second create response: %v", err)
}
if createRespSecond.Data.Period != 2 {
t.Fatalf("expected second period to be 2, got %d", createRespSecond.Data.Period)
}
resp, body = doJSONRequest(t, app, http.MethodGet, "/api/production/project_flocks/flocks/"+uintToString(flockID)+"/periods", nil)
if resp.StatusCode != fiber.StatusOK {
@@ -109,8 +132,31 @@ func TestProjectFlockSummary(t *testing.T) {
t.Fatalf("failed to parse summary response: %v", err)
}
if summary.Data.NextPeriod != 2 {
t.Fatalf("expected next_period 2, got %d", summary.Data.NextPeriod)
if summary.Data.NextPeriod != 3 {
t.Fatalf("expected next_period 3, got %d", summary.Data.NextPeriod)
}
resp, body = doJSONRequest(t, app, http.MethodDelete, "/api/production/project_flocks/"+uintToString(createResp.Data.Id), nil)
if resp.StatusCode != fiber.StatusOK {
t.Fatalf("expected 200 when deleting first project flock, got %d: %s", resp.StatusCode, string(body))
}
resp, body = doJSONRequest(t, app, http.MethodDelete, "/api/production/project_flocks/"+uintToString(createRespSecond.Data.Id), nil)
if resp.StatusCode != fiber.StatusOK {
t.Fatalf("expected 200 when deleting second project flock, got %d: %s", resp.StatusCode, string(body))
}
resp, body = doJSONRequest(t, app, http.MethodGet, "/api/production/project_flocks/flocks/"+uintToString(flockID)+"/periods", nil)
if resp.StatusCode != fiber.StatusOK {
t.Fatalf("expected 200 when fetching summary after delete, got %d: %s", resp.StatusCode, string(body))
}
if err := json.Unmarshal(body, &summary); err != nil {
t.Fatalf("failed to parse summary response after delete: %v", err)
}
if summary.Data.NextPeriod != 1 {
t.Fatalf("expected next_period 1 after soft deletes, got %d", summary.Data.NextPeriod)
}
}