feat(BE-281): fixing recording error, fixing limit upload uniformity and purchase, add filter and statistic uniformity

This commit is contained in:
ragilap
2026-01-07 20:26:27 +07:00
parent 90f363bfdb
commit a2d2c4269a
14 changed files with 847 additions and 430 deletions
+1
View File
@@ -13,6 +13,7 @@ func FiberConfig() fiber.Config {
CaseSensitive: true, CaseSensitive: true,
ServerHeader: "Fiber", ServerHeader: "Fiber",
AppName: "Fiber API", AppName: "Fiber API",
BodyLimit: 8 * 1024 * 1024,
ErrorHandler: utils.ErrorHandler, ErrorHandler: utils.ErrorHandler,
JSONEncoder: sonic.Marshal, JSONEncoder: sonic.Marshal,
JSONDecoder: sonic.Unmarshal, JSONDecoder: sonic.Unmarshal,
@@ -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;
@@ -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);
@@ -1,6 +1,9 @@
package entities package entities
import "time" import (
"encoding/json"
"time"
)
type ProjectFlockKandangUniformity struct { type ProjectFlockKandangUniformity struct {
Id uint `gorm:"primaryKey"` Id uint `gorm:"primaryKey"`
@@ -13,6 +16,7 @@ type ProjectFlockKandangUniformity struct {
ProjectFlockKandangId uint `gorm:"not null"` ProjectFlockKandangId uint `gorm:"not null"`
UniformQty float64 `gorm:"type:numeric(15,3)"` UniformQty float64 `gorm:"type:numeric(15,3)"`
NotUniformQty 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"` UniformDate *time.Time `gorm:"type:timestamptz"`
CreatedBy uint `gorm:"not null"` CreatedBy uint `gorm:"not null"`
+1 -1
View File
@@ -12,7 +12,7 @@ type RecordingEgg struct {
CreatedAt time.Time `gorm:"autoCreateTime"` CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"`
ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` 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"` CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"`
Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` Recording Recording `gorm:"foreignKey:RecordingId;references:Id"`
} }
@@ -5,6 +5,7 @@ import (
"gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/services" 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" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/validations"
"gitlab.com/mbugroup/lti-api.git/internal/response" "gitlab.com/mbugroup/lti-api.git/internal/response"
@@ -40,6 +41,13 @@ func (u *UniformityController) GetAll(c *fiber.Ctx) error {
if err != nil { if err != nil {
return err 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). return c.Status(fiber.StatusOK).
JSON(response.SuccessWithPaginate[dto.UniformityListDTO]{ JSON(response.SuccessWithPaginate[dto.UniformityListDTO]{
@@ -51,13 +59,9 @@ func (u *UniformityController) GetAll(c *fiber.Ctx) error {
Limit: query.Limit, Limit: query.Limit,
TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))),
TotalResults: totalResults, TotalResults: totalResults,
Filters: fiber.Map{ Filters: dto.BuildUniformityFilters(query),
"location_id": "",
"project_flock_id": "",
"status": "Pengajuan",
}, },
}, Data: dto.ToUniformityListDTOsWithStandard(result, standards, documents, charts),
Data: dto.ToUniformityListDTOsWithStandard(result, standards, documents),
}) })
} }
@@ -73,7 +77,7 @@ func (u *UniformityController) GetOne(c *fiber.Ctx) error {
} }
withDetails := c.QueryBool("with_details", false) withDetails := c.QueryBool("with_details", false)
calculation := service.UniformityCalculation{} calculation := utypes.UniformityCalculation{}
var document *entity.Document var document *entity.Document
var documentURL string var documentURL string
var meanWeight float64 var meanWeight float64
@@ -87,7 +91,7 @@ func (u *UniformityController) GetOne(c *fiber.Ctx) error {
return err return err
} }
} else { } else {
calculation = service.UniformityCalculation{ calculation = utypes.UniformityCalculation{
ChickQtyOfWeight: result.ChickQtyOfWeight, ChickQtyOfWeight: result.ChickQtyOfWeight,
MeanWeight: meanWeight, MeanWeight: meanWeight,
MeanDown: result.MeanDown, MeanDown: result.MeanDown,
@@ -229,7 +233,7 @@ func (u *UniformityController) UpdateOne(c *fiber.Ctx) error {
} }
} }
calculation := service.UniformityCalculation{ calculation := utypes.UniformityCalculation{
ChickQtyOfWeight: result.ChickQtyOfWeight, ChickQtyOfWeight: result.ChickQtyOfWeight,
MeanWeight: math.Round(result.MeanUp / 1.10), MeanWeight: math.Round(result.MeanUp / 1.10),
MeanDown: result.MeanDown, MeanDown: result.MeanDown,
@@ -5,7 +5,11 @@ import (
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" 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 { type UniformitySamplingDTO struct {
@@ -76,6 +80,7 @@ type UniformityListDTO struct {
MeanDown float64 `json:"mean_down"` MeanDown float64 `json:"mean_down"`
StandardMeanWeight *float64 `json:"standard_mean_weight"` StandardMeanWeight *float64 `json:"standard_mean_weight"`
StandardUniformity *float64 `json:"standard_uniformity"` StandardUniformity *float64 `json:"standard_uniformity"`
ChartData *utypes.UniformityChartData `json:"chart_data,omitempty"`
CreatedBy uint `json:"created_by"` CreatedBy uint `json:"created_by"`
LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"` LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"`
} }
@@ -87,7 +92,7 @@ func NewDocumentForResponse(name string) *entity.Document {
return &entity.Document{Name: name} return &entity.Document{Name: name}
} }
func ToUniformityVerificationDTO(calc service.UniformityCalculation) UniformityVerificationDTO { func ToUniformityVerificationDTO(calc utypes.UniformityCalculation) UniformityVerificationDTO {
return UniformityVerificationDTO{ return UniformityVerificationDTO{
Sampling: toUniformitySamplingDTO(calc), Sampling: toUniformitySamplingDTO(calc),
Result: toUniformityResultDTO(calc), Result: toUniformityResultDTO(calc),
@@ -97,7 +102,7 @@ func ToUniformityVerificationDTO(calc service.UniformityCalculation) UniformityV
func ToUniformityDetailDTO( func ToUniformityDetailDTO(
entityData entity.ProjectFlockKandangUniformity, entityData entity.ProjectFlockKandangUniformity,
calc service.UniformityCalculation, calc utypes.UniformityCalculation,
document *entity.Document, document *entity.Document,
documentURL string, documentURL string,
standard *UniformityStandardDTO, standard *UniformityStandardDTO,
@@ -171,8 +176,9 @@ func ToUniformityListDTOs(items []entity.ProjectFlockKandangUniformity) []Unifor
func ToUniformityListDTOsWithStandard( func ToUniformityListDTOsWithStandard(
items []entity.ProjectFlockKandangUniformity, items []entity.ProjectFlockKandangUniformity,
standards map[uint]service.UniformityStandard, standards map[uint]utypes.UniformityStandard,
documentNames map[uint]string, documentNames map[uint]string,
charts map[uint]utypes.UniformityChartData,
) []UniformityListDTO { ) []UniformityListDTO {
result := ToUniformityListDTOs(items) result := ToUniformityListDTOs(items)
if len(result) == 0 || len(standards) == 0 { if len(result) == 0 || len(standards) == 0 {
@@ -180,6 +186,10 @@ func ToUniformityListDTOsWithStandard(
if name, ok := documentNames[result[i].Id]; ok { if name, ok := documentNames[result[i].Id]; ok {
result[i].FileName = name result[i].FileName = name
} }
if chart, ok := charts[result[i].Id]; ok {
chartCopy := chart
result[i].ChartData = &chartCopy
}
} }
return result return result
} }
@@ -192,11 +202,15 @@ func ToUniformityListDTOsWithStandard(
if name, ok := documentNames[result[i].Id]; ok { if name, ok := documentNames[result[i].Id]; ok {
result[i].FileName = name result[i].FileName = name
} }
if chart, ok := charts[result[i].Id]; ok {
chartCopy := chart
result[i].ChartData = &chartCopy
}
} }
return result return result
} }
func toUniformitySamplingDTO(calc service.UniformityCalculation) UniformitySamplingDTO { func toUniformitySamplingDTO(calc utypes.UniformityCalculation) UniformitySamplingDTO {
return UniformitySamplingDTO{ return UniformitySamplingDTO{
ChickQtyOfWeight: calc.ChickQtyOfWeight, ChickQtyOfWeight: calc.ChickQtyOfWeight,
MeanWeight: calc.MeanWeight, 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{ return UniformityResultDTO{
UniformQty: calc.UniformQty, UniformQty: calc.UniformQty,
OutsideQty: calc.OutsideQty, 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)) result := make([]UniformityDetailItemDTO, len(calc.Details))
for i, item := range calc.Details { for i, item := range calc.Details {
result[i] = UniformityDetailItemDTO{ result[i] = UniformityDetailItemDTO{
@@ -254,5 +268,18 @@ func formatUniformityDate(date *time.Time) string {
if date == nil || date.IsZero() { if date == nil || date.IsZero() {
return "" 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",
}
} }
@@ -2,14 +2,18 @@ package repository
import ( import (
"context" "context"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository" "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/validations"
"gorm.io/gorm" "gorm.io/gorm"
) )
type UniformityRepository interface { type UniformityRepository interface {
repository.BaseRepository[entity.ProjectFlockKandangUniformity] 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 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 { func (r *UniformityRepositoryImpl) DeleteByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) error {
if len(projectFlockKandangIDs) == 0 { if len(projectFlockKandangIDs) == 0 {
return nil return nil
@@ -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
}
@@ -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
}
@@ -2,11 +2,12 @@ package service
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"math"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"sort"
"strings" "strings"
"time" "time"
@@ -17,6 +18,7 @@ import (
rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" 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" 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" 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" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/validations"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" 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) GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlockKandangUniformity, int64, error)
GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectFlockKandangUniformity, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectFlockKandangUniformity, error)
GetSummary(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) GetStandard(ctx *fiber.Ctx, uniformity *entity.ProjectFlockKandangUniformity) (*utypes.UniformityStandard, error)
MapStandards(ctx *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]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) 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) 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) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint, file *multipart.FileHeader, rows []BodyWeightExcelRow) (*entity.ProjectFlockKandangUniformity, error)
DeleteOne(ctx *fiber.Ctx, id uint) error DeleteOne(ctx *fiber.Ctx, id uint) error
Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlockKandangUniformity, error) Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlockKandangUniformity, error)
ParseBodyWeightExcel(ctx *fiber.Ctx, file *multipart.FileHeader) ([]BodyWeightExcelRow, 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) 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 { 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) { func (s uniformityService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlockKandangUniformity, int64, error) {
if err := s.Validate.Struct(params); err != nil { if err := s.Validate.Struct(params); err != nil {
return nil, 0, err return nil, 0, err
} }
offset := (params.Page - 1) * params.Limit offset := (params.Page - 1) * params.Limit
uniformitys, total, err := s.Repository.GetAllWithFilters(c.Context(), offset, params.Limit, params)
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")
})
if err != nil { if err != nil {
s.Log.Errorf("Failed to get uniformitys: %+v", err) 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) { 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) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Uniformity not found") 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) 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 { if uniformity == nil {
return nil, nil return nil, nil
} }
return s.resolveUniformityStandard(c.Context(), *uniformity) 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 { if len(items) == 0 {
return nil, nil return nil, nil
} }
@@ -149,7 +136,7 @@ func (s uniformityService) MapStandards(c *fiber.Ctx, items []entity.ProjectFloc
categoryStandard := make(map[string]*entity.ProductionStandard) categoryStandard := make(map[string]*entity.ProductionStandard)
detailCache := make(map[uint]map[int]entity.StandardGrowthDetail) 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 { for _, item := range items {
if item.Id == 0 { if item.Id == 0 {
@@ -180,7 +167,7 @@ func (s uniformityService) MapStandards(c *fiber.Ctx, items []entity.ProjectFloc
if !ok { if !ok {
continue continue
} }
standardDTO := UniformityStandard{ standardDTO := utypes.UniformityStandard{
MeanWeight: cloneFloat64(detail.TargetMeanBw), MeanWeight: cloneFloat64(detail.TargetMeanBw),
Uniformity: float64Ptr(detail.MinUniformity), Uniformity: float64Ptr(detail.MinUniformity),
} }
@@ -190,6 +177,109 @@ func (s uniformityService) MapStandards(c *fiber.Ctx, items []entity.ProjectFloc
return result, nil 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) { func (s uniformityService) MapDocuments(c *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]string, error) {
if s.DocumentSvc == nil || len(items) == 0 { if s.DocumentSvc == nil || len(items) == 0 {
return map[uint]string{}, nil return map[uint]string{}, nil
@@ -252,6 +342,11 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file
if err != nil { if err != nil {
return nil, err 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) actorID, err := m.ActorIDFromContext(c)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -267,6 +362,7 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file
ProjectFlockKandangId: req.ProjectFlockKandangId, ProjectFlockKandangId: req.ProjectFlockKandangId,
UniformQty: calculation.UniformQty, UniformQty: calculation.UniformQty,
NotUniformQty: calculation.OutsideQty, NotUniformQty: calculation.OutsideQty,
ChartData: chartJSON,
UniformDate: &uniformDate, UniformDate: &uniformDate,
CreatedBy: actorID, CreatedBy: actorID,
} }
@@ -307,7 +403,12 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file
}, },
}) })
if err != nil { 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") 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 { if err != nil {
return nil, err 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["uniformity"] = calculation.Uniformity
updateBody["cv"] = calculation.Cv 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["mean_down"] = calculation.MeanDown
updateBody["uniform_qty"] = calculation.UniformQty updateBody["uniform_qty"] = calculation.UniformQty
updateBody["not_uniform_qty"] = calculation.OutsideQty updateBody["not_uniform_qty"] = calculation.OutsideQty
updateBody["chart_data"] = chartJSON
} }
if len(updateBody) == 0 { if len(updateBody) == 0 {
@@ -590,30 +697,7 @@ func (s uniformityService) Approval(c *fiber.Ctx, req *validation.Approve) ([]en
return results, nil return results, nil
} }
type UniformityDetailItem struct { func (s uniformityService) ComputeUniformity(rows []BodyWeightExcelRow) (utypes.UniformityCalculation, error) {
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) {
return computeUniformity(rows) return computeUniformity(rows)
} }
@@ -621,37 +705,37 @@ func (s uniformityService) GetDocumentInfo(c *fiber.Ctx, uniformityID uint) (*en
return s.fetchUniformityDocument(c.Context(), uniformityID, true) 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) document, url, err := s.fetchUniformityDocument(c.Context(), uniformityID, false)
if err != nil { if err != nil {
return UniformityCalculation{}, nil, "", err return utypes.UniformityCalculation{}, nil, "", err
} }
if document == nil || url == "" { 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) req, err := http.NewRequestWithContext(c.Context(), http.MethodGet, url, nil)
if err != nil { if err != nil {
return UniformityCalculation{}, nil, "", err return utypes.UniformityCalculation{}, nil, "", err
} }
resp, err := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
return UniformityCalculation{}, nil, "", err return utypes.UniformityCalculation{}, nil, "", err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 { 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) rows, err := parseBodyWeightExcelReader(resp.Body)
if err != nil { if err != nil {
return UniformityCalculation{}, nil, "", err return utypes.UniformityCalculation{}, nil, "", err
} }
calculation, err := computeUniformity(rows) calculation, err := computeUniformity(rows)
if err != nil { if err != nil {
return UniformityCalculation{}, nil, "", err return utypes.UniformityCalculation{}, nil, "", err
} }
return calculation, document, url, nil return calculation, document, url, nil
@@ -783,7 +867,7 @@ func (s *uniformityService) attachLatestApproval(ctx context.Context, item *enti
return nil 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 { if s.ProductionStandardRepo == nil || s.StandardGrowthDetailRepo == nil {
return nil, nil return nil, nil
} }
@@ -801,7 +885,7 @@ func (s *uniformityService) resolveUniformityStandard(ctx context.Context, item
return nil, err return nil, err
} }
return &UniformityStandard{ return &utypes.UniformityStandard{
MeanWeight: cloneFloat64(detail.TargetMeanBw), MeanWeight: cloneFloat64(detail.TargetMeanBw),
Uniformity: float64Ptr(detail.MinUniformity), Uniformity: float64Ptr(detail.MinUniformity),
}, nil }, nil
@@ -858,22 +942,6 @@ func float64Ptr(value float64) *float64 {
return &copy return &copy
} }
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 { func uniqueUintSlice(values []uint) []uint {
if len(values) == 0 { if len(values) == 0 {
return nil return nil
@@ -893,114 +961,3 @@ func uniqueUintSlice(values []uint) []uint {
} }
return result 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
}
@@ -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"`
}
@@ -4,6 +4,7 @@ import (
"mime/multipart" "mime/multipart"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
@@ -25,6 +26,9 @@ type Query struct {
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,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"` ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"`
Week int `query:"week" validate:"omitempty,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 { type UploadExcelRequest struct {
@@ -37,6 +41,8 @@ type Approve struct {
Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
} }
const maxUniformityUploadBytes = 5 * 1024 * 1024
func ParseIDParam(c *fiber.Ctx, name string) (uint, error) { func ParseIDParam(c *fiber.Ctx, name string) (uint, error) {
raw := strings.TrimSpace(c.Params(name)) raw := strings.TrimSpace(c.Params(name))
if raw == "" { if raw == "" {
@@ -55,15 +61,49 @@ func ParseQuery(c *fiber.Ctx) (*Query, error) {
Limit: c.QueryInt("limit", 10), Limit: c.QueryInt("limit", 10),
ProjectFlockKandangId: uint(c.QueryInt("project_flock_kandang_id", 0)), ProjectFlockKandangId: uint(c.QueryInt("project_flock_kandang_id", 0)),
Week: c.QueryInt("week", 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 { if query.Page < 1 || query.Limit < 1 {
return nil, fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") 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 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) { func ParseCreate(c *fiber.Ctx) (*Create, *multipart.FileHeader, error) {
date := strings.TrimSpace(c.FormValue("date")) date := strings.TrimSpace(c.FormValue("date"))
if date == "" { if date == "" {
@@ -94,6 +134,9 @@ func ParseCreate(c *fiber.Ctx) (*Create, *multipart.FileHeader, error) {
if err != nil { if err != nil {
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "document is required") return nil, nil, fiber.NewError(fiber.StatusBadRequest, "document is required")
} }
if err := validateUniformityFileSize(file); err != nil {
return nil, nil, err
}
return &Create{ return &Create{
Date: date, Date: date,
@@ -134,6 +177,8 @@ func ParseUpdate(c *fiber.Ctx) (*Update, *multipart.FileHeader, error) {
file, err := c.FormFile("document") file, err := c.FormFile("document")
if err != nil { if err != nil {
file = nil file = nil
} else if err := validateUniformityFileSize(file); err != nil {
return nil, nil, err
} }
return req, file, nil return req, file, nil
@@ -151,6 +196,9 @@ func ParseUploadFiles(c *fiber.Ctx) ([]*multipart.FileHeader, error) {
if err != nil || file == nil { if err != nil || file == nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "document is required") return nil, fiber.NewError(fiber.StatusBadRequest, "document is required")
} }
if err := validateUniformityFileSize(file); err != nil {
return nil, err
}
return []*multipart.FileHeader{file}, nil return []*multipart.FileHeader{file}, nil
} }
@@ -162,3 +210,10 @@ func ParseApprove(c *fiber.Ctx) (*Approve, error) {
} }
return req, nil 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
}
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"math" "math"
"mime/multipart"
"strconv" "strconv"
"strings" "strings"
@@ -15,6 +16,8 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
const maxPurchaseUploadBytes = 5 * 1024 * 1024
type PurchaseController struct { type PurchaseController struct {
service service.PurchaseService service service.PurchaseService
} }
@@ -184,6 +187,9 @@ func (ctrl *PurchaseController) ReceiveProducts(c *fiber.Ctx) error {
if len(req.TravelDocuments) == 0 { if len(req.TravelDocuments) == 0 {
req.TravelDocuments = form.File["documents"] req.TravelDocuments = form.File["documents"]
} }
if err := validatePurchaseDocumentSizes(req.TravelDocuments); err != nil {
return err
}
result, err := ctrl.service.ReceiveProducts(c, uint(id), req) result, err := ctrl.service.ReceiveProducts(c, uint(id), req)
if err != nil { if err != nil {
return err 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 { func (ctrl *PurchaseController) DeleteItems(c *fiber.Ctx) error {
param := c.Params("id") param := c.Params("id")
id, err := strconv.Atoi(param) id, err := strconv.Atoi(param)