From a2d2c4269a26cf9dd2f53a03a0e2f7bbab374bf9 Mon Sep 17 00:00:00 2001 From: ragilap Date: Wed, 7 Jan 2026 20:26:27 +0700 Subject: [PATCH] feat(BE-281): fixing recording error, fixing limit upload uniformity and purchase, add filter and statistic uniformity --- internal/config/fiber.go | 1 + ...0107075608_alter_uniformity_table.down.sql | 7 + ...260107075608_alter_uniformity_table.up.sql | 25 ++ .../project_flock_kandang_uniformity.go | 30 +- internal/entities/recording_egg.go | 2 +- .../controllers/uniformity.controller.go | 22 +- .../uniformities/dto/uniformity.dto.go | 55 ++- .../repositories/uniformity.repository.go | 44 ++ .../services/uniformity.body_weight_excel.go | 200 --------- .../services/uniformity.calculate.go | 393 ++++++++++++++++++ .../services/uniformity.service.go | 335 +++++++-------- .../uniformities/types/uniformity.types.go | 85 ++++ .../validations/uniformity.validation.go | 63 ++- .../controllers/purchase.controller.go | 15 + 14 files changed, 847 insertions(+), 430 deletions(-) create mode 100644 internal/database/migrations/20260107075608_alter_uniformity_table.down.sql create mode 100644 internal/database/migrations/20260107075608_alter_uniformity_table.up.sql delete mode 100644 internal/modules/production/uniformities/services/uniformity.body_weight_excel.go create mode 100644 internal/modules/production/uniformities/services/uniformity.calculate.go create mode 100644 internal/modules/production/uniformities/types/uniformity.types.go diff --git a/internal/config/fiber.go b/internal/config/fiber.go index aea67b5f..40c2c818 100644 --- a/internal/config/fiber.go +++ b/internal/config/fiber.go @@ -13,6 +13,7 @@ func FiberConfig() fiber.Config { CaseSensitive: true, ServerHeader: "Fiber", AppName: "Fiber API", + BodyLimit: 8 * 1024 * 1024, ErrorHandler: utils.ErrorHandler, JSONEncoder: sonic.Marshal, JSONDecoder: sonic.Unmarshal, diff --git a/internal/database/migrations/20260107075608_alter_uniformity_table.down.sql b/internal/database/migrations/20260107075608_alter_uniformity_table.down.sql new file mode 100644 index 00000000..bb7b412d --- /dev/null +++ b/internal/database/migrations/20260107075608_alter_uniformity_table.down.sql @@ -0,0 +1,7 @@ +-- Remove chart_data, uniform_date, and related indexes +DROP INDEX IF EXISTS idx_project_flock_kandang_uniformity_uniform_date; +DROP INDEX IF EXISTS idx_project_flock_kandang_uniformity_unique; + +ALTER TABLE project_flock_kandang_uniformity + DROP COLUMN IF EXISTS chart_data, + DROP COLUMN IF EXISTS uniform_date; diff --git a/internal/database/migrations/20260107075608_alter_uniformity_table.up.sql b/internal/database/migrations/20260107075608_alter_uniformity_table.up.sql new file mode 100644 index 00000000..4c256096 --- /dev/null +++ b/internal/database/migrations/20260107075608_alter_uniformity_table.up.sql @@ -0,0 +1,25 @@ +-- Add uniform_date (if missing), chart_data, and unique constraint for uniformity records +ALTER TABLE project_flock_kandang_uniformity + ADD COLUMN IF NOT EXISTS uniform_date TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS chart_data JSONB; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'project_flock_kandang_uniformity' + AND column_name = 'deleted_at' + ) THEN + CREATE UNIQUE INDEX IF NOT EXISTS idx_project_flock_kandang_uniformity_unique + ON project_flock_kandang_uniformity (project_flock_kandang_id, week, uniform_date) + WHERE deleted_at IS NULL; + ELSE + CREATE UNIQUE INDEX IF NOT EXISTS idx_project_flock_kandang_uniformity_unique + ON project_flock_kandang_uniformity (project_flock_kandang_id, week, uniform_date); + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_project_flock_kandang_uniformity_uniform_date + ON project_flock_kandang_uniformity (uniform_date); diff --git a/internal/entities/project_flock_kandang_uniformity.go b/internal/entities/project_flock_kandang_uniformity.go index bf320c72..9171eefa 100644 --- a/internal/entities/project_flock_kandang_uniformity.go +++ b/internal/entities/project_flock_kandang_uniformity.go @@ -1,20 +1,24 @@ package entities -import "time" +import ( + "encoding/json" + "time" +) type ProjectFlockKandangUniformity struct { - Id uint `gorm:"primaryKey"` - Uniformity float64 `gorm:"type:numeric(15,3)"` - Week int `gorm:"not null"` - Cv float64 `gorm:"type:numeric(15,3)"` - ChickQtyOfWeight float64 `gorm:"type:numeric(15,3)"` - MeanUp float64 `gorm:"type:numeric(15,3)"` - MeanDown float64 `gorm:"type:numeric(15,3)"` - ProjectFlockKandangId uint `gorm:"not null"` - UniformQty float64 `gorm:"type:numeric(15,3)"` - NotUniformQty float64 `gorm:"type:numeric(15,3)"` - UniformDate *time.Time `gorm:"type:timestamptz"` - CreatedBy uint `gorm:"not null"` + Id uint `gorm:"primaryKey"` + Uniformity float64 `gorm:"type:numeric(15,3)"` + Week int `gorm:"not null"` + Cv float64 `gorm:"type:numeric(15,3)"` + ChickQtyOfWeight float64 `gorm:"type:numeric(15,3)"` + MeanUp float64 `gorm:"type:numeric(15,3)"` + MeanDown float64 `gorm:"type:numeric(15,3)"` + ProjectFlockKandangId uint `gorm:"not null"` + UniformQty float64 `gorm:"type:numeric(15,3)"` + NotUniformQty float64 `gorm:"type:numeric(15,3)"` + ChartData json.RawMessage `gorm:"type:jsonb"` + UniformDate *time.Time `gorm:"type:timestamptz"` + CreatedBy uint `gorm:"not null"` ProjectFlockKandang ProjectFlockKandang `gorm:"foreignKey:ProjectFlockKandangId;references:Id"` CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` diff --git a/internal/entities/recording_egg.go b/internal/entities/recording_egg.go index 90546448..68269728 100644 --- a/internal/entities/recording_egg.go +++ b/internal/entities/recording_egg.go @@ -12,7 +12,7 @@ type RecordingEgg struct { CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` - ProductFlagName *string `gorm:"column:product_flag_name" json:"-"` + ProductFlagName *string `gorm:"->;column:product_flag_name" json:"-"` CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` } diff --git a/internal/modules/production/uniformities/controllers/uniformity.controller.go b/internal/modules/production/uniformities/controllers/uniformity.controller.go index e18e7dce..70372ece 100644 --- a/internal/modules/production/uniformities/controllers/uniformity.controller.go +++ b/internal/modules/production/uniformities/controllers/uniformity.controller.go @@ -5,6 +5,7 @@ import ( "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/dto" service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/services" + utypes "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/types" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/validations" "gitlab.com/mbugroup/lti-api.git/internal/response" @@ -40,6 +41,13 @@ func (u *UniformityController) GetAll(c *fiber.Ctx) error { if err != nil { return err } + var charts map[uint]utypes.UniformityChartData + if query.WithChart { + charts, err = u.UniformityService.MapCharts(c, result) + if err != nil { + return err + } + } return c.Status(fiber.StatusOK). JSON(response.SuccessWithPaginate[dto.UniformityListDTO]{ @@ -51,13 +59,9 @@ func (u *UniformityController) GetAll(c *fiber.Ctx) error { Limit: query.Limit, TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), TotalResults: totalResults, - Filters: fiber.Map{ - "location_id": "", - "project_flock_id": "", - "status": "Pengajuan", - }, + Filters: dto.BuildUniformityFilters(query), }, - Data: dto.ToUniformityListDTOsWithStandard(result, standards, documents), + Data: dto.ToUniformityListDTOsWithStandard(result, standards, documents, charts), }) } @@ -73,7 +77,7 @@ func (u *UniformityController) GetOne(c *fiber.Ctx) error { } withDetails := c.QueryBool("with_details", false) - calculation := service.UniformityCalculation{} + calculation := utypes.UniformityCalculation{} var document *entity.Document var documentURL string var meanWeight float64 @@ -87,7 +91,7 @@ func (u *UniformityController) GetOne(c *fiber.Ctx) error { return err } } else { - calculation = service.UniformityCalculation{ + calculation = utypes.UniformityCalculation{ ChickQtyOfWeight: result.ChickQtyOfWeight, MeanWeight: meanWeight, MeanDown: result.MeanDown, @@ -229,7 +233,7 @@ func (u *UniformityController) UpdateOne(c *fiber.Ctx) error { } } - calculation := service.UniformityCalculation{ + calculation := utypes.UniformityCalculation{ ChickQtyOfWeight: result.ChickQtyOfWeight, MeanWeight: math.Round(result.MeanUp / 1.10), MeanDown: result.MeanDown, diff --git a/internal/modules/production/uniformities/dto/uniformity.dto.go b/internal/modules/production/uniformities/dto/uniformity.dto.go index af401a54..3db819bf 100644 --- a/internal/modules/production/uniformities/dto/uniformity.dto.go +++ b/internal/modules/production/uniformities/dto/uniformity.dto.go @@ -5,7 +5,11 @@ import ( entity "gitlab.com/mbugroup/lti-api.git/internal/entities" approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" - service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/services" + utypes "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/types" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "github.com/gofiber/fiber/v2" ) type UniformitySamplingDTO struct { @@ -49,13 +53,13 @@ type UniformityInfoDTO struct { } type UniformityDetailDTO struct { - Id uint `json:"id"` - InfoUmum UniformityInfoDTO `json:"info_umum"` - Sampling UniformitySamplingDTO `json:"sampling"` - Result UniformityResultDTO `json:"result"` - Standard *UniformityStandardDTO `json:"standard"` + Id uint `json:"id"` + InfoUmum UniformityInfoDTO `json:"info_umum"` + Sampling UniformitySamplingDTO `json:"sampling"` + Result UniformityResultDTO `json:"result"` + Standard *UniformityStandardDTO `json:"standard"` LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"` - UniformityDetails []UniformityDetailItemDTO `json:"uniformity_details"` + UniformityDetails []UniformityDetailItemDTO `json:"uniformity_details"` } type UniformityListDTO struct { @@ -76,6 +80,7 @@ type UniformityListDTO struct { MeanDown float64 `json:"mean_down"` StandardMeanWeight *float64 `json:"standard_mean_weight"` StandardUniformity *float64 `json:"standard_uniformity"` + ChartData *utypes.UniformityChartData `json:"chart_data,omitempty"` CreatedBy uint `json:"created_by"` LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"` } @@ -87,7 +92,7 @@ func NewDocumentForResponse(name string) *entity.Document { return &entity.Document{Name: name} } -func ToUniformityVerificationDTO(calc service.UniformityCalculation) UniformityVerificationDTO { +func ToUniformityVerificationDTO(calc utypes.UniformityCalculation) UniformityVerificationDTO { return UniformityVerificationDTO{ Sampling: toUniformitySamplingDTO(calc), Result: toUniformityResultDTO(calc), @@ -97,7 +102,7 @@ func ToUniformityVerificationDTO(calc service.UniformityCalculation) UniformityV func ToUniformityDetailDTO( entityData entity.ProjectFlockKandangUniformity, - calc service.UniformityCalculation, + calc utypes.UniformityCalculation, document *entity.Document, documentURL string, standard *UniformityStandardDTO, @@ -171,8 +176,9 @@ func ToUniformityListDTOs(items []entity.ProjectFlockKandangUniformity) []Unifor func ToUniformityListDTOsWithStandard( items []entity.ProjectFlockKandangUniformity, - standards map[uint]service.UniformityStandard, + standards map[uint]utypes.UniformityStandard, documentNames map[uint]string, + charts map[uint]utypes.UniformityChartData, ) []UniformityListDTO { result := ToUniformityListDTOs(items) if len(result) == 0 || len(standards) == 0 { @@ -180,6 +186,10 @@ func ToUniformityListDTOsWithStandard( if name, ok := documentNames[result[i].Id]; ok { result[i].FileName = name } + if chart, ok := charts[result[i].Id]; ok { + chartCopy := chart + result[i].ChartData = &chartCopy + } } return result } @@ -192,11 +202,15 @@ func ToUniformityListDTOsWithStandard( if name, ok := documentNames[result[i].Id]; ok { result[i].FileName = name } + if chart, ok := charts[result[i].Id]; ok { + chartCopy := chart + result[i].ChartData = &chartCopy + } } return result } -func toUniformitySamplingDTO(calc service.UniformityCalculation) UniformitySamplingDTO { +func toUniformitySamplingDTO(calc utypes.UniformityCalculation) UniformitySamplingDTO { return UniformitySamplingDTO{ ChickQtyOfWeight: calc.ChickQtyOfWeight, MeanWeight: calc.MeanWeight, @@ -205,7 +219,7 @@ func toUniformitySamplingDTO(calc service.UniformityCalculation) UniformitySampl } } -func toUniformityResultDTO(calc service.UniformityCalculation) UniformityResultDTO { +func toUniformityResultDTO(calc utypes.UniformityCalculation) UniformityResultDTO { return UniformityResultDTO{ UniformQty: calc.UniformQty, OutsideQty: calc.OutsideQty, @@ -214,7 +228,7 @@ func toUniformityResultDTO(calc service.UniformityCalculation) UniformityResultD } } -func toUniformityDetailItemsDTO(calc service.UniformityCalculation) []UniformityDetailItemDTO { +func toUniformityDetailItemsDTO(calc utypes.UniformityCalculation) []UniformityDetailItemDTO { result := make([]UniformityDetailItemDTO, len(calc.Details)) for i, item := range calc.Details { result[i] = UniformityDetailItemDTO{ @@ -254,5 +268,18 @@ func formatUniformityDate(date *time.Time) string { if date == nil || date.IsZero() { return "" } - return date.Format("2006-01-02") + return utils.FormatDate(*date) +} + +func BuildUniformityFilters(query *validation.Query) fiber.Map { + if query == nil { + return fiber.Map{} + } + return fiber.Map{ + "project_flock_kandang_id": query.ProjectFlockKandangId, + "start_date": query.StartDate, + "end_date": query.EndDate, + "with_chart": query.WithChart, + "status": "Pengajuan", + } } diff --git a/internal/modules/production/uniformities/repositories/uniformity.repository.go b/internal/modules/production/uniformities/repositories/uniformity.repository.go index 241dea49..9641c650 100644 --- a/internal/modules/production/uniformities/repositories/uniformity.repository.go +++ b/internal/modules/production/uniformities/repositories/uniformity.repository.go @@ -2,14 +2,18 @@ package repository import ( "context" + "time" "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/validations" "gorm.io/gorm" ) type UniformityRepository interface { repository.BaseRepository[entity.ProjectFlockKandangUniformity] + GetAllWithFilters(ctx context.Context, offset, limit int, params *validation.Query) ([]entity.ProjectFlockKandangUniformity, int64, error) + WithDefaultRelations() func(*gorm.DB) *gorm.DB DeleteByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) error } @@ -23,6 +27,46 @@ func NewUniformityRepository(db *gorm.DB) UniformityRepository { } } +func (r *UniformityRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, params *validation.Query) ([]entity.ProjectFlockKandangUniformity, int64, error) { + return r.GetAll(ctx, offset, limit, func(db *gorm.DB) *gorm.DB { + return r.applyQueryFilters(r.WithDefaultRelations()(db), params) + }) +} + +func (r *UniformityRepositoryImpl) WithDefaultRelations() func(*gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + return db. + Preload("ProjectFlockKandang.ProjectFlock.Location"). + Preload("ProjectFlockKandang.Kandang.Location") + } +} + +func (r *UniformityRepositoryImpl) applyQueryFilters(db *gorm.DB, params *validation.Query) *gorm.DB { + if params == nil { + return db + } + + if params.ProjectFlockKandangId != 0 { + db = db.Where("project_flock_kandang_id = ?", params.ProjectFlockKandangId) + } + if params.Week != 0 { + db = db.Where("week = ?", params.Week) + } + + startDateValue, endDateValue, err := validation.ParseDateRange(params.StartDate, params.EndDate) + if err == nil { + if startDateValue != nil && endDateValue != nil { + db = db.Where("uniform_date >= ? AND uniform_date < ?", *startDateValue, endDateValue.Add(24*time.Hour)) + } else if startDateValue != nil { + db = db.Where("uniform_date >= ?", *startDateValue) + } else if endDateValue != nil { + db = db.Where("uniform_date < ?", endDateValue.Add(24*time.Hour)) + } + } + + return db.Order("uniform_date DESC").Order("id DESC") +} + func (r *UniformityRepositoryImpl) DeleteByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) error { if len(projectFlockKandangIDs) == 0 { return nil diff --git a/internal/modules/production/uniformities/services/uniformity.body_weight_excel.go b/internal/modules/production/uniformities/services/uniformity.body_weight_excel.go deleted file mode 100644 index 4e87f0cc..00000000 --- a/internal/modules/production/uniformities/services/uniformity.body_weight_excel.go +++ /dev/null @@ -1,200 +0,0 @@ -package service - -import ( - "io" - "mime/multipart" - "strconv" - "strings" - - "github.com/gofiber/fiber/v2" - "github.com/xuri/excelize/v2" -) - -type BodyWeightExcelRow struct { - No int `json:"no"` - Weight float64 `json:"weight"` - Range string `json:"range,omitempty"` -} - -func (s uniformityService) ParseBodyWeightExcel(_ *fiber.Ctx, file *multipart.FileHeader) ([]BodyWeightExcelRow, error) { - if file == nil { - return nil, fiber.NewError(fiber.StatusBadRequest, "file is required") - } - - reader, err := file.Open() - if err != nil { - return nil, fiber.NewError(fiber.StatusBadRequest, "failed to open file") - } - defer reader.Close() - - rows, err := parseBodyWeightExcelReader(reader) - if err != nil { - return nil, err - } - - return rows, nil -} - -func parseBodyWeightExcelReader(reader io.Reader) ([]BodyWeightExcelRow, error) { - xlsx, err := excelize.OpenReader(reader) - if err != nil { - return nil, fiber.NewError(fiber.StatusBadRequest, "failed to read excel file") - } - defer func() { - _ = xlsx.Close() - }() - - sheets := xlsx.GetSheetList() - if len(sheets) == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "no sheets found in file") - } - - sheetName := sheets[0] - if len(sheets) > 1 { - sheetName = sheets[1] - } - - rows, err := xlsx.GetRows(sheetName, excelize.Options{RawCellValue: true}) - if err != nil { - return nil, fiber.NewError(fiber.StatusBadRequest, "failed to read sheet rows") - } - - return parseBodyWeightRows(rows) -} - -func parseBodyWeightRows(rows [][]string) ([]BodyWeightExcelRow, error) { - headerRowIdx, noCol, bwCol, rangeCol := findBodyWeightHeader(rows) - if headerRowIdx < 0 || bwCol < 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "header BW not found") - } - - result := make([]BodyWeightExcelRow, 0) - lastNo := 0 - - for i := headerRowIdx + 1; i < len(rows); i++ { - row := rows[i] - weightStr := cellAt(row, bwCol) - weightVal, ok := parseNumber(weightStr) - if !ok { - continue - } - - noVal := 0 - if noCol >= 0 { - if parsed, ok := parseNumber(cellAt(row, noCol)); ok { - noVal = int(parsed) - } - } - if noVal <= 0 { - noVal = lastNo + 1 - } - if noVal > lastNo { - lastNo = noVal - } - - rangeVal := "" - if rangeCol >= 0 { - rangeVal = strings.TrimSpace(cellAt(row, rangeCol)) - } - - rowPayload := BodyWeightExcelRow{ - No: noVal, - Weight: weightVal, - Range: rangeVal, - } - if rowPayload.No <= 0 || rowPayload.Weight <= 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "invalid body weight row data") - } - - result = append(result, rowPayload) - } - - if len(result) == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "no body weight data found") - } - - return result, nil -} - -func findBodyWeightHeader(rows [][]string) (rowIdx int, noCol int, bwCol int, rangeCol int) { - rowIdx = -1 - noCol = -1 - bwCol = -1 - rangeCol = -1 - - for i, row := range rows { - tempNo := -1 - tempBW := -1 - tempRange := -1 - for j, cell := range row { - label := normalizeHeader(cell) - switch label { - case "no": - tempNo = j - case "bw": - tempBW = j - case "outsiderange": - tempRange = j - default: - if strings.HasPrefix(label, "bw") { - tempBW = j - } else if strings.HasPrefix(label, "no") { - tempNo = j - } else if strings.Contains(label, "range") { - tempRange = j - } - } - } - if tempBW >= 0 { - rowIdx = i - bwCol = tempBW - noCol = tempNo - rangeCol = tempRange - break - } - } - - return rowIdx, noCol, bwCol, rangeCol -} - -func cellAt(row []string, idx int) string { - if idx < 0 || idx >= len(row) { - return "" - } - return strings.TrimSpace(row[idx]) -} - -func normalizeHeader(value string) string { - trimmed := strings.ToLower(strings.TrimSpace(value)) - if trimmed == "" { - return "" - } - var b strings.Builder - for _, r := range trimmed { - if r >= 'a' && r <= 'z' { - b.WriteRune(r) - } - } - return b.String() -} - -func parseNumber(value string) (float64, bool) { - trimmed := strings.TrimSpace(value) - if trimmed == "" { - return 0, false - } - - if strings.Contains(trimmed, ",") { - if strings.Contains(trimmed, ".") { - trimmed = strings.ReplaceAll(trimmed, ",", "") - } else { - trimmed = strings.ReplaceAll(trimmed, ",", ".") - } - } - - parsed, err := strconv.ParseFloat(trimmed, 64) - if err != nil { - return 0, false - } - return parsed, true -} diff --git a/internal/modules/production/uniformities/services/uniformity.calculate.go b/internal/modules/production/uniformities/services/uniformity.calculate.go new file mode 100644 index 00000000..1b99afa7 --- /dev/null +++ b/internal/modules/production/uniformities/services/uniformity.calculate.go @@ -0,0 +1,393 @@ +package service + +import ( + "fmt" + "io" + "math" + "mime/multipart" + "strconv" + "strings" + + utypes "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/types" + + "github.com/gofiber/fiber/v2" + "github.com/xuri/excelize/v2" +) + +type BodyWeightExcelRow struct { + No int `json:"no"` + Weight float64 `json:"weight"` + Range string `json:"range,omitempty"` +} + +func (s uniformityService) ParseBodyWeightExcel(_ *fiber.Ctx, file *multipart.FileHeader) ([]BodyWeightExcelRow, error) { + if file == nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "file is required") + } + + reader, err := file.Open() + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "failed to open file") + } + defer reader.Close() + + rows, err := parseBodyWeightExcelReader(reader) + if err != nil { + return nil, err + } + + return rows, nil +} + +func parseBodyWeightExcelReader(reader io.Reader) ([]BodyWeightExcelRow, error) { + xlsx, err := excelize.OpenReader(reader) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "failed to read excel file") + } + defer func() { + _ = xlsx.Close() + }() + + sheets := xlsx.GetSheetList() + if len(sheets) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "no sheets found in file") + } + + sheetName := sheets[0] + if len(sheets) > 1 { + sheetName = sheets[1] + } + + rows, err := xlsx.GetRows(sheetName, excelize.Options{RawCellValue: true}) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "failed to read sheet rows") + } + + return parseBodyWeightRows(rows) +} + +func parseBodyWeightRows(rows [][]string) ([]BodyWeightExcelRow, error) { + headerRowIdx, noCol, bwCol, rangeCol := findBodyWeightHeader(rows) + if headerRowIdx < 0 || bwCol < 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "header BW not found") + } + + result := make([]BodyWeightExcelRow, 0) + lastNo := 0 + + for i := headerRowIdx + 1; i < len(rows); i++ { + row := rows[i] + weightStr := cellAt(row, bwCol) + weightVal, ok := parseNumber(weightStr) + if !ok { + continue + } + + noVal := 0 + if noCol >= 0 { + if parsed, ok := parseNumber(cellAt(row, noCol)); ok { + noVal = int(parsed) + } + } + if noVal <= 0 { + noVal = lastNo + 1 + } + if noVal > lastNo { + lastNo = noVal + } + + rangeVal := "" + if rangeCol >= 0 { + rangeVal = strings.TrimSpace(cellAt(row, rangeCol)) + } + + rowPayload := BodyWeightExcelRow{ + No: noVal, + Weight: weightVal, + Range: rangeVal, + } + if rowPayload.No <= 0 || rowPayload.Weight <= 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "invalid body weight row data") + } + + result = append(result, rowPayload) + } + + if len(result) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "no body weight data found") + } + + return result, nil +} + +func findBodyWeightHeader(rows [][]string) (rowIdx int, noCol int, bwCol int, rangeCol int) { + rowIdx = -1 + noCol = -1 + bwCol = -1 + rangeCol = -1 + + for i, row := range rows { + tempNo := -1 + tempBW := -1 + tempRange := -1 + for j, cell := range row { + label := normalizeHeader(cell) + switch label { + case "no": + tempNo = j + case "bw": + tempBW = j + case "outsiderange": + tempRange = j + default: + if strings.HasPrefix(label, "bw") { + tempBW = j + } else if strings.HasPrefix(label, "no") { + tempNo = j + } else if strings.Contains(label, "range") { + tempRange = j + } + } + } + if tempBW >= 0 { + rowIdx = i + bwCol = tempBW + noCol = tempNo + rangeCol = tempRange + break + } + } + + return rowIdx, noCol, bwCol, rangeCol +} + +func cellAt(row []string, idx int) string { + if idx < 0 || idx >= len(row) { + return "" + } + return strings.TrimSpace(row[idx]) +} + +func normalizeHeader(value string) string { + trimmed := strings.ToLower(strings.TrimSpace(value)) + if trimmed == "" { + return "" + } + var b strings.Builder + for _, r := range trimmed { + if r >= 'a' && r <= 'z' { + b.WriteRune(r) + } + } + return b.String() +} + +func parseNumber(value string) (float64, bool) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return 0, false + } + + if strings.Contains(trimmed, ",") { + if strings.Contains(trimmed, ".") { + trimmed = strings.ReplaceAll(trimmed, ",", "") + } else { + trimmed = strings.ReplaceAll(trimmed, ",", ".") + } + } + + parsed, err := strconv.ParseFloat(trimmed, 64) + if err != nil { + return 0, false + } + return parsed, true +} + +func computeUniformity(rows []BodyWeightExcelRow) (utypes.UniformityCalculation, error) { + weights := make([]float64, 0, len(rows)) + details := make([]utypes.UniformityDetailItem, 0, len(rows)) + hasRangeLabels := false + for idx, row := range rows { + if row.Weight <= 0 { + continue + } + id := row.No + if id <= 0 { + id = idx + 1 + } + weights = append(weights, row.Weight) + rangeLabel := strings.TrimSpace(row.Range) + if rangeLabel != "" { + upper := strings.ToUpper(rangeLabel) + if upper == "HIGH" || upper == "LOW" { + hasRangeLabels = true + } + rangeLabel = upper + } + details = append(details, utypes.UniformityDetailItem{ + Id: id, + Weight: row.Weight, + Range: rangeLabel, + }) + } + + total := float64(len(weights)) + if total == 0 { + return utypes.UniformityCalculation{}, fiber.NewError(fiber.StatusBadRequest, "no body weight data found") + } + + var sum float64 + for _, w := range weights { + sum += w + } + mean := sum / total + meanUpThreshold := roundToPrecision(mean*1.10, 3) + meanDownThreshold := roundToPrecision(mean*0.90, 3) + + var uniformCount float64 + for i := range details { + if hasRangeLabels { + if details[i].Range == "HIGH" || details[i].Range == "LOW" { + details[i].Range = "Outside" + continue + } + details[i].Range = "Ideal" + uniformCount++ + continue + } + + if details[i].Weight > meanUpThreshold || details[i].Weight < meanDownThreshold { + details[i].Range = "Outside" + continue + } + details[i].Range = "Ideal" + uniformCount++ + } + + var deviationSum float64 + for _, w := range weights { + deviation := w - mean + deviationSum += deviation * deviation + } + stdDev := math.Sqrt(deviationSum / total) + + cv := 0.0 + if mean != 0 { + cv = (stdDev / mean) * 100 + } + + outsideCount := total - uniformCount + uniformity := 0.0 + if total > 0 { + uniformity = (uniformCount / total) * 100 + } + + return utypes.UniformityCalculation{ + ChickQtyOfWeight: total, + MeanWeight: roundToPrecision(mean, 0), + MeanDown: roundToPrecision(meanDownThreshold, 0), + MeanUp: roundToPrecision(meanUpThreshold, 0), + UniformQty: uniformCount, + OutsideQty: outsideCount, + Uniformity: roundToPrecision(uniformity, 0), + Cv: roundToPrecision(cv, 1), + Details: details, + }, nil +} + +func extractWeights(rows []BodyWeightExcelRow) []float64 { + weights := make([]float64, 0, len(rows)) + for _, row := range rows { + if row.Weight <= 0 { + continue + } + weights = append(weights, row.Weight) + } + return weights +} + +func buildChartWeekSummary(weights []float64) utypes.UniformityChartWeek { + if len(weights) == 0 { + return utypes.UniformityChartWeek{ + HasData: false, + WeightDistribution: []utypes.UniformityChartRange{}, + } + } + + minWeight := weights[0] + maxWeight := weights[0] + var sum float64 + for _, w := range weights { + sum += w + if w < minWeight { + minWeight = w + } + if w > maxWeight { + maxWeight = w + } + } + mean := sum / float64(len(weights)) + + idealMin := roundToPrecision(mean*0.90, 0) + idealMax := roundToPrecision(mean*1.10, 0) + idealCount := 0.0 + for _, w := range weights { + if w >= idealMin && w <= idealMax { + idealCount++ + } + } + + const bucketSize = 5.0 + start := math.Floor(minWeight/bucketSize) * bucketSize + end := math.Floor(maxWeight/bucketSize) * bucketSize + + distribution := make([]utypes.UniformityChartRange, 0) + for bucket := start; bucket <= end; bucket += bucketSize { + minBucket := bucket + maxBucket := bucket + bucketSize - 1 + count := 0.0 + for _, w := range weights { + if w >= minBucket && w < minBucket+bucketSize { + count++ + } + } + distribution = append(distribution, utypes.UniformityChartRange{ + Range: fmt.Sprintf("%d-%d", int(minBucket), int(maxBucket)), + MinWeight: minBucket, + MaxWeight: maxBucket, + BirdCount: count, + IsIdealRange: minBucket >= idealMin && maxBucket <= idealMax, + }) + } + + statistics := &utypes.UniformityChartStatistics{ + MinWeight: roundToPrecision(minWeight, 0), + MaxWeight: roundToPrecision(maxWeight, 0), + AverageWeight: roundToPrecision(mean, 1), + TotalBirdsMeasured: float64(len(weights)), + } + + return utypes.UniformityChartWeek{ + HasData: true, + WeightDistribution: distribution, + IdealRange: &utypes.UniformityChartIdealRange{ + MinWeight: idealMin, + MaxWeight: idealMax, + TotalIdealBirds: idealCount, + }, + Statistics: statistics, + } +} + +func roundToPrecision(value float64, precision int) float64 { + if precision < 0 { + return value + } + scale := math.Pow10(precision) + scaled := value * scale + fraction := scaled - math.Floor(scaled) + if fraction >= 0.5 { + return math.Ceil(scaled) / scale + } + return math.Floor(scaled) / scale +} diff --git a/internal/modules/production/uniformities/services/uniformity.service.go b/internal/modules/production/uniformities/services/uniformity.service.go index 747eb965..cde86694 100644 --- a/internal/modules/production/uniformities/services/uniformity.service.go +++ b/internal/modules/production/uniformities/services/uniformity.service.go @@ -2,11 +2,12 @@ package service import ( "context" + "encoding/json" "errors" "fmt" - "math" "mime/multipart" "net/http" + "sort" "strings" "time" @@ -17,6 +18,7 @@ import ( rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/repositories" + utypes "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/types" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" @@ -31,17 +33,18 @@ type UniformityService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlockKandangUniformity, int64, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectFlockKandangUniformity, error) GetSummary(ctx *fiber.Ctx, id uint) (*entity.ProjectFlockKandangUniformity, error) - GetStandard(ctx *fiber.Ctx, uniformity *entity.ProjectFlockKandangUniformity) (*UniformityStandard, error) - MapStandards(ctx *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]UniformityStandard, error) + GetStandard(ctx *fiber.Ctx, uniformity *entity.ProjectFlockKandangUniformity) (*utypes.UniformityStandard, error) + MapStandards(ctx *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]utypes.UniformityStandard, error) + MapCharts(ctx *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]utypes.UniformityChartData, error) MapDocuments(ctx *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]string, error) CreateOne(ctx *fiber.Ctx, req *validation.Create, file *multipart.FileHeader, rows []BodyWeightExcelRow) (*entity.ProjectFlockKandangUniformity, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint, file *multipart.FileHeader, rows []BodyWeightExcelRow) (*entity.ProjectFlockKandangUniformity, error) DeleteOne(ctx *fiber.Ctx, id uint) error Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlockKandangUniformity, error) ParseBodyWeightExcel(ctx *fiber.Ctx, file *multipart.FileHeader) ([]BodyWeightExcelRow, error) - ComputeUniformity(rows []BodyWeightExcelRow) (UniformityCalculation, error) + ComputeUniformity(rows []BodyWeightExcelRow) (utypes.UniformityCalculation, error) GetDocumentInfo(ctx *fiber.Ctx, uniformityID uint) (*entity.Document, string, error) - CalculateUniformityFromDocument(ctx *fiber.Ctx, uniformityID uint) (UniformityCalculation, *entity.Document, string, error) + CalculateUniformityFromDocument(ctx *fiber.Ctx, uniformityID uint) (utypes.UniformityCalculation, *entity.Document, string, error) } type uniformityService struct { @@ -79,29 +82,13 @@ func NewUniformityService( } } -func (s uniformityService) withRelations(db *gorm.DB) *gorm.DB { - return db. - Preload("ProjectFlockKandang.ProjectFlock.Location"). - Preload("ProjectFlockKandang.Kandang.Location") -} - func (s uniformityService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlockKandangUniformity, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err } offset := (params.Page - 1) * params.Limit - - uniformitys, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { - db = s.withRelations(db) - if params.ProjectFlockKandangId != 0 { - db = db.Where("project_flock_kandang_id = ?", params.ProjectFlockKandangId) - } - if params.Week != 0 { - db = db.Where("week = ?", params.Week) - } - return db.Order("uniform_date DESC").Order("id DESC") - }) + uniformitys, total, err := s.Repository.GetAllWithFilters(c.Context(), offset, params.Limit, params) if err != nil { s.Log.Errorf("Failed to get uniformitys: %+v", err) @@ -114,7 +101,7 @@ func (s uniformityService) GetAll(c *fiber.Ctx, params *validation.Query) ([]ent } func (s uniformityService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlockKandangUniformity, error) { - uniformity, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + uniformity, err := s.Repository.GetByID(c.Context(), id, s.Repository.WithDefaultRelations()) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Uniformity not found") } @@ -132,14 +119,14 @@ func (s uniformityService) GetSummary(c *fiber.Ctx, id uint) (*entity.ProjectFlo return s.GetOne(c, id) } -func (s uniformityService) GetStandard(c *fiber.Ctx, uniformity *entity.ProjectFlockKandangUniformity) (*UniformityStandard, error) { +func (s uniformityService) GetStandard(c *fiber.Ctx, uniformity *entity.ProjectFlockKandangUniformity) (*utypes.UniformityStandard, error) { if uniformity == nil { return nil, nil } return s.resolveUniformityStandard(c.Context(), *uniformity) } -func (s uniformityService) MapStandards(c *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]UniformityStandard, error) { +func (s uniformityService) MapStandards(c *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]utypes.UniformityStandard, error) { if len(items) == 0 { return nil, nil } @@ -149,7 +136,7 @@ func (s uniformityService) MapStandards(c *fiber.Ctx, items []entity.ProjectFloc categoryStandard := make(map[string]*entity.ProductionStandard) detailCache := make(map[uint]map[int]entity.StandardGrowthDetail) - result := make(map[uint]UniformityStandard, len(items)) + result := make(map[uint]utypes.UniformityStandard, len(items)) for _, item := range items { if item.Id == 0 { @@ -180,7 +167,7 @@ func (s uniformityService) MapStandards(c *fiber.Ctx, items []entity.ProjectFloc if !ok { continue } - standardDTO := UniformityStandard{ + standardDTO := utypes.UniformityStandard{ MeanWeight: cloneFloat64(detail.TargetMeanBw), Uniformity: float64Ptr(detail.MinUniformity), } @@ -190,6 +177,109 @@ func (s uniformityService) MapStandards(c *fiber.Ctx, items []entity.ProjectFloc return result, nil } +func (s uniformityService) MapCharts(c *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]utypes.UniformityChartData, error) { + if len(items) == 0 { + return nil, nil + } + + grouped := make(map[uint][]entity.ProjectFlockKandangUniformity) + for _, item := range items { + if item.ProjectFlockKandangId == 0 { + continue + } + grouped[item.ProjectFlockKandangId] = append(grouped[item.ProjectFlockKandangId], item) + } + + if len(grouped) == 0 { + return nil, nil + } + + result := make(map[uint]utypes.UniformityChartData, len(items)) + + for _, group := range grouped { + allWeeks := make(map[int]utypes.UniformityChartWeek) + weekOrder := make([]int, 0, len(group)) + weekSeen := make(map[int]struct{}, len(group)) + weeksWithData := 0 + gaugeWeeks := make([]utypes.UniformityChartGaugeWeek, 0, len(group)) + + for _, item := range group { + if item.Week == 0 { + continue + } + var weekSummary utypes.UniformityChartWeek + if len(item.ChartData) > 0 { + if err := json.Unmarshal(item.ChartData, &weekSummary); err != nil { + return nil, err + } + } + if weekSummary.WeightDistribution == nil { + weekSummary.WeightDistribution = []utypes.UniformityChartRange{} + } + if !weekSummary.HasData && item.ChickQtyOfWeight > 0 { + weekSummary.HasData = true + } + if weekSummary.HasData { + weeksWithData++ + } + allWeeks[item.Week] = weekSummary + + if _, ok := weekSeen[item.Week]; !ok { + weekSeen[item.Week] = struct{}{} + weekOrder = append(weekOrder, item.Week) + } + + hasData := item.ChickQtyOfWeight > 0 + gaugeWeeks = append(gaugeWeeks, utypes.UniformityChartGaugeWeek{ + Week: item.Week, + UniformityPercent: item.Uniformity, + IdealCount: item.UniformQty, + OutsideIdealCount: item.NotUniformQty, + TotalCount: item.ChickQtyOfWeight, + HasData: hasData, + }) + } + + sort.Ints(weekOrder) + sort.Slice(gaugeWeeks, func(i, j int) bool { + return gaugeWeeks[i].Week < gaugeWeeks[j].Week + }) + + weekIndex := make(map[int]int, len(weekOrder)) + for idx, week := range weekOrder { + weekIndex[week] = idx + } + + totalWeeks := len(weekOrder) + for _, item := range group { + if item.Id == 0 || item.Week == 0 { + continue + } + currentIndex := weekIndex[item.Week] + chart := utypes.UniformityChartData{ + BarChart: utypes.UniformityChartBar{ + CurrentWeek: item.Week, + AllWeeks: allWeeks, + }, + GaugeChart: utypes.UniformityChartGauge{ + CurrentWeek: item.Week, + AvailableWeeks: gaugeWeeks, + WeekInfo: utypes.UniformityChartWeekInfo{ + TotalWeeks: totalWeeks, + WeeksWithData: weeksWithData, + CurrentWeekIndex: currentIndex, + HasPrevWeek: currentIndex > 0, + HasNextWeek: currentIndex < totalWeeks-1, + }, + }, + } + result[item.Id] = chart + } + } + + return result, nil +} + func (s uniformityService) MapDocuments(c *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]string, error) { if s.DocumentSvc == nil || len(items) == 0 { return map[uint]string{}, nil @@ -252,6 +342,11 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file if err != nil { return nil, err } + chartSummary := buildChartWeekSummary(extractWeights(rows)) + chartJSON, err := json.Marshal(chartSummary) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "failed to build chart data") + } actorID, err := m.ActorIDFromContext(c) if err != nil { return nil, err @@ -267,6 +362,7 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file ProjectFlockKandangId: req.ProjectFlockKandangId, UniformQty: calculation.UniformQty, NotUniformQty: calculation.OutsideQty, + ChartData: chartJSON, UniformDate: &uniformDate, CreatedBy: actorID, } @@ -307,7 +403,12 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file }, }) if err != nil { - s.rollbackUniformityCreate(c.Context(), createBody.Id) + if errDelete := s.ApprovalRepo.DeleteByTarget(c.Context(), utils.ApprovalWorkflowUniformity.String(), createBody.Id); errDelete != nil { + s.Log.WithError(errDelete).Warnf("Failed to rollback uniformity approvals for %d", createBody.Id) + } + if errDelete := s.Repository.DeleteOne(c.Context(), createBody.Id); errDelete != nil { + s.Log.WithError(errDelete).Warnf("Failed to rollback uniformity %d", createBody.Id) + } return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to upload uniformity document") } } @@ -391,6 +492,11 @@ func (s uniformityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id ui if err != nil { return nil, err } + chartSummary := buildChartWeekSummary(extractWeights(rows)) + chartJSON, err := json.Marshal(chartSummary) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "failed to build chart data") + } updateBody["uniformity"] = calculation.Uniformity updateBody["cv"] = calculation.Cv @@ -399,6 +505,7 @@ func (s uniformityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id ui updateBody["mean_down"] = calculation.MeanDown updateBody["uniform_qty"] = calculation.UniformQty updateBody["not_uniform_qty"] = calculation.OutsideQty + updateBody["chart_data"] = chartJSON } if len(updateBody) == 0 { @@ -590,30 +697,7 @@ func (s uniformityService) Approval(c *fiber.Ctx, req *validation.Approve) ([]en return results, nil } -type UniformityDetailItem struct { - Id int - Weight float64 - Range string -} - -type UniformityCalculation struct { - ChickQtyOfWeight float64 - MeanWeight float64 - MeanDown float64 - MeanUp float64 - UniformQty float64 - OutsideQty float64 - Uniformity float64 - Cv float64 - Details []UniformityDetailItem -} - -type UniformityStandard struct { - MeanWeight *float64 - Uniformity *float64 -} - -func (s uniformityService) ComputeUniformity(rows []BodyWeightExcelRow) (UniformityCalculation, error) { +func (s uniformityService) ComputeUniformity(rows []BodyWeightExcelRow) (utypes.UniformityCalculation, error) { return computeUniformity(rows) } @@ -621,37 +705,37 @@ func (s uniformityService) GetDocumentInfo(c *fiber.Ctx, uniformityID uint) (*en return s.fetchUniformityDocument(c.Context(), uniformityID, true) } -func (s uniformityService) CalculateUniformityFromDocument(c *fiber.Ctx, uniformityID uint) (UniformityCalculation, *entity.Document, string, error) { +func (s uniformityService) CalculateUniformityFromDocument(c *fiber.Ctx, uniformityID uint) (utypes.UniformityCalculation, *entity.Document, string, error) { document, url, err := s.fetchUniformityDocument(c.Context(), uniformityID, false) if err != nil { - return UniformityCalculation{}, nil, "", err + return utypes.UniformityCalculation{}, nil, "", err } if document == nil || url == "" { - return UniformityCalculation{}, nil, "", fiber.NewError(fiber.StatusNotFound, "Uniformity document not found") + return utypes.UniformityCalculation{}, nil, "", fiber.NewError(fiber.StatusNotFound, "Uniformity document not found") } req, err := http.NewRequestWithContext(c.Context(), http.MethodGet, url, nil) if err != nil { - return UniformityCalculation{}, nil, "", err + return utypes.UniformityCalculation{}, nil, "", err } resp, err := http.DefaultClient.Do(req) if err != nil { - return UniformityCalculation{}, nil, "", err + return utypes.UniformityCalculation{}, nil, "", err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return UniformityCalculation{}, nil, "", fiber.NewError(fiber.StatusBadRequest, "Failed to download uniformity document") + return utypes.UniformityCalculation{}, nil, "", fiber.NewError(fiber.StatusBadRequest, "Failed to download uniformity document") } rows, err := parseBodyWeightExcelReader(resp.Body) if err != nil { - return UniformityCalculation{}, nil, "", err + return utypes.UniformityCalculation{}, nil, "", err } calculation, err := computeUniformity(rows) if err != nil { - return UniformityCalculation{}, nil, "", err + return utypes.UniformityCalculation{}, nil, "", err } return calculation, document, url, nil @@ -783,7 +867,7 @@ func (s *uniformityService) attachLatestApproval(ctx context.Context, item *enti return nil } -func (s *uniformityService) resolveUniformityStandard(ctx context.Context, item entity.ProjectFlockKandangUniformity) (*UniformityStandard, error) { +func (s *uniformityService) resolveUniformityStandard(ctx context.Context, item entity.ProjectFlockKandangUniformity) (*utypes.UniformityStandard, error) { if s.ProductionStandardRepo == nil || s.StandardGrowthDetailRepo == nil { return nil, nil } @@ -801,7 +885,7 @@ func (s *uniformityService) resolveUniformityStandard(ctx context.Context, item return nil, err } - return &UniformityStandard{ + return &utypes.UniformityStandard{ MeanWeight: cloneFloat64(detail.TargetMeanBw), Uniformity: float64Ptr(detail.MinUniformity), }, nil @@ -858,22 +942,6 @@ func float64Ptr(value float64) *float64 { return © } -func (s *uniformityService) rollbackUniformityCreate(ctx context.Context, uniformityID uint) { - if uniformityID == 0 { - return - } - - if s.ApprovalRepo != nil { - if err := s.ApprovalRepo.DeleteByTarget(ctx, utils.ApprovalWorkflowUniformity.String(), uniformityID); err != nil { - s.Log.WithError(err).Warnf("Failed to rollback uniformity approvals for %d", uniformityID) - } - } - - if err := s.Repository.DeleteOne(ctx, uniformityID); err != nil { - s.Log.WithError(err).Warnf("Failed to rollback uniformity %d", uniformityID) - } -} - func uniqueUintSlice(values []uint) []uint { if len(values) == 0 { return nil @@ -893,114 +961,3 @@ func uniqueUintSlice(values []uint) []uint { } return result } - -func computeUniformity(rows []BodyWeightExcelRow) (UniformityCalculation, error) { - weights := make([]float64, 0, len(rows)) - details := make([]UniformityDetailItem, 0, len(rows)) - hasRangeLabels := false - for idx, row := range rows { - if row.Weight <= 0 { - continue - } - id := row.No - if id <= 0 { - id = idx + 1 - } - weights = append(weights, row.Weight) - rangeLabel := strings.TrimSpace(row.Range) - if rangeLabel != "" { - upper := strings.ToUpper(rangeLabel) - if upper == "HIGH" || upper == "LOW" { - hasRangeLabels = true - } - rangeLabel = upper - } - details = append(details, UniformityDetailItem{ - Id: id, - Weight: row.Weight, - Range: rangeLabel, - }) - } - - total := float64(len(weights)) - if total == 0 { - return UniformityCalculation{}, fiber.NewError(fiber.StatusBadRequest, "no body weight data found") - } - - var sum float64 - for _, w := range weights { - sum += w - } - mean := sum / total - meanUpThreshold := roundToPrecision(mean*1.10, 3) - meanDownThreshold := roundToPrecision(mean*0.90, 3) - - var uniformCount float64 - for i := range details { - if hasRangeLabels { - if details[i].Range == "HIGH" || details[i].Range == "LOW" { - details[i].Range = "Outside" - continue - } - details[i].Range = "Ideal" - uniformCount++ - continue - } - - w := details[i].Weight - if w > meanUpThreshold || w < meanDownThreshold { - details[i].Range = "Outside" - continue - } - details[i].Range = "Ideal" - uniformCount++ - } - outsideCount := total - uniformCount - - var cv float64 - if mean > 0 && total > 1 { - stddevWeights := weights - stddevCount := float64(len(stddevWeights)) - if stddevCount > 1 { - var stddevSum float64 - for _, w := range stddevWeights { - stddevSum += w - } - stddevMean := stddevSum / stddevCount - var sumSquares float64 - for _, w := range stddevWeights { - diff := w - stddevMean - sumSquares += diff * diff - } - stddev := math.Sqrt(sumSquares / (stddevCount - 1)) - cv = (stddev / mean) * 100 - } - } - - uniformity := (uniformCount / total) * 100 - - return UniformityCalculation{ - ChickQtyOfWeight: total, - MeanWeight: roundToPrecision(mean, 0), - MeanDown: roundToPrecision(mean*0.90, 0), - MeanUp: roundToPrecision(mean*1.10, 0), - UniformQty: uniformCount, - OutsideQty: outsideCount, - Uniformity: roundToPrecision(uniformity, 0), - Cv: roundToPrecision(cv, 1), - Details: details, - }, nil -} - -func roundToPrecision(value float64, precision int) float64 { - if precision < 0 { - return value - } - scale := math.Pow10(precision) - scaled := value * scale - fraction := scaled - math.Floor(scaled) - if fraction >= 0.5 { - return math.Ceil(scaled) / scale - } - return math.Floor(scaled) / scale -} diff --git a/internal/modules/production/uniformities/types/uniformity.types.go b/internal/modules/production/uniformities/types/uniformity.types.go new file mode 100644 index 00000000..17fcf305 --- /dev/null +++ b/internal/modules/production/uniformities/types/uniformity.types.go @@ -0,0 +1,85 @@ +package types + +type UniformityDetailItem struct { + Id int + Weight float64 + Range string +} + +type UniformityCalculation struct { + ChickQtyOfWeight float64 + MeanWeight float64 + MeanDown float64 + MeanUp float64 + UniformQty float64 + OutsideQty float64 + Uniformity float64 + Cv float64 + Details []UniformityDetailItem +} + +type UniformityStandard struct { + MeanWeight *float64 + Uniformity *float64 +} + +type UniformityChartRange struct { + Range string `json:"range"` + MinWeight float64 `json:"min_weight"` + MaxWeight float64 `json:"max_weight"` + BirdCount float64 `json:"bird_count"` + IsIdealRange bool `json:"is_ideal_range"` +} + +type UniformityChartIdealRange struct { + MinWeight float64 `json:"min_weight"` + MaxWeight float64 `json:"max_weight"` + TotalIdealBirds float64 `json:"total_ideal_birds"` +} + +type UniformityChartStatistics struct { + MinWeight float64 `json:"min_weight"` + MaxWeight float64 `json:"max_weight"` + AverageWeight float64 `json:"average_weight"` + TotalBirdsMeasured float64 `json:"total_birds_measured"` +} + +type UniformityChartWeek struct { + HasData bool `json:"has_data"` + WeightDistribution []UniformityChartRange `json:"weight_distribution"` + IdealRange *UniformityChartIdealRange `json:"ideal_range"` + Statistics *UniformityChartStatistics `json:"statistics"` +} + +type UniformityChartBar struct { + CurrentWeek int `json:"current_week"` + AllWeeks map[int]UniformityChartWeek `json:"all_weeks"` +} + +type UniformityChartGaugeWeek struct { + Week int `json:"week"` + UniformityPercent float64 `json:"uniformity_percentage"` + IdealCount float64 `json:"ideal_count"` + OutsideIdealCount float64 `json:"outside_ideal_count"` + TotalCount float64 `json:"total_count"` + HasData bool `json:"has_data"` +} + +type UniformityChartWeekInfo struct { + TotalWeeks int `json:"total_weeks"` + WeeksWithData int `json:"weeks_with_data"` + CurrentWeekIndex int `json:"current_week_index"` + HasPrevWeek bool `json:"has_prev_week"` + HasNextWeek bool `json:"has_next_week"` +} + +type UniformityChartGauge struct { + CurrentWeek int `json:"current_week"` + AvailableWeeks []UniformityChartGaugeWeek `json:"available_weeks"` + WeekInfo UniformityChartWeekInfo `json:"week_info"` +} + +type UniformityChartData struct { + BarChart UniformityChartBar `json:"bar_chart"` + GaugeChart UniformityChartGauge `json:"gauge_chart"` +} diff --git a/internal/modules/production/uniformities/validations/uniformity.validation.go b/internal/modules/production/uniformities/validations/uniformity.validation.go index b2aeaf26..e4f7f8a0 100644 --- a/internal/modules/production/uniformities/validations/uniformity.validation.go +++ b/internal/modules/production/uniformities/validations/uniformity.validation.go @@ -4,6 +4,7 @@ import ( "mime/multipart" "strconv" "strings" + "time" "github.com/gofiber/fiber/v2" ) @@ -21,10 +22,13 @@ type Update struct { } type Query struct { - Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` - ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"` - Week int `query:"week" validate:"omitempty,min=1"` + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"` + Week int `query:"week" validate:"omitempty,min=1"` + StartDate string `query:"start_date" validate:"omitempty"` + EndDate string `query:"end_date" validate:"omitempty"` + WithChart bool `query:"with_chart"` } type UploadExcelRequest struct { @@ -37,6 +41,8 @@ type Approve struct { Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` } +const maxUniformityUploadBytes = 5 * 1024 * 1024 + func ParseIDParam(c *fiber.Ctx, name string) (uint, error) { raw := strings.TrimSpace(c.Params(name)) if raw == "" { @@ -55,15 +61,49 @@ func ParseQuery(c *fiber.Ctx) (*Query, error) { Limit: c.QueryInt("limit", 10), ProjectFlockKandangId: uint(c.QueryInt("project_flock_kandang_id", 0)), Week: c.QueryInt("week", 0), + StartDate: strings.TrimSpace(c.Query("start_date")), + EndDate: strings.TrimSpace(c.Query("end_date")), + WithChart: c.QueryBool("with_chart", false), } if query.Page < 1 || query.Limit < 1 { return nil, fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") } + if _, _, err := ParseDateRange(query.StartDate, query.EndDate); err != nil { + return nil, err + } + return query, nil } +func ParseDateRange(startDate, endDate string) (*time.Time, *time.Time, error) { + var startDateValue *time.Time + var endDateValue *time.Time + + if startDate != "" { + parsed, err := time.Parse("2006-01-02", startDate) + if err != nil { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "start_date must be in YYYY-MM-DD format") + } + startDateValue = &parsed + } + if endDate != "" { + parsed, err := time.Parse("2006-01-02", endDate) + if err != nil { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "end_date must be in YYYY-MM-DD format") + } + endDateValue = &parsed + } + if startDateValue != nil && endDateValue != nil { + if endDateValue.Before(*startDateValue) { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "end_date must be greater than or equal to start_date") + } + } + + return startDateValue, endDateValue, nil +} + func ParseCreate(c *fiber.Ctx) (*Create, *multipart.FileHeader, error) { date := strings.TrimSpace(c.FormValue("date")) if date == "" { @@ -94,6 +134,9 @@ func ParseCreate(c *fiber.Ctx) (*Create, *multipart.FileHeader, error) { if err != nil { return nil, nil, fiber.NewError(fiber.StatusBadRequest, "document is required") } + if err := validateUniformityFileSize(file); err != nil { + return nil, nil, err + } return &Create{ Date: date, @@ -134,6 +177,8 @@ func ParseUpdate(c *fiber.Ctx) (*Update, *multipart.FileHeader, error) { file, err := c.FormFile("document") if err != nil { file = nil + } else if err := validateUniformityFileSize(file); err != nil { + return nil, nil, err } return req, file, nil @@ -151,6 +196,9 @@ func ParseUploadFiles(c *fiber.Ctx) ([]*multipart.FileHeader, error) { if err != nil || file == nil { return nil, fiber.NewError(fiber.StatusBadRequest, "document is required") } + if err := validateUniformityFileSize(file); err != nil { + return nil, err + } return []*multipart.FileHeader{file}, nil } @@ -162,3 +210,10 @@ func ParseApprove(c *fiber.Ctx) (*Approve, error) { } return req, nil } + +func validateUniformityFileSize(file *multipart.FileHeader) error { + if file != nil && file.Size > maxUniformityUploadBytes { + return fiber.NewError(fiber.StatusRequestEntityTooLarge, "Document size must be <= 5MB") + } + return nil +} diff --git a/internal/modules/purchases/controllers/purchase.controller.go b/internal/modules/purchases/controllers/purchase.controller.go index 977b4ac1..c4291619 100644 --- a/internal/modules/purchases/controllers/purchase.controller.go +++ b/internal/modules/purchases/controllers/purchase.controller.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "math" + "mime/multipart" "strconv" "strings" @@ -15,6 +16,8 @@ import ( "github.com/gofiber/fiber/v2" ) +const maxPurchaseUploadBytes = 5 * 1024 * 1024 + type PurchaseController struct { service service.PurchaseService } @@ -184,6 +187,9 @@ func (ctrl *PurchaseController) ReceiveProducts(c *fiber.Ctx) error { if len(req.TravelDocuments) == 0 { req.TravelDocuments = form.File["documents"] } + if err := validatePurchaseDocumentSizes(req.TravelDocuments); err != nil { + return err + } result, err := ctrl.service.ReceiveProducts(c, uint(id), req) if err != nil { return err @@ -198,6 +204,15 @@ func (ctrl *PurchaseController) ReceiveProducts(c *fiber.Ctx) error { }) } +func validatePurchaseDocumentSizes(files []*multipart.FileHeader) error { + for _, file := range files { + if file != nil && file.Size > maxPurchaseUploadBytes { + return fiber.NewError(fiber.StatusRequestEntityTooLarge, "Document size must be <= 5MB") + } + } + return nil +} + func (ctrl *PurchaseController) DeleteItems(c *fiber.Ctx) error { param := c.Params("id") id, err := strconv.Atoi(param)