feat(BE): recording

This commit is contained in:
ragilap
2025-10-23 15:23:28 +07:00
parent 346ae15314
commit 69ded31eb1
7 changed files with 186 additions and 67 deletions
+1 -2
View File
@@ -11,8 +11,7 @@ type Recording struct {
ProjectFlockKandangId uint `gorm:"column:project_flock_id;not null;index"`
RecordDatetime time.Time `gorm:"column:record_datetime;not null"`
RecordDate *time.Time `gorm:"column:record_date"`
Status int `gorm:"column:status;not null;default:0"`
Ontime bool `gorm:"column:ontime;not null;default:false"`
Ontime int `gorm:"column:ontime;not null;default:0"`
Day *int `gorm:"column:day"`
TotalDepletion *int `gorm:"column:total_depletion"`
CumDepletionRate *float64 `gorm:"column:cum_depletion_rate"`
@@ -67,7 +67,6 @@ func (r *projectFlockKandangRepositoryImpl) GetByID(ctx context.Context, id uint
Preload("ProjectFlock").
Preload("ProjectFlock.Flock").
Preload("Kandang").
Preload("CreatedUser").
First(record, id).Error; err != nil {
return nil, err
}
@@ -71,7 +71,7 @@ func (u *RecordingController) GetOne(c *fiber.Ctx) error {
Code: fiber.StatusOK,
Status: "success",
Message: "Get recording successfully",
Data: dto.ToRecordingListDTO(*result),
Data: dto.ToRecordingDetailDTO(*result),
})
}
@@ -92,7 +92,7 @@ func (u *RecordingController) CreateOne(c *fiber.Ctx) error {
Code: fiber.StatusCreated,
Status: "success",
Message: "Create recording successfully",
Data: dto.ToRecordingListDTO(*result),
Data: dto.ToRecordingDetailDTO(*result),
})
}
@@ -119,7 +119,7 @@ func (u *RecordingController) UpdateOne(c *fiber.Ctx) error {
Code: fiber.StatusOK,
Status: "success",
Message: "Update recording successfully",
Data: dto.ToRecordingListDTO(*result),
Data: dto.ToRecordingDetailDTO(*result),
})
}
@@ -14,7 +14,6 @@ type RecordingBaseDTO struct {
ProjectFlockKandangId uint `json:"project_flock_kandang_id"`
RecordDatetime time.Time `json:"record_datetime"`
RecordDate *time.Time `json:"record_date,omitempty"`
Status int `json:"status"`
Ontime bool `json:"ontime"`
Day *int `json:"day,omitempty"`
TotalDepletion *int `json:"total_depletion,omitempty"`
@@ -37,18 +36,51 @@ type RecordingListDTO struct {
type RecordingDetailDTO struct {
RecordingListDTO
BodyWeights []RecordingBodyWeightDTO `json:"body_weights"`
Depletions []RecordingDepletionDTO `json:"depletions"`
Stocks []RecordingStockDTO `json:"stocks"`
}
type RecordingBodyWeightDTO struct {
Weight float64 `json:"weight"`
Qty int `json:"qty"`
Notes *string `json:"notes,omitempty"`
}
type RecordingDepletionDTO struct {
ProductWarehouseId uint `json:"product_warehouse_id"`
Total int64 `json:"total"`
Notes *string `json:"notes,omitempty"`
}
type RecordingStockDTO struct {
ProductWarehouseId uint `json:"product_warehouse_id"`
Increase *float64 `json:"increase,omitempty"`
Decrease *float64 `json:"decrease,omitempty"`
UsageAmount *int64 `json:"usage_amount,omitempty"`
Notes *string `json:"notes,omitempty"`
}
// === Mapper Functions ===
func ToRecordingBaseDTO(e entity.Recording) RecordingBaseDTO {
recordDate := e.RecordDate
if recordDate == nil {
rd := time.Date(
e.RecordDatetime.Year(),
e.RecordDatetime.Month(),
e.RecordDatetime.Day(),
0, 0, 0, 0,
e.RecordDatetime.Location(),
)
recordDate = &rd
}
return RecordingBaseDTO{
Id: e.Id,
ProjectFlockKandangId: e.ProjectFlockKandangId,
RecordDatetime: e.RecordDatetime,
RecordDate: e.RecordDate,
Status: e.Status,
Ontime: e.Ontime,
RecordDate: recordDate,
Ontime: e.Ontime == 1,
Day: e.Day,
TotalDepletion: e.TotalDepletion,
CumDepletionRate: e.CumDepletionRate,
@@ -88,5 +120,46 @@ func ToRecordingListDTOs(e []entity.Recording) []RecordingListDTO {
func ToRecordingDetailDTO(e entity.Recording) RecordingDetailDTO {
return RecordingDetailDTO{
RecordingListDTO: ToRecordingListDTO(e),
BodyWeights: ToRecordingBodyWeightDTOs(e.BodyWeights),
Depletions: ToRecordingDepletionDTOs(e.Depletions),
Stocks: ToRecordingStockDTOs(e.Stocks),
}
}
func ToRecordingBodyWeightDTOs(bodyWeights []entity.RecordingBW) []RecordingBodyWeightDTO {
result := make([]RecordingBodyWeightDTO, len(bodyWeights))
for i, bw := range bodyWeights {
result[i] = RecordingBodyWeightDTO{
Weight: bw.Weight,
Qty: bw.Qty,
Notes: bw.Notes,
}
}
return result
}
func ToRecordingDepletionDTOs(depletions []entity.RecordingDepletion) []RecordingDepletionDTO {
result := make([]RecordingDepletionDTO, len(depletions))
for i, d := range depletions {
result[i] = RecordingDepletionDTO{
ProductWarehouseId: d.ProductWarehouseId,
Total: d.Total,
Notes: d.Notes,
}
}
return result
}
func ToRecordingStockDTOs(stocks []entity.RecordingStock) []RecordingStockDTO {
result := make([]RecordingStockDTO, len(stocks))
for i, s := range stocks {
result[i] = RecordingStockDTO{
ProductWarehouseId: s.ProductWarehouseId,
Increase: s.Increase,
Decrease: s.Decrease,
UsageAmount: s.UsageAmount,
Notes: s.Notes,
}
}
return result
}
@@ -5,6 +5,8 @@ import (
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
sRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services"
@@ -16,11 +18,12 @@ type RecordingModule struct{}
func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
recordingRepo := rRecording.NewRecordingRepository(db)
projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
userRepo := rUser.NewUserRepository(db)
recordingService := sRecording.NewRecordingService(recordingRepo, validate)
recordingService := sRecording.NewRecordingService(recordingRepo, projectFlockKandangRepo, productWarehouseRepo, validate)
userService := sUser.NewUserService(userRepo, validate)
RecordingRoutes(router, userService, recordingService)
}
@@ -9,6 +9,8 @@ import (
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
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"
@@ -28,16 +30,25 @@ type RecordingService interface {
}
type recordingService struct {
Log *logrus.Logger
Validate *validator.Validate
Repository repository.RecordingRepository
Log *logrus.Logger
Validate *validator.Validate
Repository repository.RecordingRepository
ProjectFlockKandangRepo rProjectFlockKandang.ProjectFlockKandangRepository
ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository
}
func NewRecordingService(repo repository.RecordingRepository, validate *validator.Validate) RecordingService {
func NewRecordingService(
repo repository.RecordingRepository,
projectFlockKandangRepo rProjectFlockKandang.ProjectFlockKandangRepository,
productWarehouseRepo rProductWarehouse.ProductWarehouseRepository,
validate *validator.Validate,
) RecordingService {
return &recordingService{
Log: utils.Log,
Validate: validate,
Repository: repo,
Log: utils.Log,
Validate: validate,
Repository: repo,
ProjectFlockKandangRepo: projectFlockKandangRepo,
ProductWarehouseRepo: productWarehouseRepo,
}
}
@@ -98,9 +109,16 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
return nil, err
}
recordTime, err := time.Parse(time.RFC3339, req.RecordDatetime)
if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "record_datetime must be in RFC3339 format")
if _, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), req.ProjectFlockKandangId); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang not found")
}
s.Log.Errorf("Failed to get project flock kandang: %+v", err)
return nil, err
}
if err := s.ensureProductWarehousesExist(c, req.Stocks, req.Depletions); err != nil {
return nil, err
}
tx := s.Repository.DB().WithContext(c.Context()).Begin()
@@ -122,20 +140,22 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
return nil, err
}
status := 0
if req.Status != nil {
status = *req.Status
}
ontime := false
if req.Ontime != nil {
ontime = *req.Ontime
}
currentTime := time.Now().UTC()
recordTime := currentTime
recordDate := time.Date(
recordTime.Year(),
recordTime.Month(),
recordTime.Day(),
0, 0, 0, 0,
recordTime.Location(),
)
ontimeFlag := computeOntime(recordTime, currentTime)
recording := &entity.Recording{
ProjectFlockKandangId: req.ProjectFlockKandangId,
RecordDatetime: recordTime,
Status: status,
Ontime: ontime,
RecordDate: &recordDate,
Ontime: boolToInt(ontimeFlag),
Day: &nextDay,
CreatedBy: 1, // TODO: replace with authenticated user
}
@@ -203,33 +223,13 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
return nil, err
}
updateBody := make(map[string]any)
if req.RecordDatetime != nil {
parsed, err := time.Parse(time.RFC3339, *req.RecordDatetime)
if err != nil {
_ = tx.Rollback()
return nil, fiber.NewError(fiber.StatusBadRequest, "record_datetime must be in RFC3339 format")
}
updateBody["record_datetime"] = parsed
recording.RecordDatetime = parsed
}
if req.Status != nil {
updateBody["status"] = *req.Status
recording.Status = *req.Status
}
if req.Ontime != nil {
updateBody["ontime"] = *req.Ontime
recording.Ontime = *req.Ontime
}
if len(updateBody) > 0 {
if err := tx.Model(&entity.Recording{}).Where("id = ?", id).Updates(updateBody).Error; err != nil {
_ = tx.Rollback()
s.Log.Errorf("Failed to update recording: %+v", err)
return nil, err
}
ontimeValue := boolToInt(computeOntime(recording.RecordDatetime, time.Now().UTC()))
if err := tx.Model(&entity.Recording{}).Where("id = ?", id).Update("ontime", ontimeValue).Error; err != nil {
_ = tx.Rollback()
s.Log.Errorf("Failed to refresh ontime flag: %+v", err)
return nil, err
}
recording.Ontime = ontimeValue
if req.BodyWeights != nil {
if err := s.replaceBodyWeights(tx, recording.Id, req.BodyWeights); err != nil {
@@ -239,6 +239,10 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
}
}
if req.Stocks != nil {
if err := s.ensureProductWarehousesExist(c, req.Stocks, nil); err != nil {
_ = tx.Rollback()
return nil, err
}
if err := s.replaceStocks(tx, recording.Id, req.Stocks); err != nil {
_ = tx.Rollback()
s.Log.Errorf("Failed to update stocks: %+v", err)
@@ -246,6 +250,10 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
}
}
if req.Depletions != nil {
if err := s.ensureProductWarehousesExist(c, nil, req.Depletions); err != nil {
_ = tx.Rollback()
return nil, err
}
if err := s.replaceDepletions(tx, recording.Id, req.Depletions); err != nil {
_ = tx.Rollback()
s.Log.Errorf("Failed to update depletions: %+v", err)
@@ -280,6 +288,38 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error {
// === Persistence Helpers ===
func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []validation.Stock, depletions []validation.Depletion) error {
idSet := make(map[uint]struct{})
for _, stock := range stocks {
if stock.ProductWarehouseId != 0 {
idSet[stock.ProductWarehouseId] = struct{}{}
}
}
for _, dep := range depletions {
if dep.ProductWarehouseId != 0 {
idSet[dep.ProductWarehouseId] = struct{}{}
}
}
if len(idSet) == 0 {
return nil
}
for id := range idSet {
ok, err := s.ProductWarehouseRepo.ExistsByID(c.Context(), id)
if err != nil {
s.Log.Errorf("Failed to validate product warehouse %d: %+v", id, err)
return err
}
if !ok {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Product warehouse %d not found", id))
}
}
return nil
}
func (s *recordingService) generateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error) {
var days []int
if err := tx.Model(&entity.Recording{}).
@@ -319,6 +359,17 @@ func nextRecordingDay(days []int) int {
return len(normalized) + 1
}
func computeOntime(recordDatetime, reference time.Time) bool {
return !recordDatetime.Before(reference)
}
func boolToInt(v bool) int {
if v {
return 1
}
return 0
}
func (s *recordingService) persistBodyWeights(tx *gorm.DB, recordingID uint, payload []validation.BodyWeight) error {
if len(payload) == 0 {
return nil
@@ -24,21 +24,15 @@ type (
type Create struct {
ProjectFlockKandangId uint `json:"project_flock_kandang_id" validate:"required,number,min=1"`
RecordDatetime string `json:"record_datetime" validate:"required,datetime=2006-01-02T15:04:05Z07:00"`
Status *int `json:"status,omitempty" validate:"omitempty,oneof=0 1 2 3"`
Ontime *bool `json:"ontime,omitempty" validate:"omitempty"`
BodyWeights []BodyWeight `json:"body_weights,omitempty" validate:"omitempty,dive"`
Stocks []Stock `json:"stocks,omitempty" validate:"omitempty,dive"`
Depletions []Depletion `json:"depletions,omitempty" validate:"omitempty,dive"`
}
type Update struct {
RecordDatetime *string `json:"record_datetime,omitempty" validate:"omitempty,datetime=2006-01-02T15:04:05Z07:00"`
Status *int `json:"status,omitempty" validate:"omitempty,oneof=0 1 2 3"`
Ontime *bool `json:"ontime,omitempty" validate:"omitempty"`
BodyWeights []BodyWeight `json:"body_weights,omitempty" validate:"omitempty,dive"`
Stocks []Stock `json:"stocks,omitempty" validate:"omitempty,dive"`
Depletions []Depletion `json:"depletions,omitempty" validate:"omitempty,dive"`
BodyWeights []BodyWeight `json:"body_weights,omitempty" validate:"omitempty,dive"`
Stocks []Stock `json:"stocks,omitempty" validate:"omitempty,dive"`
Depletions []Depletion `json:"depletions,omitempty" validate:"omitempty,dive"`
}
type Query struct {