mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 05:21:57 +00:00
fix calculate egg mass and hen house recordings
This commit is contained in:
@@ -0,0 +1,380 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/database"
|
||||||
|
recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const metricEpsilon = 1e-9
|
||||||
|
|
||||||
|
type normalizeOptions struct {
|
||||||
|
Apply bool
|
||||||
|
RecordingID uint
|
||||||
|
ProjectFlockKandangID uint
|
||||||
|
From *time.Time
|
||||||
|
To *time.Time
|
||||||
|
BatchSize int
|
||||||
|
Limit int
|
||||||
|
}
|
||||||
|
|
||||||
|
type normalizeStats struct {
|
||||||
|
Processed int
|
||||||
|
Changed int
|
||||||
|
Updated int
|
||||||
|
Skipped int
|
||||||
|
Failed int
|
||||||
|
}
|
||||||
|
|
||||||
|
type recordingMetricRow struct {
|
||||||
|
ID uint `gorm:"column:id"`
|
||||||
|
ProjectFlockKandangID uint `gorm:"column:project_flock_kandangs_id"`
|
||||||
|
RecordDatetime time.Time `gorm:"column:record_datetime"`
|
||||||
|
HenHouse *float64 `gorm:"column:hen_house"`
|
||||||
|
EggMass *float64 `gorm:"column:egg_mass"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var (
|
||||||
|
apply bool
|
||||||
|
recordingID uint
|
||||||
|
projectFlockKandangID uint
|
||||||
|
fromRaw string
|
||||||
|
toRaw string
|
||||||
|
batchSize int
|
||||||
|
limit int
|
||||||
|
)
|
||||||
|
|
||||||
|
flag.BoolVar(&apply, "apply", false, "Apply update. If false, run as dry-run")
|
||||||
|
flag.UintVar(&recordingID, "recording-id", 0, "Target a single recording ID")
|
||||||
|
flag.UintVar(&projectFlockKandangID, "project-flock-kandang-id", 0, "Filter by project_flock_kandangs_id")
|
||||||
|
flag.StringVar(&fromRaw, "from", "", "Lower bound record_datetime (RFC3339 / YYYY-MM-DD)")
|
||||||
|
flag.StringVar(&toRaw, "to", "", "Upper bound record_datetime (RFC3339 / YYYY-MM-DD)")
|
||||||
|
flag.IntVar(&batchSize, "batch-size", 200, "Batch size when scanning recordings")
|
||||||
|
flag.IntVar(&limit, "limit", 0, "Max recordings to process (0 = no limit)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if batchSize <= 0 {
|
||||||
|
log.Fatal("--batch-size must be > 0")
|
||||||
|
}
|
||||||
|
if limit < 0 {
|
||||||
|
log.Fatal("--limit cannot be negative")
|
||||||
|
}
|
||||||
|
|
||||||
|
from, err := parseTimeBound(strings.TrimSpace(fromRaw), false)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("invalid --from: %v", err)
|
||||||
|
}
|
||||||
|
to, err := parseTimeBound(strings.TrimSpace(toRaw), true)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("invalid --to: %v", err)
|
||||||
|
}
|
||||||
|
if from != nil && to != nil && to.Before(*from) {
|
||||||
|
log.Fatal("--to cannot be before --from")
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := normalizeOptions{
|
||||||
|
Apply: apply,
|
||||||
|
RecordingID: recordingID,
|
||||||
|
ProjectFlockKandangID: projectFlockKandangID,
|
||||||
|
From: from,
|
||||||
|
To: to,
|
||||||
|
BatchSize: batchSize,
|
||||||
|
Limit: limit,
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
db := database.Connect(config.DBHost, config.DBName)
|
||||||
|
repo := recordingRepo.NewRecordingRepository(db)
|
||||||
|
|
||||||
|
fmt.Printf("Mode: %s\n", modeLabel(opts.Apply))
|
||||||
|
fmt.Printf("Filter recording_id: %s\n", displayUint(opts.RecordingID))
|
||||||
|
fmt.Printf("Filter project_flock_kandangs_id: %s\n", displayUint(opts.ProjectFlockKandangID))
|
||||||
|
fmt.Printf("Filter from: %s\n", displayTime(opts.From))
|
||||||
|
fmt.Printf("Filter to: %s\n", displayTime(opts.To))
|
||||||
|
fmt.Printf("Batch size: %d\n", opts.BatchSize)
|
||||||
|
fmt.Printf("Limit: %d\n\n", opts.Limit)
|
||||||
|
|
||||||
|
stats, err := normalizeRecordings(ctx, db, repo, opts)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("normalize failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf(
|
||||||
|
"Summary: processed=%d changed=%d updated=%d skipped=%d failed=%d\n",
|
||||||
|
stats.Processed,
|
||||||
|
stats.Changed,
|
||||||
|
stats.Updated,
|
||||||
|
stats.Skipped,
|
||||||
|
stats.Failed,
|
||||||
|
)
|
||||||
|
|
||||||
|
if stats.Failed > 0 {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRecordings(
|
||||||
|
ctx context.Context,
|
||||||
|
db *gorm.DB,
|
||||||
|
repo recordingRepo.RecordingRepository,
|
||||||
|
opts normalizeOptions,
|
||||||
|
) (normalizeStats, error) {
|
||||||
|
stats := normalizeStats{}
|
||||||
|
lastID := uint(0)
|
||||||
|
initialChickCache := make(map[uint]float64)
|
||||||
|
|
||||||
|
for {
|
||||||
|
batchLimit := opts.BatchSize
|
||||||
|
if opts.Limit > 0 {
|
||||||
|
remaining := opts.Limit - stats.Processed
|
||||||
|
if remaining <= 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if remaining < batchLimit {
|
||||||
|
batchLimit = remaining
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := loadRecordingBatch(ctx, db, opts, lastID, batchLimit)
|
||||||
|
if err != nil {
|
||||||
|
return stats, err
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, row := range rows {
|
||||||
|
stats.Processed++
|
||||||
|
lastID = row.ID
|
||||||
|
|
||||||
|
initialChick, ok := initialChickCache[row.ProjectFlockKandangID]
|
||||||
|
if !ok {
|
||||||
|
initialChick, err = repo.GetTotalChickinByProjectFlockKandang(db.WithContext(ctx), row.ProjectFlockKandangID)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("FAIL rec=%d error=getTotalChickinByProjectFlockKandang: %v\n", row.ID, err)
|
||||||
|
stats.Failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
initialChickCache[row.ProjectFlockKandangID] = initialChick
|
||||||
|
}
|
||||||
|
|
||||||
|
_, totalEggWeightGrams, err := repo.GetEggSummaryByRecording(db.WithContext(ctx), row.ID)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("FAIL rec=%d error=getEggSummaryByRecording: %v\n", row.ID, err)
|
||||||
|
stats.Failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
cumulativeEggQty, err := repo.GetCumulativeEggQtyByProjectFlockKandang(db.WithContext(ctx), row.ProjectFlockKandangID, row.RecordDatetime)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("FAIL rec=%d error=getCumulativeEggQtyByProjectFlockKandang: %v\n", row.ID, err)
|
||||||
|
stats.Failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
newHenHouse, newEggMass := computeNormalizedMetrics(initialChick, cumulativeEggQty, totalEggWeightGrams)
|
||||||
|
henHouseChanged := metricChanged(row.HenHouse, newHenHouse)
|
||||||
|
eggMassChanged := metricChanged(row.EggMass, newEggMass)
|
||||||
|
|
||||||
|
if !henHouseChanged && !eggMassChanged {
|
||||||
|
stats.Skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.Changed++
|
||||||
|
fmt.Printf(
|
||||||
|
"PLAN rec=%d pfk=%d at=%s hen_house:%s->%s egg_mass:%s->%s\n",
|
||||||
|
row.ID,
|
||||||
|
row.ProjectFlockKandangID,
|
||||||
|
row.RecordDatetime.UTC().Format(time.RFC3339),
|
||||||
|
displayFloat(row.HenHouse),
|
||||||
|
displayFloat(newHenHouse),
|
||||||
|
displayFloat(row.EggMass),
|
||||||
|
displayFloat(newEggMass),
|
||||||
|
)
|
||||||
|
|
||||||
|
if !opts.Apply {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := updateRecordingMetrics(ctx, db, row.ID, newHenHouse, newEggMass); err != nil {
|
||||||
|
fmt.Printf("FAIL rec=%d error=updateRecordingMetrics: %v\n", row.ID, err)
|
||||||
|
stats.Failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(
|
||||||
|
"DONE rec=%d hen_house=%s egg_mass=%s\n",
|
||||||
|
row.ID,
|
||||||
|
displayFloat(newHenHouse),
|
||||||
|
displayFloat(newEggMass),
|
||||||
|
)
|
||||||
|
stats.Updated++
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.RecordingID > 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadRecordingBatch(
|
||||||
|
ctx context.Context,
|
||||||
|
db *gorm.DB,
|
||||||
|
opts normalizeOptions,
|
||||||
|
lastID uint,
|
||||||
|
limit int,
|
||||||
|
) ([]recordingMetricRow, error) {
|
||||||
|
query := db.WithContext(ctx).
|
||||||
|
Table("recordings").
|
||||||
|
Select("id, project_flock_kandangs_id, record_datetime, hen_house, egg_mass").
|
||||||
|
Where("recordings.deleted_at IS NULL")
|
||||||
|
|
||||||
|
if opts.RecordingID > 0 {
|
||||||
|
query = query.Where("recordings.id = ?", opts.RecordingID)
|
||||||
|
}
|
||||||
|
if opts.ProjectFlockKandangID > 0 {
|
||||||
|
query = query.Where("recordings.project_flock_kandangs_id = ?", opts.ProjectFlockKandangID)
|
||||||
|
}
|
||||||
|
if opts.From != nil {
|
||||||
|
query = query.Where("recordings.record_datetime >= ?", *opts.From)
|
||||||
|
}
|
||||||
|
if opts.To != nil {
|
||||||
|
query = query.Where("recordings.record_datetime <= ?", *opts.To)
|
||||||
|
}
|
||||||
|
if opts.RecordingID == 0 && lastID > 0 {
|
||||||
|
query = query.Where("recordings.id > ?", lastID)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []recordingMetricRow
|
||||||
|
err := query.
|
||||||
|
Order("recordings.id ASC").
|
||||||
|
Limit(limit).
|
||||||
|
Scan(&rows).Error
|
||||||
|
return rows, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func computeNormalizedMetrics(initialChick, cumulativeEggQty, totalEggWeightGrams float64) (*float64, *float64) {
|
||||||
|
var henHouse *float64
|
||||||
|
if initialChick > 0 && cumulativeEggQty >= 0 {
|
||||||
|
value := cumulativeEggQty / initialChick
|
||||||
|
henHouse = &value
|
||||||
|
}
|
||||||
|
|
||||||
|
var eggMass *float64
|
||||||
|
if initialChick > 0 && totalEggWeightGrams > 0 {
|
||||||
|
value := totalEggWeightGrams / initialChick
|
||||||
|
eggMass = &value
|
||||||
|
}
|
||||||
|
|
||||||
|
return henHouse, eggMass
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateRecordingMetrics(ctx context.Context, db *gorm.DB, recordingID uint, henHouse, eggMass *float64) error {
|
||||||
|
updates := map[string]any{}
|
||||||
|
if henHouse == nil {
|
||||||
|
updates["hen_house"] = gorm.Expr("NULL")
|
||||||
|
} else {
|
||||||
|
updates["hen_house"] = *henHouse
|
||||||
|
}
|
||||||
|
if eggMass == nil {
|
||||||
|
updates["egg_mass"] = gorm.Expr("NULL")
|
||||||
|
} else {
|
||||||
|
updates["egg_mass"] = *eggMass
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.WithContext(ctx).
|
||||||
|
Table("recordings").
|
||||||
|
Where("id = ?", recordingID).
|
||||||
|
Updates(updates).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func metricChanged(oldValue, newValue *float64) bool {
|
||||||
|
if oldValue == nil && newValue == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if oldValue == nil || newValue == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return !nearlyEqual(*oldValue, *newValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
func nearlyEqual(a, b float64) bool {
|
||||||
|
scale := math.Max(1, math.Max(math.Abs(a), math.Abs(b)))
|
||||||
|
return math.Abs(a-b) <= metricEpsilon*scale
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTimeBound(raw string, isUpper bool) (*time.Time, error) {
|
||||||
|
if raw == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
layouts := []string{
|
||||||
|
time.RFC3339Nano,
|
||||||
|
time.RFC3339,
|
||||||
|
"2006-01-02 15:04:05",
|
||||||
|
"2006-01-02",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, layout := range layouts {
|
||||||
|
parsed, err := time.Parse(layout, raw)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if layout == "2006-01-02" {
|
||||||
|
if isUpper {
|
||||||
|
endOfDay := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 23, 59, 59, int(time.Second-time.Nanosecond), time.UTC)
|
||||||
|
return &endOfDay, nil
|
||||||
|
}
|
||||||
|
startOfDay := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
|
return &startOfDay, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
t := parsed.UTC()
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("unsupported format %q", raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func modeLabel(apply bool) string {
|
||||||
|
if apply {
|
||||||
|
return "APPLY"
|
||||||
|
}
|
||||||
|
return "DRY-RUN"
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayFloat(v *float64) string {
|
||||||
|
if v == nil {
|
||||||
|
return "NULL"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.6f", *v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayTime(v *time.Time) string {
|
||||||
|
if v == nil {
|
||||||
|
return "<nil>"
|
||||||
|
}
|
||||||
|
return v.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayUint(v uint) string {
|
||||||
|
if v == 0 {
|
||||||
|
return "<all>"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d", v)
|
||||||
|
}
|
||||||
@@ -758,15 +758,39 @@ func (r *RecordingRepositoryImpl) GetCumulativeEggQtyByProjectFlockKandang(
|
|||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var result float64
|
var cumulativeEggQty float64
|
||||||
err := tx.
|
err := tx.
|
||||||
Table("recording_eggs").
|
Table("recording_eggs").
|
||||||
Select("COALESCE(SUM(recording_eggs.qty), 0)").
|
Select("COALESCE(SUM(recording_eggs.qty), 0)").
|
||||||
Joins("JOIN recordings ON recordings.id = recording_eggs.recording_id").
|
Joins("JOIN recordings ON recordings.id = recording_eggs.recording_id").
|
||||||
Where("recordings.project_flock_kandangs_id = ?", projectFlockKandangId).
|
Where("recordings.project_flock_kandangs_id = ?", projectFlockKandangId).
|
||||||
Where("recordings.record_datetime <= ?", recordTime).
|
Where("recordings.record_datetime <= ?", recordTime).
|
||||||
Scan(&result).Error
|
Scan(&cumulativeEggQty).Error
|
||||||
return result, err
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
productWarehouseSubQuery := tx.
|
||||||
|
Table("recording_eggs").
|
||||||
|
Select("DISTINCT recording_eggs.product_warehouse_id").
|
||||||
|
Joins("JOIN recordings ON recordings.id = recording_eggs.recording_id").
|
||||||
|
Where("recordings.project_flock_kandangs_id = ?", projectFlockKandangId).
|
||||||
|
Where("recordings.record_datetime <= ?", recordTime)
|
||||||
|
|
||||||
|
var adjustmentEggQty float64
|
||||||
|
err = tx.
|
||||||
|
Table("adjustment_stocks").
|
||||||
|
Select("COALESCE(SUM(adjustment_stocks.total_qty), 0)").
|
||||||
|
Where("adjustment_stocks.product_warehouse_id IN (?)", productWarehouseSubQuery).
|
||||||
|
Where("adjustment_stocks.function_code = ?", "RECORDING_EGG_IN").
|
||||||
|
Where("adjustment_stocks.transaction_type = ?", "RECORDING").
|
||||||
|
Where("adjustment_stocks.created_at <= ?", recordTime).
|
||||||
|
Scan(&adjustmentEggQty).Error
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return cumulativeEggQty + adjustmentEggQty, nil
|
||||||
}
|
}
|
||||||
func (r *RecordingRepositoryImpl) GetProductionWeightAndQtyByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeight float64, totalQty float64, err error) {
|
func (r *RecordingRepositoryImpl) GetProductionWeightAndQtyByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeight float64, totalQty float64, err error) {
|
||||||
// Body-weight tracking is removed; keep stub for report compatibility.
|
// Body-weight tracking is removed; keep stub for report compatibility.
|
||||||
|
|||||||
@@ -1989,9 +1989,9 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm
|
|||||||
}
|
}
|
||||||
|
|
||||||
var eggMass float64
|
var eggMass float64
|
||||||
if remainingChick > 0 && totalEggWeightGrams > 0 {
|
if initialChickin > 0 && totalEggWeightGrams > 0 {
|
||||||
// totalEggWeightGrams is in grams; egg mass is grams per hen.
|
// totalEggWeightGrams is in grams; egg mass uses initial chick population.
|
||||||
eggMass = totalEggWeightGrams / remainingChick
|
eggMass = totalEggWeightGrams / initialChickin
|
||||||
updates["egg_mass"] = eggMass
|
updates["egg_mass"] = eggMass
|
||||||
recording.EggMass = &eggMass
|
recording.EggMass = &eggMass
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in New Issue
Block a user