feat(BE): recording

This commit is contained in:
ragilap
2025-10-22 22:20:08 +07:00
parent 445789edfe
commit 346ae15314
10 changed files with 951 additions and 46 deletions
@@ -0,0 +1,24 @@
BEGIN;
--? Child Indexes(optional, biar rapi tapi klo gada juga ilang pas di drop)
DROP INDEX IF EXISTS idx_recording_stocks_product;
DROP INDEX IF EXISTS idx_recording_stocks_recording;
DROP INDEX IF EXISTS idx_recording_depl_recording;
DROP INDEX IF EXISTS idx_recording_bws_recording;
--? Child Tables
DROP TABLE IF EXISTS recording_stocks;
DROP TABLE IF EXISTS recording_depletions;
DROP TABLE IF EXISTS recording_bws;
--? Parent Indexes ON recordings
DROP INDEX IF EXISTS uq_recordings_flock_record_date;
DROP INDEX IF EXISTS idx_recordings_flock_datetime;
--? Parent table
DROP TABLE IF EXISTS recordings;
COMMIT;
@@ -0,0 +1,150 @@
BEGIN;
--? RECORDINGS (tabel induk recording harian)
CREATE TABLE IF NOT EXISTS recordings (
id BIGSERIAL PRIMARY KEY,
project_flock_id BIGINT NOT NULL,
record_datetime TIMESTAMPTZ NOT NULL,
record_date DATE,
status INT NOT NULL DEFAULT 0, --? 0=draft,1=submitted,2=approved,3=rejected
ontime INT NOT NULL DEFAULT 0, --? 1=ontime,0=late (pakai INT/BOOLEAN sesuai preferensi)
day INT,
total_depletion INT,
cum_depletion_rate NUMERIC(7,3),
daily_gain NUMERIC(7,3),
avg_daily_gain NUMERIC(7,3),
cum_intake INT,
fcr_value NUMERIC(7,3),
total_chick BIGINT,
daily_depletion_rate NUMERIC(7,3),
cum_depletion INT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT,
CONSTRAINT fk_recordings_project_flock
FOREIGN KEY (project_flock_id) REFERENCES project_flock_kandangs(id),
CONSTRAINT fk_recordings_created_by
FOREIGN KEY (created_by) REFERENCES users(id),
CONSTRAINT chk_recordings_status
CHECK (status IN (0,1,2,3)),
CONSTRAINT chk_recordings_ontime
CHECK (ontime IN (0,1)),
CONSTRAINT chk_recordings_day
CHECK (day IS NULL OR day >= 1),
CONSTRAINT chk_recordings_nonnegatives
CHECK (
(total_depletion IS NULL OR total_depletion >= 0) AND
(cum_depletion IS NULL OR cum_depletion >= 0) AND
(total_chick IS NULL OR total_chick >= 0) AND
(cum_intake IS NULL OR cum_intake >= 0) AND
(daily_gain IS NULL OR daily_gain >= 0) AND
(avg_daily_gain IS NULL OR avg_daily_gain >= 0) AND
(fcr_value IS NULL OR fcr_value > 0) AND
(daily_depletion_rate IS NULL OR daily_depletion_rate >= 0) AND
(cum_depletion_rate IS NULL OR cum_depletion_rate >= 0)
)
);
--? Set record_date otomatis berdasarkan record_datetime (pakai zona Asia/Jakarta)
CREATE OR REPLACE FUNCTION trg_set_record_date() RETURNS trigger AS $$
BEGIN
NEW.record_date := (NEW.record_datetime AT TIME ZONE 'Asia/Jakarta')::date;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS recordings_set_record_date_trg ON recordings;
CREATE TRIGGER recordings_set_record_date_trg
BEFORE INSERT OR UPDATE OF record_datetime ON recordings
FOR EACH ROW EXECUTE FUNCTION trg_set_record_date();
CREATE INDEX IF NOT EXISTS idx_recordings_flock_datetime
ON recordings (project_flock_id, record_datetime);
--? Unique harian (1 recording per hari dan per flock)
CREATE UNIQUE INDEX IF NOT EXISTS uq_recordings_flock_record_date
ON recordings (project_flock_id, record_date)
WHERE deleted_at IS NULL;
--? RECORDING_BWS (BW per recording)
CREATE TABLE IF NOT EXISTS recording_bws (
id BIGSERIAL PRIMARY KEY,
recording_id BIGINT NOT NULL,
weight NUMERIC(8,2) NOT NULL, --? bobot per ekor/kelompok
qty INT NOT NULL DEFAULT 1, --? jumlah ekor pada bobot ini
notes VARCHAR,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT fk_recording_bws_recording
FOREIGN KEY (recording_id) REFERENCES recordings(id) ON DELETE CASCADE,
CONSTRAINT chk_recording_bws_nonneg
CHECK (weight >= 0 AND qty >= 1)
);
CREATE INDEX IF NOT EXISTS idx_recording_bws_recording
ON recording_bws (recording_id);
--? RECORDING_DEPLETIONS
CREATE TABLE IF NOT EXISTS recording_depletions (
id BIGSERIAL PRIMARY KEY,
recording_id BIGINT NOT NULL,
product_warehouse_id BIGINT NOT NULL,
total BIGINT NOT NULL,
notes VARCHAR,
CONSTRAINT fk_recording_depl_recording
FOREIGN KEY (recording_id) REFERENCES recordings(id) ON DELETE CASCADE,
CONSTRAINT fk_recording_depl_prodwh
FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses(id),
CONSTRAINT chk_recording_depl_total
CHECK (total >= 0)
);
CREATE INDEX IF NOT EXISTS idx_recording_depl_recording
ON recording_depletions (recording_id);
--? RECORDING_STOCKS
CREATE TABLE IF NOT EXISTS recording_stocks (
id BIGSERIAL PRIMARY KEY,
recording_id BIGINT NOT NULL,
product_warehouse_id BIGINT NOT NULL,
increase NUMERIC(10,3), --? penambahan (boleh NULL)
decrease NUMERIC(10,3), --? pengurangan (boleh NULL)
usage_amount BIGINT, --? pemakaian (opsional, jika konsep dipisah dari decrease)
notes VARCHAR,
CONSTRAINT fk_recording_stocks_recording
FOREIGN KEY (recording_id) REFERENCES recordings(id) ON DELETE CASCADE,
CONSTRAINT fk_recording_stocks_prodwh
FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses(id),
CONSTRAINT chk_recording_stocks_nonneg
CHECK (
(increase IS NULL OR increase >= 0) AND
(decrease IS NULL OR decrease >= 0) AND
(usage_amount IS NULL OR usage_amount >= 0)
)
);
CREATE INDEX IF NOT EXISTS idx_recording_stocks_recording
ON recording_stocks (recording_id);
CREATE INDEX IF NOT EXISTS idx_recording_stocks_product
ON recording_stocks (product_warehouse_id);
COMMIT;
+25 -7
View File
@@ -7,12 +7,30 @@ import (
) )
type Recording struct { type Recording struct {
Id uint `gorm:"primaryKey"` Id uint `gorm:"primaryKey"`
Name string `gorm:"not null;uniqueIndex:idx_name,where:deleted_at IS NULL"` ProjectFlockKandangId uint `gorm:"column:project_flock_id;not null;index"`
CreatedBy uint `gorm:"not null"` RecordDatetime time.Time `gorm:"column:record_datetime;not null"`
CreatedAt time.Time `gorm:"autoCreateTime"` RecordDate *time.Time `gorm:"column:record_date"`
UpdatedAt time.Time `gorm:"autoUpdateTime"` Status int `gorm:"column:status;not null;default:0"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` Ontime bool `gorm:"column:ontime;not null;default:false"`
Day *int `gorm:"column:day"`
TotalDepletion *int `gorm:"column:total_depletion"`
CumDepletionRate *float64 `gorm:"column:cum_depletion_rate"`
DailyGain *float64 `gorm:"column:daily_gain"`
AvgDailyGain *float64 `gorm:"column:avg_daily_gain"`
CumIntake *int64 `gorm:"column:cum_intake"`
FcrValue *float64 `gorm:"column:fcr_value"`
TotalChick *int64 `gorm:"column:total_chick"`
DailyDepletionRate *float64 `gorm:"column:daily_depletion_rate"`
CumDepletion *int `gorm:"column:cum_depletion"`
CreatedBy uint `gorm:"column:created_by"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` ProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:ProjectFlockKandangId;references:Id"`
CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"`
BodyWeights []RecordingBW `gorm:"foreignKey:RecordingId;references:Id"`
Depletions []RecordingDepletion `gorm:"foreignKey:RecordingId;references:Id"`
Stocks []RecordingStock `gorm:"foreignKey:RecordingId;references:Id"`
} }
+16
View File
@@ -0,0 +1,16 @@
package entities
import "time"
type RecordingBW struct {
Id uint `gorm:"primaryKey"`
RecordingId uint `gorm:"column:recording_id;not null;index"`
Weight float64 `gorm:"column:weight;not null"`
Qty int `gorm:"column:qty;not null;default:1"`
Notes *string `gorm:"column:notes"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
Recording Recording `gorm:"foreignKey:RecordingId;references:Id"`
}
+13
View File
@@ -0,0 +1,13 @@
package entities
type RecordingDepletion struct {
Id uint `gorm:"primaryKey"`
RecordingId uint `gorm:"column:recording_id;not null;index"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
Total int64 `gorm:"column:total;not null"`
Notes *string `gorm:"column:notes"`
Recording Recording `gorm:"foreignKey:RecordingId;references:Id"`
ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
}
+14
View File
@@ -0,0 +1,14 @@
package entities
type RecordingStock struct {
Id uint `gorm:"primaryKey"`
RecordingId uint `gorm:"column:recording_id;not null;index"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
Increase *float64 `gorm:"column:increase"`
Decrease *float64 `gorm:"column:decrease"`
UsageAmount *int64 `gorm:"column:usage_amount"`
Notes *string `gorm:"column:notes"`
Recording Recording `gorm:"foreignKey:RecordingId;references:Id"`
ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
}
@@ -23,10 +23,14 @@ func NewRecordingController(recordingService service.RecordingService) *Recordin
} }
func (u *RecordingController) GetAll(c *fiber.Ctx) error { func (u *RecordingController) GetAll(c *fiber.Ctx) error {
projectFlockID := c.QueryInt("project_flock_kandang_id", 0)
query := &validation.Query{ query := &validation.Query{
Page: c.QueryInt("page", 1), Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10), Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""), }
if projectFlockID > 0 {
query.ProjectFlockKandangId = uint(projectFlockID)
} }
result, totalResults, err := u.RecordingService.GetAll(c, query) result, totalResults, err := u.RecordingService.GetAll(c, query)
@@ -10,15 +10,29 @@ import (
// === DTO Structs === // === DTO Structs ===
type RecordingBaseDTO struct { type RecordingBaseDTO struct {
Id uint `json:"id"` Id uint `json:"id"`
Name string `json:"name"` ProjectFlockKandangId uint `json:"project_flock_kandang_id"`
RecordDatetime time.Time `json:"record_datetime"`
RecordDate *time.Time `json:"record_date,omitempty"`
Status int `json:"status"`
Ontime bool `json:"ontime"`
Day *int `json:"day,omitempty"`
TotalDepletion *int `json:"total_depletion,omitempty"`
CumDepletionRate *float64 `json:"cum_depletion_rate,omitempty"`
DailyGain *float64 `json:"daily_gain,omitempty"`
AvgDailyGain *float64 `json:"avg_daily_gain,omitempty"`
CumIntake *int64 `json:"cum_intake,omitempty"`
FcrValue *float64 `json:"fcr_value,omitempty"`
TotalChick *int64 `json:"total_chick,omitempty"`
DailyDepletionRate *float64 `json:"daily_depletion_rate,omitempty"`
CumDepletion *int `json:"cum_depletion,omitempty"`
} }
type RecordingListDTO struct { type RecordingListDTO struct {
RecordingBaseDTO RecordingBaseDTO
CreatedUser *userDTO.UserBaseDTO `json:"created_user"` CreatedUser *userDTO.UserBaseDTO `json:"created_user"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
type RecordingDetailDTO struct { type RecordingDetailDTO struct {
@@ -29,23 +43,37 @@ type RecordingDetailDTO struct {
func ToRecordingBaseDTO(e entity.Recording) RecordingBaseDTO { func ToRecordingBaseDTO(e entity.Recording) RecordingBaseDTO {
return RecordingBaseDTO{ return RecordingBaseDTO{
Id: e.Id, Id: e.Id,
Name: e.Name, ProjectFlockKandangId: e.ProjectFlockKandangId,
RecordDatetime: e.RecordDatetime,
RecordDate: e.RecordDate,
Status: e.Status,
Ontime: e.Ontime,
Day: e.Day,
TotalDepletion: e.TotalDepletion,
CumDepletionRate: e.CumDepletionRate,
DailyGain: e.DailyGain,
AvgDailyGain: e.AvgDailyGain,
CumIntake: e.CumIntake,
FcrValue: e.FcrValue,
TotalChick: e.TotalChick,
DailyDepletionRate: e.DailyDepletionRate,
CumDepletion: e.CumDepletion,
} }
} }
func ToRecordingListDTO(e entity.Recording) RecordingListDTO { func ToRecordingListDTO(e entity.Recording) RecordingListDTO {
var createdUser *userDTO.UserBaseDTO var createdUser *userDTO.UserBaseDTO
if e.CreatedUser.Id != 0 { if e.CreatedUser != nil && e.CreatedUser.Id != 0 {
mapped := userDTO.ToUserBaseDTO(e.CreatedUser) mapped := userDTO.ToUserBaseDTO(*e.CreatedUser)
createdUser = &mapped createdUser = &mapped
} }
return RecordingListDTO{ return RecordingListDTO{
RecordingBaseDTO: ToRecordingBaseDTO(e), RecordingBaseDTO: ToRecordingBaseDTO(e),
CreatedAt: e.CreatedAt, CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt, UpdatedAt: e.UpdatedAt,
CreatedUser: createdUser, CreatedUser: createdUser,
} }
} }
@@ -2,6 +2,11 @@ package service
import ( import (
"errors" "errors"
"fmt"
"math"
"sort"
"strings"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
@@ -37,7 +42,13 @@ func NewRecordingService(repo repository.RecordingRepository, validate *validato
} }
func (s recordingService) withRelations(db *gorm.DB) *gorm.DB { func (s recordingService) withRelations(db *gorm.DB) *gorm.DB {
return db.Preload("CreatedUser") return db.
Preload("CreatedUser").
Preload("ProjectFlockKandang").
Preload("ProjectFlockKandang.ProjectFlock").
Preload("BodyWeights").
Preload("Depletions").
Preload("Stocks")
} }
func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Recording, int64, error) { func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Recording, int64, error) {
@@ -45,14 +56,22 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
return nil, 0, err return nil, 0, err
} }
offset := (params.Page - 1) * params.Limit limit := params.Limit
if limit == 0 {
limit = 10
}
page := params.Page
if page == 0 {
page = 1
}
offset := (page - 1) * limit
recordings, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { recordings, total, err := s.Repository.GetAll(c.Context(), offset, limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db) db = s.withRelations(db)
if params.Search != "" { if params.ProjectFlockKandangId != 0 {
return db.Where("name LIKE ?", "%"+params.Search+"%") db = db.Where("project_flock_id = ?", params.ProjectFlockKandangId)
} }
return db.Order("created_at DESC").Order("updated_at DESC") return db.Order("record_datetime DESC").Order("created_at DESC")
}) })
if err != nil { if err != nil {
@@ -79,16 +98,82 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
return nil, err return nil, err
} }
createBody := &entity.Recording{ recordTime, err := time.Parse(time.RFC3339, req.RecordDatetime)
Name: req.Name, if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "record_datetime must be in RFC3339 format")
} }
if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { tx := s.Repository.DB().WithContext(c.Context()).Begin()
if tx.Error != nil {
s.Log.Errorf("Failed to start recording transaction: %+v", tx.Error)
return nil, tx.Error
}
defer func() {
if r := recover(); r != nil {
_ = tx.Rollback()
panic(r)
}
}()
nextDay, err := s.generateNextDay(tx, req.ProjectFlockKandangId)
if err != nil {
_ = tx.Rollback()
s.Log.Errorf("Failed to determine recording day: %+v", err)
return nil, err
}
status := 0
if req.Status != nil {
status = *req.Status
}
ontime := false
if req.Ontime != nil {
ontime = *req.Ontime
}
recording := &entity.Recording{
ProjectFlockKandangId: req.ProjectFlockKandangId,
RecordDatetime: recordTime,
Status: status,
Ontime: ontime,
Day: &nextDay,
CreatedBy: 1, // TODO: replace with authenticated user
}
if err := tx.Create(recording).Error; err != nil {
_ = tx.Rollback()
s.Log.Errorf("Failed to create recording: %+v", err) s.Log.Errorf("Failed to create recording: %+v", err)
return nil, err return nil, err
} }
return s.GetOne(c, createBody.Id) if err := s.persistBodyWeights(tx, recording.Id, req.BodyWeights); err != nil {
_ = tx.Rollback()
s.Log.Errorf("Failed to persist body weights: %+v", err)
return nil, err
}
if err := s.persistStocks(tx, recording.Id, req.Stocks); err != nil {
_ = tx.Rollback()
s.Log.Errorf("Failed to persist stocks: %+v", err)
return nil, err
}
if err := s.persistDepletions(tx, recording.Id, req.Depletions); err != nil {
_ = tx.Rollback()
s.Log.Errorf("Failed to persist depletions: %+v", err)
return nil, err
}
if err := s.computeAndUpdateMetrics(tx, recording); err != nil {
_ = tx.Rollback()
s.Log.Errorf("Failed to compute recording metrics: %+v", err)
return nil, err
}
if err := tx.Commit().Error; err != nil {
s.Log.Errorf("Failed to commit recording transaction: %+v", err)
return nil, err
}
return s.GetOne(c, recording.Id)
} }
func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Recording, error) { func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Recording, error) {
@@ -96,21 +181,86 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
return nil, err return nil, err
} }
updateBody := make(map[string]any) tx := s.Repository.DB().WithContext(c.Context()).Begin()
if tx.Error != nil {
if req.Name != nil { s.Log.Errorf("Failed to start recording transaction: %+v", tx.Error)
updateBody["name"] = *req.Name return nil, tx.Error
} }
defer func() {
if r := recover(); r != nil {
_ = tx.Rollback()
panic(r)
}
}()
if len(updateBody) == 0 { var recording entity.Recording
return s.GetOne(c, id) if err := tx.First(&recording, id).Error; err != nil {
} _ = tx.Rollback()
if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Recording not found") return nil, fiber.NewError(fiber.StatusNotFound, "Recording not found")
} }
s.Log.Errorf("Failed to update recording: %+v", err) s.Log.Errorf("Failed to find recording: %+v", err)
return nil, err
}
updateBody := make(map[string]any)
if req.RecordDatetime != nil {
parsed, err := time.Parse(time.RFC3339, *req.RecordDatetime)
if err != nil {
_ = tx.Rollback()
return nil, fiber.NewError(fiber.StatusBadRequest, "record_datetime must be in RFC3339 format")
}
updateBody["record_datetime"] = parsed
recording.RecordDatetime = parsed
}
if req.Status != nil {
updateBody["status"] = *req.Status
recording.Status = *req.Status
}
if req.Ontime != nil {
updateBody["ontime"] = *req.Ontime
recording.Ontime = *req.Ontime
}
if len(updateBody) > 0 {
if err := tx.Model(&entity.Recording{}).Where("id = ?", id).Updates(updateBody).Error; err != nil {
_ = tx.Rollback()
s.Log.Errorf("Failed to update recording: %+v", err)
return nil, err
}
}
if req.BodyWeights != nil {
if err := s.replaceBodyWeights(tx, recording.Id, req.BodyWeights); err != nil {
_ = tx.Rollback()
s.Log.Errorf("Failed to update body weights: %+v", err)
return nil, err
}
}
if req.Stocks != nil {
if err := s.replaceStocks(tx, recording.Id, req.Stocks); err != nil {
_ = tx.Rollback()
s.Log.Errorf("Failed to update stocks: %+v", err)
return nil, err
}
}
if req.Depletions != nil {
if err := s.replaceDepletions(tx, recording.Id, req.Depletions); err != nil {
_ = tx.Rollback()
s.Log.Errorf("Failed to update depletions: %+v", err)
return nil, err
}
}
if err := s.computeAndUpdateMetrics(tx, &recording); err != nil {
_ = tx.Rollback()
s.Log.Errorf("Failed to recompute recording metrics: %+v", err)
return nil, err
}
if err := tx.Commit().Error; err != nil {
s.Log.Errorf("Failed to commit recording transaction: %+v", err)
return nil, err return nil, err
} }
@@ -127,3 +277,458 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error {
} }
return nil return nil
} }
// === Persistence Helpers ===
func (s *recordingService) generateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error) {
var days []int
if err := tx.Model(&entity.Recording{}).
Where("project_flock_id = ?", projectFlockKandangId).
Where("day IS NOT NULL").
Pluck("day", &days).Error; err != nil {
return 0, err
}
return nextRecordingDay(days), nil
}
func nextRecordingDay(days []int) int {
if len(days) == 0 {
return 1
}
unique := make(map[int]struct{}, len(days))
for _, day := range days {
if day > 0 {
unique[day] = struct{}{}
}
}
normalized := make([]int, 0, len(unique))
for day := range unique {
normalized = append(normalized, day)
}
sort.Ints(normalized)
for idx, day := range normalized {
expected := idx + 1
if day != expected {
return expected
}
}
return len(normalized) + 1
}
func (s *recordingService) persistBodyWeights(tx *gorm.DB, recordingID uint, payload []validation.BodyWeight) error {
if len(payload) == 0 {
return nil
}
bodyWeights := make([]entity.RecordingBW, len(payload))
for i, bw := range payload {
bodyWeights[i] = entity.RecordingBW{
RecordingId: recordingID,
Weight: bw.Weight,
Qty: bw.Qty,
Notes: bw.Notes,
}
}
return tx.Create(&bodyWeights).Error
}
func (s *recordingService) persistStocks(tx *gorm.DB, recordingID uint, payload []validation.Stock) error {
if len(payload) == 0 {
return nil
}
stocks := make([]entity.RecordingStock, len(payload))
for i, stock := range payload {
stocks[i] = entity.RecordingStock{
RecordingId: recordingID,
ProductWarehouseId: stock.ProductWarehouseId,
Notes: stock.Notes,
}
if stock.Increase != nil {
val := *stock.Increase
stocks[i].Increase = &val
}
if stock.Decrease != nil {
val := *stock.Decrease
stocks[i].Decrease = &val
}
if stock.UsageAmount != nil {
val := *stock.UsageAmount
stocks[i].UsageAmount = &val
}
}
return tx.Create(&stocks).Error
}
func (s *recordingService) persistDepletions(tx *gorm.DB, recordingID uint, payload []validation.Depletion) error {
if len(payload) == 0 {
return nil
}
depletions := make([]entity.RecordingDepletion, len(payload))
for i, depl := range payload {
total := depl.Total
depletions[i] = entity.RecordingDepletion{
RecordingId: recordingID,
ProductWarehouseId: depl.ProductWarehouseId,
Total: total,
Notes: depl.Notes,
}
}
return tx.Create(&depletions).Error
}
func (s *recordingService) replaceBodyWeights(tx *gorm.DB, recordingID uint, payload []validation.BodyWeight) error {
if err := tx.Where("recording_id = ?", recordingID).Delete(&entity.RecordingBW{}).Error; err != nil {
return err
}
return s.persistBodyWeights(tx, recordingID, payload)
}
func (s *recordingService) replaceStocks(tx *gorm.DB, recordingID uint, payload []validation.Stock) error {
if err := tx.Where("recording_id = ?", recordingID).Delete(&entity.RecordingStock{}).Error; err != nil {
return err
}
return s.persistStocks(tx, recordingID, payload)
}
func (s *recordingService) replaceDepletions(tx *gorm.DB, recordingID uint, payload []validation.Depletion) error {
if err := tx.Where("recording_id = ?", recordingID).Delete(&entity.RecordingDepletion{}).Error; err != nil {
return err
}
return s.persistDepletions(tx, recordingID, payload)
}
// === Metrics Calculation ===
func (s *recordingService) computeAndUpdateMetrics(tx *gorm.DB, recording *entity.Recording) error {
day := 0
if recording.Day != nil {
day = *recording.Day
}
totalDepletion, err := s.sumRecordingDepletions(tx, recording.Id)
if err != nil {
return fmt.Errorf("sumRecordingDepletions: %w", err)
}
prevRecording, err := s.getPreviousRecording(tx, recording.ProjectFlockKandangId, day)
if err != nil {
return fmt.Errorf("getPreviousRecording: %w", err)
}
var prevCumDepletion int64
var prevCumIntake float64
var prevAvgWeight float64
if prevRecording != nil {
if prevRecording.CumDepletion != nil {
prevCumDepletion = int64(*prevRecording.CumDepletion)
}
if prevRecording.CumIntake != nil {
prevCumIntake = float64(*prevRecording.CumIntake)
}
prevAvgWeight, err = s.getAverageBodyWeight(tx, prevRecording.Id)
if err != nil {
return fmt.Errorf("getAverageBodyWeight(prev): %w", err)
}
}
totalChick, err := s.getTotalChick(tx, recording.ProjectFlockKandangId)
if err != nil {
return fmt.Errorf("getTotalChick: %w", err)
}
currentAvgWeight, err := s.getAverageBodyWeight(tx, recording.Id)
if err != nil {
return fmt.Errorf("getAverageBodyWeight(current): %w", err)
}
usageInGrams, err := s.getFeedUsageInGrams(tx, recording.Id)
if err != nil {
return fmt.Errorf("getFeedUsageInGrams: %w", err)
}
fcrId, err := s.getFcrID(tx, recording.ProjectFlockKandangId)
if err != nil {
return fmt.Errorf("getFcrID: %w", err)
}
currentAvgGrams := toGrams(currentAvgWeight)
currentAvgKg := gramsToKg(currentAvgGrams)
prevAvgGrams := toGrams(prevAvgWeight)
totalDepletionInt := int(totalDepletion)
cumDepletion := prevCumDepletion + totalDepletion
cumDepletionInt := int(cumDepletion)
updates := map[string]any{
"total_depletion": totalDepletionInt,
"cum_depletion": cumDepletionInt,
}
recording.TotalDepletion = &totalDepletionInt
recording.CumDepletion = &cumDepletionInt
if totalChick > 0 {
updates["total_chick"] = totalChick
recording.TotalChick = &totalChick
cumRate := (float64(cumDepletion) / float64(totalChick)) * 100
updates["cum_depletion_rate"] = cumRate
recording.CumDepletionRate = &cumRate
remainingAfter := totalChick - cumDepletion
if remainingAfter <= 0 {
remainingAfter = 1
}
dailyRate := (float64(totalDepletion) / float64(remainingAfter)) * 100
updates["daily_depletion_rate"] = dailyRate
recording.DailyDepletionRate = &dailyRate
} else {
updates["total_chick"] = gorm.Expr("NULL")
updates["cum_depletion_rate"] = gorm.Expr("NULL")
updates["daily_depletion_rate"] = gorm.Expr("NULL")
recording.TotalChick = nil
recording.CumDepletionRate = nil
recording.DailyDepletionRate = nil
}
if currentAvgGrams > 0 && prevAvgGrams > 0 {
dailyGainKg := (currentAvgGrams - prevAvgGrams) / 1000
updates["daily_gain"] = dailyGainKg
recording.DailyGain = &dailyGainKg
} else {
updates["daily_gain"] = gorm.Expr("NULL")
recording.DailyGain = nil
}
if fcrId != 0 && currentAvgKg > 0 && day > 0 {
if fcrWeightKg, ok, err := s.getFcrStandardWeightKg(tx, fcrId, currentAvgKg); err != nil {
return fmt.Errorf("getFcrStandardWeightKg: %w", err)
} else if ok {
avgDailyGain := (currentAvgKg - fcrWeightKg) / float64(day)
updates["avg_daily_gain"] = avgDailyGain
recording.AvgDailyGain = &avgDailyGain
} else {
updates["avg_daily_gain"] = gorm.Expr("NULL")
recording.AvgDailyGain = nil
}
} else {
updates["avg_daily_gain"] = gorm.Expr("NULL")
recording.AvgDailyGain = nil
}
if usageInGrams > 0 && totalChick > 0 {
var cumIntakeValue float64
if prevRecording == nil || prevRecording.CumIntake == nil {
cumIntakeValue = usageInGrams / float64(totalChick)
} else {
remaining := float64(totalChick - cumDepletion)
if remaining <= 0 {
remaining = float64(totalChick)
}
cumIntakeValue = prevCumIntake + (usageInGrams / remaining)
}
cumIntakeRounded := int64(math.Round(cumIntakeValue))
updates["cum_intake"] = cumIntakeRounded
recording.CumIntake = &cumIntakeRounded
} else if prevRecording != nil && prevRecording.CumIntake != nil {
// Keep previous cumulative intake if no additional feed usage provided
updates["cum_intake"] = *prevRecording.CumIntake
recording.CumIntake = prevRecording.CumIntake
} else {
updates["cum_intake"] = gorm.Expr("NULL")
recording.CumIntake = nil
}
if usageInGrams > 0 && currentAvgKg > 0 {
feedUsageKg := usageInGrams / 1000
fcrValue := feedUsageKg / currentAvgKg
updates["fcr_value"] = fcrValue
recording.FcrValue = &fcrValue
} else {
updates["fcr_value"] = gorm.Expr("NULL")
recording.FcrValue = nil
}
if err := tx.Model(&entity.Recording{}).
Where("id = ?", recording.Id).
Updates(updates).Error; err != nil {
return err
}
return nil
}
// === Query Helpers ===
func (s *recordingService) sumRecordingDepletions(tx *gorm.DB, recordingID uint) (int64, error) {
var result int64
if err := tx.Model(&entity.RecordingDepletion{}).
Where("recording_id = ?", recordingID).
Select("COALESCE(SUM(total), 0)").
Scan(&result).Error; err != nil {
return 0, err
}
return result, nil
}
func (s *recordingService) getPreviousRecording(tx *gorm.DB, projectFlockKandangId uint, currentDay int) (*entity.Recording, error) {
if currentDay <= 1 {
return nil, nil
}
var prev entity.Recording
err := tx.
Where("project_flock_id = ? AND day < ?", projectFlockKandangId, currentDay).
Where("day IS NOT NULL").
Order("day DESC").
Limit(1).
Find(&prev).Error
if errors.Is(err, gorm.ErrRecordNotFound) || prev.Id == 0 {
return nil, nil
}
if err != nil {
return nil, err
}
return &prev, nil
}
func (s *recordingService) getTotalChick(tx *gorm.DB, projectFlockKandangId uint) (int64, error) {
var population entity.ProjectFlockPopulation
err := tx.
Where("project_flock_kandang_id = ?", projectFlockKandangId).
Order("created_at DESC").
First(&population).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, nil
}
if err != nil {
return 0, err
}
return int64(math.Round(population.InitialQuantity)), nil
}
func (s *recordingService) getAverageBodyWeight(tx *gorm.DB, recordingID uint) (float64, error) {
var result struct {
TotalWeight float64
TotalQty float64
}
if err := tx.Model(&entity.RecordingBW{}).
Select("COALESCE(SUM(weight * qty), 0) AS total_weight, COALESCE(SUM(qty), 0) AS total_qty").
Where("recording_id = ?", recordingID).
Scan(&result).Error; err != nil {
return 0, err
}
if result.TotalQty == 0 {
return 0, nil
}
return result.TotalWeight / result.TotalQty, nil
}
func (s *recordingService) getFeedUsageInGrams(tx *gorm.DB, recordingID uint) (float64, error) {
var rows []struct {
UsageAmount float64
UomName string
}
if err := tx.
Table("recording_stocks").
Select("COALESCE(recording_stocks.usage_amount, 0) AS usage_amount, LOWER(uoms.name) AS uom_name").
Joins("JOIN product_warehouses ON product_warehouses.id = recording_stocks.product_warehouse_id").
Joins("JOIN products ON products.id = product_warehouses.product_id").
Joins("JOIN uoms ON uoms.id = products.uom_id").
Where("recording_stocks.recording_id = ?", recordingID).
Scan(&rows).Error; err != nil {
return 0, err
}
var total float64
for _, row := range rows {
if row.UsageAmount <= 0 {
continue
}
switch strings.TrimSpace(row.UomName) {
case "kilogram", "kg", "kilograms", "kilo":
total += row.UsageAmount * 1000
case "gram", "g", "grams":
total += row.UsageAmount
default:
total += row.UsageAmount
}
}
return total, nil
}
func (s *recordingService) getFcrID(tx *gorm.DB, projectFlockKandangId uint) (uint, error) {
var result struct {
FcrID uint
}
if err := tx.Table("project_flock_kandangs").
Select("project_flocks.fcr_id AS fcr_id").
Joins("JOIN project_flocks ON project_flocks.id = project_flock_kandangs.project_flock_id").
Where("project_flock_kandangs.id = ?", projectFlockKandangId).
Scan(&result).Error; err != nil {
return 0, err
}
return result.FcrID, nil
}
func (s *recordingService) getFcrStandardWeightKg(tx *gorm.DB, fcrId uint, currentWeightKg float64) (float64, bool, error) {
if fcrId == 0 {
return 0, false, nil
}
var standard entity.FcrStandard
err := tx.
Where("fcr_id = ? AND weight >= ?", fcrId, currentWeightKg).
Order("weight ASC").
First(&standard).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
err = tx.
Where("fcr_id = ?", fcrId).
Order("weight DESC").
First(&standard).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, false, nil
}
}
if err != nil {
return 0, false, err
}
weight := standard.Weight
if weight > 10 {
// assume already in grams
return weight / 1000, true, nil
}
return weight, true, nil
}
// === Unit Helpers ===
func toGrams(weight float64) float64 {
if weight <= 0 {
return 0
}
if weight > 10 {
return weight
}
return weight * 1000
}
func gramsToKg(value float64) float64 {
if value <= 0 {
return 0
}
return value / 1000
}
@@ -1,15 +1,48 @@
package validation package validation
type (
BodyWeight struct {
Weight float64 `json:"weight" validate:"required"`
Qty int `json:"qty" validate:"required,number,min=1"`
Notes *string `json:"notes,omitempty" validate:"omitempty"`
}
Stock struct {
ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"`
Increase *float64 `json:"increase,omitempty" validate:"omitempty"`
Decrease *float64 `json:"decrease,omitempty" validate:"omitempty"`
UsageAmount *int64 `json:"usage_amount,omitempty" validate:"omitempty,min=0"`
Notes *string `json:"notes,omitempty" validate:"omitempty"`
}
Depletion struct {
ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"`
Total int64 `json:"total" validate:"required,number,min=0"`
Notes *string `json:"notes,omitempty" validate:"omitempty"`
}
)
type Create struct { type Create struct {
Name string `json:"name" validate:"required_strict,min=3"` ProjectFlockKandangId uint `json:"project_flock_kandang_id" validate:"required,number,min=1"`
RecordDatetime string `json:"record_datetime" validate:"required,datetime=2006-01-02T15:04:05Z07:00"`
Status *int `json:"status,omitempty" validate:"omitempty,oneof=0 1 2 3"`
Ontime *bool `json:"ontime,omitempty" validate:"omitempty"`
BodyWeights []BodyWeight `json:"body_weights,omitempty" validate:"omitempty,dive"`
Stocks []Stock `json:"stocks,omitempty" validate:"omitempty,dive"`
Depletions []Depletion `json:"depletions,omitempty" validate:"omitempty,dive"`
} }
type Update struct { type Update struct {
Name *string `json:"name,omitempty" validate:"omitempty"` RecordDatetime *string `json:"record_datetime,omitempty" validate:"omitempty,datetime=2006-01-02T15:04:05Z07:00"`
Status *int `json:"status,omitempty" validate:"omitempty,oneof=0 1 2 3"`
Ontime *bool `json:"ontime,omitempty" validate:"omitempty"`
BodyWeights []BodyWeight `json:"body_weights,omitempty" validate:"omitempty,dive"`
Stocks []Stock `json:"stocks,omitempty" validate:"omitempty,dive"`
Depletions []Depletion `json:"depletions,omitempty" validate:"omitempty,dive"`
} }
type Query struct { type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1"` Page int `query:"page" validate:"omitempty,number,min=1"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
Search string `query:"search" validate:"omitempty,max=50"` ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"`
} }