[FEAT/BE] fixing fifo fallback recording,fixing backdate and fixing product category

This commit is contained in:
ragilap
2026-02-18 11:49:25 +07:00
parent 756fc431b3
commit 36ba4f34bb
20 changed files with 2036 additions and 889 deletions
@@ -36,6 +36,7 @@ type ChickinService interface {
UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.ProjectChickin, error)
DeleteOne(ctx *fiber.Ctx, id uint) error
Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectChickin, error)
EnsureChickInExists(ctx context.Context, projectFlockKandangID uint) error
}
type chickinService struct {
@@ -731,6 +732,30 @@ func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB,
return nil
}
func (s chickinService) EnsureChickInExists(ctx context.Context, projectFlockKandangID uint) error {
if projectFlockKandangID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid")
}
populations, err := s.ProjectflockPopulationRepo.GetByProjectFlockKandangID(ctx, projectFlockKandangID)
if err != nil {
s.Log.Errorf("Failed to check project flock population for project_flock_kandang_id=%d: %+v", projectFlockKandangID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memeriksa data chick in")
}
if len(populations) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Project flock belum memiliki chick in yang disetujui sehingga belum dapat membuat recording")
}
for _, population := range populations {
if population.TotalQty > 0 {
return nil
}
}
return fiber.NewError(fiber.StatusBadRequest, "Chick in project flock belum disetujui sehingga belum dapat membuat recording")
}
func (s *chickinService) adjustProductWarehouseQuantities(ctx context.Context, tx *gorm.DB, deltas map[uint]float64) error {
if len(deltas) == 0 {
return nil
@@ -13,6 +13,7 @@ import (
type ProjectFlockKandangRepository interface {
GetByID(ctx context.Context, id uint) (*entity.ProjectFlockKandang, error)
GetByIDLight(ctx context.Context, id uint) (*entity.ProjectFlockKandang, error)
GetByProjectFlockAndKandang(ctx context.Context, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, error)
GetActiveByKandangID(ctx context.Context, kandangID uint) (*entity.ProjectFlockKandang, error)
UpdateClosedAt(ctx context.Context, id uint, t *time.Time) error
@@ -342,6 +343,17 @@ func (r *projectFlockKandangRepositoryImpl) GetByID(ctx context.Context, id uint
return record, nil
}
// GetByIDLight loads only the minimal relations needed for recording flows.
func (r *projectFlockKandangRepositoryImpl) GetByIDLight(ctx context.Context, id uint) (*entity.ProjectFlockKandang, error) {
record := new(entity.ProjectFlockKandang)
if err := r.db.WithContext(ctx).
Preload("ProjectFlock").
First(record, id).Error; err != nil {
return nil, err
}
return record, nil
}
func (r *projectFlockKandangRepositoryImpl) GetByProjectFlockAndKandang(ctx context.Context, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, error) {
record := new(entity.ProjectFlockKandang)
if err := r.db.WithContext(ctx).
@@ -48,6 +48,7 @@ type ProjectflockService interface {
GetProjectPeriods(ctx *fiber.Ctx, projectIDs []uint) (map[uint]int, error)
Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlock, error)
Resubmit(ctx *fiber.Ctx, req *validation.Resubmit, id uint) (*entity.ProjectFlock, error)
EnsureProjectFlockApproved(ctx context.Context, projectFlockID uint) error
}
type projectflockService struct {
@@ -112,6 +113,32 @@ func (s projectflockService) approvalQueryModifier() func(*gorm.DB) *gorm.DB {
}
}
func (s projectflockService) EnsureProjectFlockApproved(ctx context.Context, projectFlockID uint) error {
if projectFlockID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid")
}
approvalSvc := s.ApprovalSvc
if approvalSvc == nil {
approvalSvc = commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(s.Repository.DB()))
}
latest, err := approvalSvc.LatestByTarget(ctx, s.approvalWorkflow, projectFlockID, nil)
if err != nil {
s.Log.Errorf("Failed to check project flock %d approval status: %+v", projectFlockID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memeriksa status project flock")
}
if latest == nil {
return fiber.NewError(fiber.StatusBadRequest, "Project flock masih dalam status pengajuan sehingga belum dapat membuat recording")
}
if latest.StepNumber != uint16(utils.ProjectFlockStepAktif) || latest.Action == nil || *latest.Action != entity.ApprovalActionApproved {
return fiber.NewError(fiber.StatusBadRequest, "Project flock masih dalam status pengajuan sehingga belum dapat membuat recording")
}
return nil
}
func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, map[uint]*flockDTO.FlockRelationDTO, error) {
if err := s.Validate.Struct(params); err != nil {
return nil, 0, nil, err
@@ -459,6 +486,15 @@ func (s projectflockService) GetProjectFlockKandangPopulation(ctx *fiber.Ctx, pr
return 0, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required")
}
total, err := s.PopulationRepo.GetAvailableQtyByProjectFlockKandangID(ctx.Context(), projectFlockKandangID)
if err != nil {
s.Log.Errorf("Failed to fetch project flock kandang population %d: %+v", projectFlockKandangID, err)
return 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang population")
}
if total > 0 {
return total, nil
}
if s.RecordingRepo != nil {
latest, err := s.RecordingRepo.GetLatestByProjectFlockKandangID(ctx.Context(), projectFlockKandangID)
if err != nil {
@@ -470,12 +506,6 @@ func (s projectflockService) GetProjectFlockKandangPopulation(ctx *fiber.Ctx, pr
}
}
total, err := s.PopulationRepo.GetAvailableQtyByProjectFlockKandangID(ctx.Context(), projectFlockKandangID)
if err != nil {
s.Log.Errorf("Failed to fetch project flock kandang population %d: %+v", projectFlockKandangID, err)
return 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang population")
}
return total, nil
}
@@ -550,21 +580,22 @@ func (s projectflockService) GetProjectFlockKandangByParams(ctx *fiber.Ctx, idSt
}
func (s projectflockService) GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID uint) (float64, error) {
if s.PopulationRepo == nil {
return 0, fiber.NewError(fiber.StatusInternalServerError, "Project flock population repository is not configured")
}
wh, err := s.WarehouseRepo.GetByKandangID(ctx.Context(), kandangID)
pfk, err := s.PivotRepo.GetActiveByKandangID(ctx.Context(), kandangID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found")
}
return 0, err
}
productWarehouses, err := s.ProductWarehouseRepo.GetByCategoryCodeAndWarehouseID(ctx.Context(), "DOC", wh.Id)
total, err := s.PopulationRepo.GetAvailableQtyByProjectFlockKandangID(ctx.Context(), pfk.Id)
if err != nil {
return 0, err
}
total := 0.0
for _, pw := range productWarehouses {
total += pw.Quantity
}
return total, nil
}
@@ -27,9 +27,14 @@ func NewRecordingController(recordingService service.RecordingService) *Recordin
func (u *RecordingController) GetAll(c *fiber.Ctx) error {
projectFlockID := c.QueryInt("project_flock_kandang_id", 0)
page := c.QueryInt("page", 1)
limit := c.QueryInt("limit", 10)
offset := (page - 1) * limit
query := &validation.Query{
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
Page: page,
Limit: limit,
Offset: offset,
Search: c.Query("search"),
}
if projectFlockID > 0 {
@@ -79,25 +84,27 @@ func (u *RecordingController) GetOne(c *fiber.Ctx) error {
}
func (u *RecordingController) GetNextDay(c *fiber.Ctx) error {
projectFlockID := c.QueryInt("project_flock_kandang_id", 0)
if projectFlockID <= 0 {
req := new(validation.GetRecordingNextDay)
if err := c.QueryParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid query params")
}
if req.ProjectFlockKandangId == 0 {
return fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required")
}
recordTime := time.Now().UTC()
if recordDate := strings.TrimSpace(c.Query("record_date")); recordDate != "" {
parsed, err := time.Parse("2006-01-02", recordDate)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "record_date must be in YYYY-MM-DD format")
}
recordTime = parsed.UTC()
if req.RecordTime == nil || strings.TrimSpace(*req.RecordTime) == "" {
return fiber.NewError(fiber.StatusBadRequest, "record_date is required")
}
nextDay, err := u.RecordingService.GetNextDay(c, uint(projectFlockID), recordTime)
recordTime, err := time.Parse("2006-01-02", strings.TrimSpace(*req.RecordTime))
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "record_time must be in YYYY-MM-DD format")
}
req.RecordTimeValue = &recordTime
nextDay, err := u.RecordingService.GetNextDay(c, req)
if err != nil {
return err
}
projectFlockID := req.ProjectFlockKandangId
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
@@ -11,9 +11,17 @@ import (
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
rFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/repositories"
rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories"
rNonstock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/repositories"
rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories"
sProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/services"
rProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories"
rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories"
rChickin "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories"
sChickin "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/services"
rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
sProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services"
rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
sRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services"
rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
@@ -28,9 +36,18 @@ type RecordingModule struct{}
func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
recordingRepo := rRecording.NewRecordingRepository(db)
projectFlockRepo := rProjectFlock.NewProjectflockRepository(db)
projectFlockKandangRepo := rProjectFlock.NewProjectFlockKandangRepository(db)
projectFlockPopulationRepo := rProjectFlock.NewProjectFlockPopulationRepository(db)
projectBudgetRepo := rProjectFlock.NewProjectBudgetRepository(db)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
flockRepo := rFlock.NewFlockRepository(db)
kandangRepo := rKandang.NewKandangRepository(db)
warehouseRepo := rWarehouse.NewWarehouseRepository(db)
nonstockRepo := rNonstock.NewNonstockRepository(db)
productRepo := rProduct.NewProductRepository(db)
chickinRepo := rChickin.NewChickinRepository(db)
chickinDetailRepo := rChickin.NewChickinDetailRepository(db)
stockAllocationRepo := commonRepo.NewStockAllocationRepository(db)
stockLogRepo := rStockLogs.NewStockLogRepository(db)
productionStandardRepo := rProductionStandard.NewProductionStandardRepository(db)
@@ -53,14 +70,30 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
ProductWarehouseID: "product_warehouse_id",
TotalQuantity: "total_qty",
TotalUsedQuantity: "total_used",
CreatedAt: "created_at",
CreatedAt: "(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_eggs.recording_id)",
},
OrderBy: []string{"created_at ASC", "id ASC"},
OrderBy: []string{"(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_eggs.recording_id) ASC", "id ASC"},
}); err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "already registered") {
panic(fmt.Sprintf("failed to register recording egg stockable workflow: %v", err))
}
}
if err := fifoService.RegisterStockable(fifo.StockableConfig{
Key: fifo.StockableKeyRecordingDepletion,
Table: "recording_depletions",
Columns: fifo.StockableColumns{
ID: "id",
ProductWarehouseID: "product_warehouse_id",
TotalQuantity: "qty",
TotalUsedQuantity: "total_used_qty",
CreatedAt: "(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_depletions.recording_id)",
},
OrderBy: []string{"(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_depletions.recording_id) ASC", "id ASC"},
}); err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "already registered") {
panic(fmt.Sprintf("failed to register recording depletion stockable workflow: %v", err))
}
}
if err := fifoService.RegisterUsable(fifo.UsableConfig{
Key: fifo.UsableKeyRecordingStock,
Table: "recording_stocks",
@@ -69,7 +102,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
ProductWarehouseID: "product_warehouse_id",
UsageQuantity: "usage_qty",
PendingQuantity: "pending_qty",
CreatedAt: "id",
CreatedAt: "(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_stocks.recording_id)",
},
}); err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "already registered") {
@@ -82,9 +115,9 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
Columns: fifo.UsableColumns{
ID: "id",
ProductWarehouseID: "source_product_warehouse_id",
UsageQuantity: "qty",
UsageQuantity: "usage_qty",
PendingQuantity: "pending_qty",
CreatedAt: "id",
CreatedAt: "(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_depletions.recording_id)",
},
ExcludedStockables: []fifo.StockableKey{
fifo.StockableKeyTransferToLayingIn,
@@ -104,9 +137,41 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowRecording, utils.RecordingApprovalSteps); err != nil {
panic(fmt.Sprintf("failed to register recording approval workflow: %v", err))
}
if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowProjectFlock, utils.ProjectFlockApprovalSteps); err != nil {
panic(fmt.Sprintf("failed to register project flock approval workflow: %v", err))
}
userRepo := rUser.NewUserRepository(db)
projectFlockService := sProjectFlock.NewProjectflockService(
projectFlockRepo,
flockRepo,
kandangRepo,
projectFlockKandangRepo,
warehouseRepo,
productWarehouseRepo,
projectBudgetRepo,
nonstockRepo,
projectFlockPopulationRepo,
recordingRepo,
approvalService,
validate,
)
chickinService := sChickin.NewChickinService(
chickinRepo,
kandangRepo,
warehouseRepo,
productWarehouseRepo,
productRepo,
projectFlockRepo,
projectFlockKandangRepo,
projectFlockPopulationRepo,
chickinDetailRepo,
validate,
fifoService,
)
recordingService := sRecording.NewRecordingService(
recordingRepo,
projectFlockKandangRepo,
@@ -117,6 +182,8 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
fifoService,
stockLogRepo,
productionStandardService,
projectFlockService,
chickinService,
validate,
)
userService := sUser.NewUserService(userRepo, validate)
@@ -17,8 +17,13 @@ type RecordingRepository interface {
repository.BaseRepository[entity.Recording]
WithRelations(db *gorm.DB) *gorm.DB
WithRelationsList(db *gorm.DB) *gorm.DB
ApplyListFilters(db *gorm.DB, search string, projectFlockKandangId uint) *gorm.DB
ApplyListCountFilters(db *gorm.DB, search string, projectFlockKandangId uint) *gorm.DB
ApplySearchFilters(db *gorm.DB, rawSearch string) *gorm.DB
GetAllWithFilters(ctx context.Context, offset, limit int, search string, projectFlockKandangId uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.Recording, int64, error)
GetLatestByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (*entity.Recording, error)
ListByProjectFlockKandangID(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, from *time.Time) ([]entity.Recording, error)
GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error)
CreateStocks(tx *gorm.DB, stocks []entity.RecordingStock) error
@@ -40,9 +45,13 @@ type RecordingRepository interface {
SumRecordingDepletions(tx *gorm.DB, recordingID uint) (float64, error)
GetCumulativeDepletionByProjectFlockKandangUntil(tx *gorm.DB, projectFlockKandangId uint, recordTime time.Time) (float64, error)
GetCumulativeDepletionByRecordingIDs(tx *gorm.DB, recordingIDs []uint) (map[uint]float64, error)
GetUniformityMeanBwByWeek(tx *gorm.DB, projectFlockKandangId uint, week int) (float64, bool, error)
FindPreviousRecording(tx *gorm.DB, projectFlockKandangId uint, currentDay int) (*entity.Recording, error)
GetPreviousTotalChickByRecordingIDs(tx *gorm.DB, recordingIDs []uint) (map[uint]*float64, error)
GetTotalChick(tx *gorm.DB, projectFlockKandangId uint) (int64, error)
GetRemainingPopulationByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint) (float64, error)
GetTotalChickByProjectFlockKandangIDs(tx *gorm.DB, projectFlockKandangIds []uint) (map[uint]int64, error)
GetTotalChickinByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint) (float64, error)
GetFeedUsageInGrams(tx *gorm.DB, recordingID uint) (float64, error)
GetEggSummaryByRecording(tx *gorm.DB, recordingID uint) (totalQty float64, totalWeightGrams float64, err error)
@@ -55,6 +64,10 @@ type RecordingRepository interface {
GetLatestAvgWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (avgWeight float64, err error)
GetTotalEggProductionWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeightKg float64, err error)
GetAverageTargetMetricsByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint, includeTargets bool) (RecordingTargetAverages, error)
ResyncProjectFlockPopulationUsage(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error
ValidateFeedProductWarehouses(ctx context.Context, ids []uint) (uint, error)
ValidateEggProductWarehouses(ctx context.Context, ids []uint) (uint, error)
ValidateDepletionProductWarehouses(ctx context.Context, ids []uint) (uint, error)
}
type RecordingRepositoryImpl struct {
@@ -113,6 +126,64 @@ func (r *RecordingRepositoryImpl) WithRelations(db *gorm.DB) *gorm.DB {
Preload("Eggs.ProductWarehouse.Warehouse.Location")
}
func (r *RecordingRepositoryImpl) WithRelationsList(db *gorm.DB) *gorm.DB {
return db.
Preload("CreatedUser").
Preload("ProjectFlockKandang").
Preload("ProjectFlockKandang.Kandang").
Preload("ProjectFlockKandang.Kandang.Location").
Preload("ProjectFlockKandang.ProjectFlock").
Preload("ProjectFlockKandang.ProjectFlock.ProductionStandard")
}
func (r *RecordingRepositoryImpl) ApplyListFilters(db *gorm.DB, search string, projectFlockKandangId uint) *gorm.DB {
db = r.WithRelationsList(db)
db = db.
Joins("JOIN project_flock_kandangs pfk ON pfk.id = recordings.project_flock_kandangs_id").
Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id")
if projectFlockKandangId != 0 {
db = db.Where("recordings.project_flock_kandangs_id = ?", projectFlockKandangId)
}
db = r.ApplySearchFilters(db, search)
return db.Order("recordings.record_datetime DESC").Order("recordings.created_at DESC")
}
func (r *RecordingRepositoryImpl) ApplyListCountFilters(db *gorm.DB, search string, projectFlockKandangId uint) *gorm.DB {
db = db.
Joins("JOIN project_flock_kandangs pfk ON pfk.id = recordings.project_flock_kandangs_id").
Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id")
if projectFlockKandangId != 0 {
db = db.Where("recordings.project_flock_kandangs_id = ?", projectFlockKandangId)
}
db = r.ApplySearchFilters(db, search)
return db
}
func (r *RecordingRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, search string, projectFlockKandangId uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.Recording, int64, error) {
var (
records []entity.Recording
total int64
)
countQ := r.ApplyListCountFilters(r.DB().WithContext(ctx).Model(&entity.Recording{}), search, projectFlockKandangId)
if modifier != nil {
countQ = modifier(countQ)
}
if err := countQ.Count(&total).Error; err != nil {
return nil, 0, err
}
listQ := r.ApplyListFilters(r.DB().WithContext(ctx).Model(&entity.Recording{}), search, projectFlockKandangId)
if modifier != nil {
listQ = modifier(listQ)
}
if err := listQ.Offset(offset).Limit(limit).Find(&records).Error; err != nil {
return nil, 0, err
}
return records, total, nil
}
func (r *RecordingRepositoryImpl) ApplySearchFilters(db *gorm.DB, rawSearch string) *gorm.DB {
normalized := strings.ToLower(strings.TrimSpace(rawSearch))
if normalized == "" {
@@ -170,6 +241,27 @@ func (r *RecordingRepositoryImpl) GetLatestByProjectFlockKandangID(ctx context.C
return &record, nil
}
func (r *RecordingRepositoryImpl) ListByProjectFlockKandangID(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, from *time.Time) ([]entity.Recording, error) {
if projectFlockKandangId == 0 {
return nil, errors.New("project_flock_kandang_id is required")
}
db := tx.WithContext(ctx).
Model(&entity.Recording{}).
Where("project_flock_kandangs_id = ?", projectFlockKandangId).
Where("deleted_at IS NULL")
if from != nil {
db = db.Where("record_datetime >= ?", *from)
}
var records []entity.Recording
if err := db.Order("record_datetime ASC").Order("created_at ASC").Find(&records).Error; err != nil {
return nil, err
}
return records, nil
}
func (r *RecordingRepositoryImpl) GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error) {
var days []int
if err := tx.Model(&entity.Recording{}).
@@ -332,6 +424,41 @@ func (r *RecordingRepositoryImpl) GetCumulativeDepletionByProjectFlockKandangUnt
return total, err
}
func (r *RecordingRepositoryImpl) GetCumulativeDepletionByRecordingIDs(tx *gorm.DB, recordingIDs []uint) (map[uint]float64, error) {
result := make(map[uint]float64)
if len(recordingIDs) == 0 {
return result, nil
}
type row struct {
RecordingID uint `gorm:"column:recording_id"`
Total float64 `gorm:"column:total_qty"`
}
var rows []row
err := tx.
Table("recordings r").
Select("r.id AS recording_id, COALESCE(SUM(rd.qty), 0) AS total_qty").
Joins(`
LEFT JOIN recordings r2
ON r2.project_flock_kandangs_id = r.project_flock_kandangs_id
AND r2.record_datetime <= r.record_datetime
AND r2.deleted_at IS NULL`).
Joins("LEFT JOIN recording_depletions rd ON rd.recording_id = r2.id").
Where("r.id IN ?", recordingIDs).
Where("r.deleted_at IS NULL").
Group("r.id").
Scan(&rows).Error
if err != nil {
return nil, err
}
for _, row := range rows {
result[row.RecordingID] = row.Total
}
return result, nil
}
func (r *RecordingRepositoryImpl) GetUniformityMeanBwByWeek(tx *gorm.DB, projectFlockKandangId uint, week int) (float64, bool, error) {
if projectFlockKandangId == 0 || week <= 0 {
return 0, false, nil
@@ -381,6 +508,46 @@ func (r *RecordingRepositoryImpl) FindPreviousRecording(tx *gorm.DB, projectFloc
return &prev, nil
}
func (r *RecordingRepositoryImpl) GetPreviousTotalChickByRecordingIDs(tx *gorm.DB, recordingIDs []uint) (map[uint]*float64, error) {
result := make(map[uint]*float64)
if len(recordingIDs) == 0 {
return result, nil
}
type row struct {
RecordingID uint `gorm:"column:recording_id"`
PrevTotalChickQty *float64 `gorm:"column:prev_total_chick_qty"`
}
var rows []row
err := tx.
Table("recordings r").
Select(`
r.id AS recording_id,
(
SELECT r2.total_chick_qty
FROM recordings r2
WHERE r2.project_flock_kandangs_id = r.project_flock_kandangs_id
AND r2.day IS NOT NULL
AND r.day IS NOT NULL
AND r2.day < r.day
AND r2.deleted_at IS NULL
ORDER BY r2.day DESC
LIMIT 1
) AS prev_total_chick_qty`).
Where("r.id IN ?", recordingIDs).
Where("r.deleted_at IS NULL").
Scan(&rows).Error
if err != nil {
return nil, err
}
for _, row := range rows {
result[row.RecordingID] = row.PrevTotalChickQty
}
return result, nil
}
func (r *RecordingRepositoryImpl) GetTotalChick(tx *gorm.DB, projectFlockKandangId uint) (int64, error) {
var total float64
err := tx.
@@ -400,6 +567,57 @@ func (r *RecordingRepositoryImpl) GetTotalChick(tx *gorm.DB, projectFlockKandang
return int64(math.Round(total)), nil
}
func (r *RecordingRepositoryImpl) GetRemainingPopulationByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint) (float64, error) {
var total float64
err := tx.
Table("project_flock_populations").
Select("COALESCE(SUM(project_flock_populations.total_qty - project_flock_populations.total_used_qty), 0) AS total_qty").
Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id").
Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangId).
Scan(&total).Error
if err != nil {
return 0, err
}
if total < 0 {
total = 0
}
return total, nil
}
func (r *RecordingRepositoryImpl) GetTotalChickByProjectFlockKandangIDs(tx *gorm.DB, projectFlockKandangIds []uint) (map[uint]int64, error) {
result := make(map[uint]int64)
if len(projectFlockKandangIds) == 0 {
return result, nil
}
type row struct {
ProjectFlockKandangId uint `gorm:"column:project_flock_kandang_id"`
Total float64 `gorm:"column:total_qty"`
}
var rows []row
err := tx.
Table("project_flock_populations pfp").
Select("project_chickins.project_flock_kandang_id, COALESCE(SUM(pfp.total_qty - pfp.total_used_qty), 0) AS total_qty").
Joins("JOIN project_chickins ON project_chickins.id = pfp.project_chickin_id").
Where("project_chickins.project_flock_kandang_id IN ?", projectFlockKandangIds).
Group("project_chickins.project_flock_kandang_id").
Scan(&rows).Error
if err != nil {
return nil, err
}
for _, row := range rows {
total := math.Round(row.Total)
if total < 0 {
total = 0
}
result[row.ProjectFlockKandangId] = int64(total)
}
return result, nil
}
func (r *RecordingRepositoryImpl) GetTotalChickinByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint) (float64, error) {
if projectFlockKandangId == 0 {
return 0, nil
@@ -585,6 +803,130 @@ func (r *RecordingRepositoryImpl) GetAverageTargetMetricsByProjectFlockKandangID
return result, nil
}
func (r *RecordingRepositoryImpl) ResyncProjectFlockPopulationUsage(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error {
if projectFlockKandangID == 0 {
return nil
}
idsSubquery := `
SELECT pfp.id
FROM project_flock_populations pfp
JOIN project_chickins pc ON pc.id = pfp.project_chickin_id
WHERE pc.project_flock_kandang_id = ?
`
updateWithAlloc := `
UPDATE project_flock_populations p
SET total_used_qty = COALESCE(a.used, 0)
FROM (
SELECT stockable_id, SUM(qty) AS used
FROM stock_allocations
WHERE stockable_type = 'PROJECT_FLOCK_POPULATION'
AND status = 'ACTIVE'
GROUP BY stockable_id
) a
WHERE p.id = a.stockable_id
AND p.id IN (` + idsSubquery + `)
`
resetMissing := `
UPDATE project_flock_populations p
SET total_used_qty = 0
WHERE p.id IN (` + idsSubquery + `)
AND NOT EXISTS (
SELECT 1
FROM stock_allocations sa
WHERE sa.stockable_type = 'PROJECT_FLOCK_POPULATION'
AND sa.status = 'ACTIVE'
AND sa.stockable_id = p.id
)
`
db := r.DB().WithContext(ctx)
if tx != nil {
db = tx.WithContext(ctx)
}
if err := db.Exec(updateWithAlloc, projectFlockKandangID).Error; err != nil {
return err
}
if err := db.Exec(resetMissing, projectFlockKandangID).Error; err != nil {
return err
}
return nil
}
func (r *RecordingRepositoryImpl) ValidateFeedProductWarehouses(ctx context.Context, ids []uint) (uint, error) {
if len(ids) == 0 {
return 0, nil
}
var invalidIDs []uint
if err := r.DB().WithContext(ctx).
Table("product_warehouses pw").
Where("pw.id IN ?", ids).
Where(`NOT EXISTS (
SELECT 1 FROM flags f
WHERE f.flagable_type = 'products'
AND f.flagable_id = pw.product_id
AND UPPER(f.name) = 'PAKAN'
)`).
Pluck("pw.id", &invalidIDs).Error; err != nil {
return 0, err
}
if len(invalidIDs) > 0 {
return invalidIDs[0], nil
}
return 0, nil
}
func (r *RecordingRepositoryImpl) ValidateEggProductWarehouses(ctx context.Context, ids []uint) (uint, error) {
if len(ids) == 0 {
return 0, nil
}
eggFlags := []string{"TELUR-UTUH", "TELUR-PECAH", "TELUR-PUTIH", "TELUR-RETAK", "TELUR"}
var invalidIDs []uint
if err := r.DB().WithContext(ctx).
Table("product_warehouses pw").
Where("pw.id IN ?", ids).
Where(`NOT EXISTS (
SELECT 1 FROM flags f
WHERE f.flagable_type = 'products'
AND f.flagable_id = pw.product_id
AND UPPER(f.name) IN ?
)`, eggFlags).
Pluck("pw.id", &invalidIDs).Error; err != nil {
return 0, err
}
if len(invalidIDs) > 0 {
return invalidIDs[0], nil
}
return 0, nil
}
func (r *RecordingRepositoryImpl) ValidateDepletionProductWarehouses(ctx context.Context, ids []uint) (uint, error) {
if len(ids) == 0 {
return 0, nil
}
ayamFlags := []string{"AYAM-AFKIR", "AYAM-CULLING", "AYAM-MATI"}
var invalidIDs []uint
if err := r.DB().WithContext(ctx).
Table("product_warehouses pw").
Where("pw.id IN ?", ids).
Where(`NOT EXISTS (
SELECT 1 FROM flags f
WHERE f.flagable_type = 'products'
AND f.flagable_id = pw.product_id
AND UPPER(f.name) IN ?
)`, ayamFlags).
Pluck("pw.id", &invalidIDs).Error; err != nil {
return 0, err
}
if len(invalidIDs) > 0 {
return invalidIDs[0], nil
}
return 0, nil
}
func nextRecordingDay(days []int) int {
if len(days) == 0 {
return 1
File diff suppressed because it is too large Load Diff
@@ -3,43 +3,92 @@ package service
import (
"context"
"errors"
"fmt"
"math"
"strings"
"time"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations"
rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
recordingutil "gitlab.com/mbugroup/lti-api.git/internal/utils/recording"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
type RecordingFIFOIntegrationService interface {
ConsumeRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock, note string, actorID uint) error
ReleaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock, note string, actorID uint) error
}
var recordingStockUsableKey = fifo.UsableKeyRecordingStock
var recordingDepletionUsableKey = fifo.UsableKeyRecordingDepletion
func NewRecordingFIFOIntegrationService(
repo repository.RecordingRepository,
productWarehouseRepo rProductWarehouse.ProductWarehouseRepository,
fifoSvc commonSvc.FifoService,
stockLogRepo rStockLogs.StockLogRepository,
) RecordingFIFOIntegrationService {
return &recordingService{
Log: utils.Log,
Repository: repo,
ProductWarehouseRepo: productWarehouseRepo,
FifoSvc: fifoSvc,
StockLogRepo: stockLogRepo,
const depletionUsageTolerance = 0.000001
func (s *recordingService) logStockTrace(action string, stock entity.RecordingStock, extra string) {
if s == nil || s.Log == nil {
return
}
usage := 0.0
if stock.UsageQty != nil {
usage = *stock.UsageQty
}
pending := 0.0
if stock.PendingQty != nil {
pending = *stock.PendingQty
}
s.Log.Infof(
"[recording-stock] action=%s recording_id=%d stock_id=%d pw=%d usage=%.3f pending=%.3f %s",
action,
stock.RecordingId,
stock.Id,
stock.ProductWarehouseId,
usage,
pending,
extra,
)
}
func (s *recordingService) logEggTrace(action string, egg entity.RecordingEgg, extra string) {
if s == nil || s.Log == nil {
return
}
weight := 0.0
if egg.Weight != nil {
weight = *egg.Weight
}
s.Log.Infof(
"[recording-egg] action=%s recording_id=%d egg_id=%d pw=%d qty=%d weight=%.3f total_qty=%.3f total_used=%.3f %s",
action,
egg.RecordingId,
egg.Id,
egg.ProductWarehouseId,
egg.Qty,
weight,
egg.TotalQty,
egg.TotalUsed,
extra,
)
}
func (s *recordingService) logDepletionTrace(action string, dep entity.RecordingDepletion, extra string) {
if s == nil || s.Log == nil {
return
}
sourceWarehouseID := uint(0)
if dep.SourceProductWarehouseId != nil {
sourceWarehouseID = *dep.SourceProductWarehouseId
}
s.Log.Infof(
"[recording-depletion] action=%s recording_id=%d depletion_id=%d source_pw=%d dest_pw=%d qty=%.3f usage=%.3f pending=%.3f %s",
action,
dep.RecordingId,
dep.Id,
sourceWarehouseID,
dep.ProductWarehouseId,
dep.Qty,
dep.UsageQty,
dep.PendingQty,
extra,
)
}
func (s *recordingService) consumeRecordingStocks(
@@ -49,9 +98,13 @@ func (s *recordingService) consumeRecordingStocks(
note string,
actorID uint,
) error {
if len(stocks) == 0 || s.FifoSvc == nil {
if len(stocks) == 0 {
return nil
}
if s.FifoSvc == nil {
s.Log.Errorf("FIFO service is not available for consuming recording stocks")
return errors.New("fifo service is not available")
}
if strings.TrimSpace(note) != "" && s.StockLogRepo == nil {
return errors.New("stock log repository is not available")
}
@@ -60,6 +113,7 @@ func (s *recordingService) consumeRecordingStocks(
if stock.Id == 0 {
continue
}
s.logStockTrace("consume:start", stock, "")
var desired float64
if stock.UsageQty != nil {
@@ -87,6 +141,7 @@ func (s *recordingService) consumeRecordingStocks(
if err := s.Repository.UpdateStockUsage(tx, stock.Id, result.UsageQuantity, result.PendingQuantity); err != nil {
return err
}
s.logStockTrace("consume:done", stock, fmt.Sprintf("desired=%.3f used=%.3f pending=%.3f", desiredTotal, result.UsageQuantity, result.PendingQuantity))
logDecrease := result.UsageQuantity
if result.PendingQuantity > 0 {
@@ -129,9 +184,13 @@ func (s *recordingService) consumeRecordingDepletions(
note string,
actorID uint,
) error {
if len(depletions) == 0 || s.FifoSvc == nil {
if len(depletions) == 0 {
return nil
}
if s.FifoSvc == nil {
s.Log.Errorf("FIFO service is not available for consuming recording depletions")
return errors.New("fifo service is not available")
}
if strings.TrimSpace(note) != "" && s.StockLogRepo == nil {
return errors.New("stock log repository is not available")
}
@@ -140,6 +199,7 @@ func (s *recordingService) consumeRecordingDepletions(
if depletion.Id == 0 {
continue
}
s.logDepletionTrace("consume:start", depletion, "")
sourceWarehouseID := uint(0)
if depletion.SourceProductWarehouseId != nil {
@@ -166,6 +226,7 @@ func (s *recordingService) consumeRecordingDepletions(
if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, result.PendingQuantity); err != nil {
return err
}
s.logDepletionTrace("consume:done", depletion, fmt.Sprintf("desired=%.3f used=%.3f pending=%.3f", desired, result.UsageQuantity, result.PendingQuantity))
logDecrease := result.UsageQuantity
if result.PendingQuantity > 0 {
@@ -231,16 +292,6 @@ func (s *recordingService) consumeRecordingDepletions(
return nil
}
func (s *recordingService) ConsumeRecordingStocks(
ctx context.Context,
tx *gorm.DB,
stocks []entity.RecordingStock,
note string,
actorID uint,
) error {
return s.consumeRecordingStocks(ctx, tx, stocks, note, actorID)
}
func (s *recordingService) releaseRecordingStocks(
ctx context.Context,
tx *gorm.DB,
@@ -248,9 +299,13 @@ func (s *recordingService) releaseRecordingStocks(
note string,
actorID uint,
) error {
if len(stocks) == 0 || s.FifoSvc == nil {
if len(stocks) == 0 {
return nil
}
if s.FifoSvc == nil {
s.Log.Errorf("FIFO service is not available for releasing recording stocks")
return errors.New("fifo service is not available")
}
if strings.TrimSpace(note) != "" && s.StockLogRepo == nil {
return errors.New("stock log repository is not available")
}
@@ -259,6 +314,26 @@ func (s *recordingService) releaseRecordingStocks(
if stock.Id == 0 {
continue
}
if stock.UsageQty != nil && *stock.UsageQty > 0 {
activeCount, err := s.countActiveAllocations(ctx, tx, fifo.UsableKeyRecordingStock, stock.Id)
if err != nil {
return err
}
if activeCount == 0 {
s.Log.Warnf("recording-stock release: no active allocations, forcing usage/pending to 0 (stock_id=%d)", stock.Id)
if err := s.Repository.UpdateStockUsage(tx, stock.Id, 0, 0); err != nil {
return err
}
continue
}
if err := s.resyncStockableUsageFromAllocations(ctx, tx, fifo.UsableKeyRecordingStock, stock.Id); err != nil {
return err
}
if err := s.ensureActiveAllocations(ctx, tx, fifo.UsableKeyRecordingStock, stock.Id); err != nil {
return err
}
}
s.logStockTrace("release:start", stock, "")
if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{
UsableKey: recordingStockUsableKey,
UsableID: stock.Id,
@@ -271,6 +346,7 @@ func (s *recordingService) releaseRecordingStocks(
if err := s.Repository.UpdateStockUsage(tx, stock.Id, 0, 0); err != nil {
return err
}
s.logStockTrace("release:done", stock, "")
if stock.UsageQty != nil && *stock.UsageQty > 0 && strings.TrimSpace(note) != "" && actorID != 0 {
log := &entity.StockLog{
@@ -309,9 +385,13 @@ func (s *recordingService) releaseRecordingDepletions(
note string,
actorID uint,
) error {
if len(depletions) == 0 || s.FifoSvc == nil {
if len(depletions) == 0 {
return nil
}
if s.FifoSvc == nil {
s.Log.Errorf("FIFO service is not available for releasing recording depletions")
return errors.New("fifo service is not available")
}
if strings.TrimSpace(note) != "" && s.StockLogRepo == nil {
return errors.New("stock log repository is not available")
}
@@ -320,6 +400,36 @@ func (s *recordingService) releaseRecordingDepletions(
if depletion.Id == 0 {
continue
}
if depletion.UsageQty > 0 {
activeCount, err := s.countActiveAllocations(ctx, tx, fifo.UsableKeyRecordingDepletion, depletion.Id)
if err != nil {
return err
}
if activeCount == 0 {
s.Log.Warnf("recording-depletion release: no active allocations, forcing usage/pending to 0 (depletion_id=%d)", depletion.Id)
if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, 0); err != nil {
return err
}
if err := tx.WithContext(ctx).
Table("recording_depletions").
Where("id = ?", depletion.Id).
Update("usage_qty", 0).Error; err != nil {
return err
}
continue
}
if err := s.resyncStockableUsageFromAllocations(ctx, tx, fifo.UsableKeyRecordingDepletion, depletion.Id); err != nil {
return err
}
if err := s.ensureActiveAllocations(ctx, tx, fifo.UsableKeyRecordingDepletion, depletion.Id); err != nil {
return err
}
}
s.logDepletionTrace("release:start", depletion, "")
if err := validateDepletionUsage(depletion); err != nil {
s.Log.Errorf("FIFO depletion mismatch for recording %d (depletion %d): qty=%.3f usage=%.3f pending=%.3f", depletion.RecordingId, depletion.Id, depletion.Qty, depletion.UsageQty, depletion.PendingQty)
return err
}
sourceWarehouseID := uint(0)
if depletion.SourceProductWarehouseId != nil {
@@ -340,6 +450,7 @@ func (s *recordingService) releaseRecordingDepletions(
if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, 0); err != nil {
return err
}
s.logDepletionTrace("release:done", depletion, "")
logIncrease := depletion.Qty
if depletion.PendingQty > 0 {
@@ -405,14 +516,15 @@ func (s *recordingService) releaseRecordingDepletions(
return nil
}
func (s *recordingService) ReleaseRecordingStocks(
ctx context.Context,
tx *gorm.DB,
stocks []entity.RecordingStock,
note string,
actorID uint,
) error {
return s.releaseRecordingStocks(ctx, tx, stocks, note, actorID)
func validateDepletionUsage(depletion entity.RecordingDepletion) error {
desired := depletion.Qty + depletion.PendingQty
if math.Abs(depletion.UsageQty-desired) <= depletionUsageTolerance {
return nil
}
return fiber.NewError(
fiber.StatusConflict,
fmt.Sprintf("FIFO depletion mismatch (id=%d): qty=%.3f usage=%.3f pending=%.3f", depletion.Id, depletion.Qty, depletion.UsageQty, depletion.PendingQty),
)
}
func (s *recordingService) logRecordingEggUsage(
@@ -503,9 +615,13 @@ func (s *recordingService) replenishRecordingEggs(
note string,
actorID uint,
) error {
if len(eggs) == 0 || s.FifoSvc == nil {
if len(eggs) == 0 {
return nil
}
if s.FifoSvc == nil {
s.Log.Errorf("FIFO service is not available for replenishing recording eggs")
return errors.New("fifo service is not available")
}
if strings.TrimSpace(note) != "" && s.StockLogRepo == nil {
return errors.New("stock log repository is not available")
}
@@ -514,6 +630,7 @@ func (s *recordingService) replenishRecordingEggs(
if egg.Id == 0 || egg.ProductWarehouseId == 0 || egg.Qty <= 0 {
continue
}
s.logEggTrace("replenish:start", egg, "")
if _, err := s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{
StockableKey: fifo.StockableKeyRecordingEgg,
StockableID: egg.Id,
@@ -524,6 +641,7 @@ func (s *recordingService) replenishRecordingEggs(
s.Log.Errorf("Failed to replenish FIFO stock for recording egg %d: %+v", egg.Id, err)
return err
}
s.logEggTrace("replenish:done", egg, "")
if strings.TrimSpace(note) != "" && actorID != 0 {
log := &entity.StockLog{
@@ -555,6 +673,210 @@ func (s *recordingService) replenishRecordingEggs(
return nil
}
func (s *recordingService) replenishRecordingDepletions(
ctx context.Context,
tx *gorm.DB,
depletions []entity.RecordingDepletion,
) error {
if len(depletions) == 0 {
return nil
}
if s.FifoSvc == nil {
s.Log.Errorf("FIFO service is not available for replenishing recording depletions")
return errors.New("fifo service is not available")
}
for _, depletion := range depletions {
if depletion.Id == 0 || depletion.ProductWarehouseId == 0 || depletion.Qty <= 0 {
continue
}
s.logDepletionTrace("replenish:start", depletion, "")
if _, err := s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{
StockableKey: fifo.StockableKeyRecordingDepletion,
StockableID: depletion.Id,
ProductWarehouseID: depletion.ProductWarehouseId,
Quantity: depletion.Qty,
Tx: tx,
}); err != nil {
s.Log.Errorf("Failed to replenish FIFO stock for recording depletion %d: %+v", depletion.Id, err)
return err
}
s.logDepletionTrace("replenish:done", depletion, "")
}
return nil
}
func (s *recordingService) reduceRecordingDepletions(
ctx context.Context,
tx *gorm.DB,
depletions []entity.RecordingDepletion,
) error {
if len(depletions) == 0 {
return nil
}
if s.FifoSvc == nil {
s.Log.Errorf("FIFO service is not available for reducing recording depletions")
return errors.New("fifo service is not available")
}
for _, depletion := range depletions {
if depletion.Id == 0 || depletion.ProductWarehouseId == 0 || depletion.Qty <= 0 {
continue
}
s.logDepletionTrace("reduce:start", depletion, "")
if err := s.FifoSvc.AdjustStockableQuantity(ctx, commonSvc.StockAdjustRequest{
StockableKey: fifo.StockableKeyRecordingDepletion,
StockableID: depletion.Id,
ProductWarehouseID: depletion.ProductWarehouseId,
Quantity: -depletion.Qty,
Tx: tx,
}); err != nil {
s.Log.Errorf("Failed to reduce FIFO stock for recording depletion %d: %+v", depletion.Id, err)
return err
}
s.logDepletionTrace("reduce:done", depletion, "")
}
return nil
}
func (s *recordingService) reduceRecordingEggs(
ctx context.Context,
tx *gorm.DB,
eggs []entity.RecordingEgg,
) error {
if len(eggs) == 0 {
return nil
}
if s.FifoSvc == nil {
s.Log.Errorf("FIFO service is not available for reducing recording eggs")
return errors.New("fifo service is not available")
}
for _, egg := range eggs {
if egg.Id == 0 || egg.ProductWarehouseId == 0 || egg.Qty <= 0 {
continue
}
s.logEggTrace("reduce:start", egg, "")
if err := s.FifoSvc.AdjustStockableQuantity(ctx, commonSvc.StockAdjustRequest{
StockableKey: fifo.StockableKeyRecordingEgg,
StockableID: egg.Id,
ProductWarehouseID: egg.ProductWarehouseId,
Quantity: -float64(egg.Qty),
Tx: tx,
}); err != nil {
s.Log.Errorf("Failed to reduce FIFO stock for recording egg %d: %+v", egg.Id, err)
return err
}
s.logEggTrace("reduce:done", egg, "")
}
return nil
}
func (s *recordingService) ensureActiveAllocations(
ctx context.Context,
tx *gorm.DB,
usableKey fifo.UsableKey,
usableID uint,
) error {
if usableID == 0 {
return nil
}
var count int64
if err := tx.WithContext(ctx).
Model(&entity.StockAllocation{}).
Where("usable_type = ? AND usable_id = ? AND status = ?", usableKey, usableID, entity.StockAllocationStatusActive).
Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusConflict, fmt.Sprintf("no active allocations for usable %s id=%d", usableKey, usableID))
}
return nil
}
func (s *recordingService) countActiveAllocations(
ctx context.Context,
tx *gorm.DB,
usableKey fifo.UsableKey,
usableID uint,
) (int64, error) {
if usableID == 0 {
return 0, nil
}
var count int64
if err := tx.WithContext(ctx).
Model(&entity.StockAllocation{}).
Where("usable_type = ? AND usable_id = ? AND status = ?", usableKey, usableID, entity.StockAllocationStatusActive).
Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (s *recordingService) resyncStockableUsageFromAllocations(
ctx context.Context,
tx *gorm.DB,
usableKey fifo.UsableKey,
usableID uint,
) error {
if usableID == 0 {
return nil
}
type stockableRef struct {
StockableType string
StockableID uint
}
var refs []stockableRef
if err := tx.WithContext(ctx).
Model(&entity.StockAllocation{}).
Select("stockable_type, stockable_id").
Where("usable_type = ? AND usable_id = ? AND status = ?", usableKey, usableID, entity.StockAllocationStatusActive).
Group("stockable_type, stockable_id").
Scan(&refs).Error; err != nil {
return err
}
if len(refs) == 0 {
return nil
}
for _, ref := range refs {
var total float64
if err := tx.WithContext(ctx).
Model(&entity.StockAllocation{}).
Select("COALESCE(SUM(qty),0)").
Where("stockable_type = ? AND stockable_id = ? AND status = ?", ref.StockableType, ref.StockableID, entity.StockAllocationStatusActive).
Scan(&total).Error; err != nil {
return err
}
switch ref.StockableType {
case string(fifo.StockableKeyProjectFlockPopulation):
if err := tx.WithContext(ctx).
Table("project_flock_populations").
Where("id = ?", ref.StockableID).
Update("total_used_qty", total).Error; err != nil {
return err
}
case string(fifo.StockableKeyPurchaseItems):
if err := tx.WithContext(ctx).
Table("purchase_items").
Where("id = ?", ref.StockableID).
Update("total_used", total).Error; err != nil {
return err
}
default:
// no-op for other stockables
}
}
return nil
}
type desiredStock struct {
Usage float64
Pending float64
@@ -565,7 +887,7 @@ type desiredDepletion struct {
Pending float64
}
func resetStockQuantitiesForFIFO(stocks []entity.RecordingStock, enabled bool) []desiredStock {
func resetStockQuantitiesForFIFO(stocks []entity.RecordingStock) []desiredStock {
desired := make([]desiredStock, len(stocks))
for i := range stocks {
if stocks[i].UsageQty != nil {
@@ -574,9 +896,6 @@ func resetStockQuantitiesForFIFO(stocks []entity.RecordingStock, enabled bool) [
if stocks[i].PendingQty != nil {
desired[i].Pending = *stocks[i].PendingQty
}
if !enabled {
continue
}
zero := 0.0
stocks[i].UsageQty = &zero
stocks[i].PendingQty = &zero
@@ -584,39 +903,19 @@ func resetStockQuantitiesForFIFO(stocks []entity.RecordingStock, enabled bool) [
return desired
}
func applyStockDesiredQuantities(stocks []entity.RecordingStock, desired []desiredStock, enabled bool) {
if !enabled {
return
}
for i := range stocks {
if i >= len(desired) {
break
}
usage := desired[i].Usage
pending := desired[i].Pending
stocks[i].UsageQty = &usage
stocks[i].PendingQty = &pending
}
}
func resetDepletionQuantitiesForFIFO(depletions []entity.RecordingDepletion, enabled bool) []desiredDepletion {
func resetDepletionQuantitiesForFIFO(depletions []entity.RecordingDepletion) []desiredDepletion {
desired := make([]desiredDepletion, len(depletions))
for i := range depletions {
desired[i].Qty = depletions[i].Qty
desired[i].Pending = depletions[i].PendingQty
if !enabled {
continue
}
depletions[i].Qty = 0
depletions[i].UsageQty = 0
depletions[i].PendingQty = 0
}
return desired
}
func applyDepletionDesiredQuantities(depletions []entity.RecordingDepletion, desired []desiredDepletion, enabled bool) {
if !enabled {
return
}
func applyDepletionDesiredQuantities(depletions []entity.RecordingDepletion, desired []desiredDepletion) {
for i := range depletions {
if i >= len(desired) {
break
@@ -636,11 +935,8 @@ func (s *recordingService) syncRecordingStocks(
actorID uint,
) error {
if s.FifoSvc == nil {
if err := s.Repository.DeleteStocks(tx, recordingID); err != nil {
return err
}
mapped := recordingutil.MapStocks(recordingID, incoming)
return s.Repository.CreateStocks(tx, mapped)
s.Log.Errorf("FIFO service is not available for syncing recording stocks")
return errors.New("fifo service is not available")
}
existingByWarehouse := make(map[uint][]entity.RecordingStock)
@@ -701,3 +997,137 @@ func (s *recordingService) syncRecordingStocks(
}
return s.consumeRecordingStocks(ctx, tx, stocksToConsume, note, actorID)
}
func sumDepletionQty(items []entity.RecordingDepletion) float64 {
var total float64
for _, item := range items {
if item.Qty > 0 {
total += item.Qty
}
}
return total
}
func (s *recordingService) ensureDepletionWithinPopulation(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, newTotal float64, existingTotal float64) error {
if projectFlockKandangId == 0 || newTotal <= 0 {
return nil
}
totalChick, err := s.Repository.GetTotalChick(tx, projectFlockKandangId)
if err != nil {
return err
}
// totalChick already reflects existing depletions; add them back to compare the delta.
available := float64(totalChick) + existingTotal
if newTotal > available {
return fiber.NewError(fiber.StatusBadRequest, "Depletion melebihi populasi yang tersedia")
}
return nil
}
func ensureRecordingEggsUnused(eggs []entity.RecordingEgg) error {
for _, egg := range eggs {
if egg.TotalUsed > 0 {
return fiber.NewError(fiber.StatusBadRequest, "Recording egg sudah digunakan sehingga tidak dapat diubah")
}
}
return nil
}
func (s *recordingService) resolvePopulationWarehouseID(ctx context.Context, projectFlockKandangID uint) (uint, error) {
if projectFlockKandangID == 0 {
return 0, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid")
}
populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(ctx, projectFlockKandangID)
if err != nil {
s.Log.Errorf("Failed to fetch populations for project_flock_kandang_id=%d: %+v", projectFlockKandangID, err)
return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data populasi")
}
for _, pop := range populations {
if pop.ProductWarehouseId > 0 && pop.TotalQty > 0 {
return pop.ProductWarehouseId, nil
}
}
for _, pop := range populations {
if pop.ProductWarehouseId > 0 {
return pop.ProductWarehouseId, nil
}
}
return 0, fiber.NewError(fiber.StatusBadRequest, "Source product warehouse populasi tidak ditemukan")
}
func (s *recordingService) recalculateFrom(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, from time.Time) error {
if tx == nil || projectFlockKandangId == 0 || from.IsZero() {
return nil
}
fromUTC := from.UTC()
records, err := s.Repository.ListByProjectFlockKandangID(ctx, tx, projectFlockKandangId, &fromUTC)
if err != nil {
return err
}
for i := range records {
if err := s.computeAndUpdateMetrics(ctx, tx, &records[i]); err != nil {
return err
}
}
return nil
}
func (s *recordingService) rollbackRecordingInventory(ctx context.Context, tx *gorm.DB, recordingID uint, note string, actorID uint) error {
if recordingID == 0 || tx == nil {
return nil
}
if err := s.requireFIFO(); err != nil {
return err
}
oldDepletions, err := s.Repository.ListDepletions(tx, recordingID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Failed to list depletions: %+v", err)
return err
}
oldEggs, err := s.Repository.ListEggs(tx, recordingID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Failed to list eggs: %+v", err)
return err
}
if err := ensureRecordingEggsUnused(oldEggs); err != nil {
return err
}
if err := s.releaseRecordingDepletions(ctx, tx, oldDepletions, note, actorID); err != nil {
return err
}
oldStocks, err := s.Repository.ListStocks(tx, recordingID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Failed to list stocks: %+v", err)
return err
}
if err := s.releaseRecordingStocks(ctx, tx, oldStocks, note, actorID); err != nil {
return err
}
if err := s.reduceRecordingDepletions(ctx, tx, oldDepletions); err != nil {
return err
}
if err := s.reduceRecordingEggs(ctx, tx, oldEggs); err != nil {
return err
}
if err := s.logRecordingEggRollback(ctx, tx, oldEggs, note, actorID); err != nil {
return err
}
return nil
}
func (s *recordingService) requireFIFO() error {
if s.FifoSvc == nil {
s.Log.Errorf("FIFO service is not available for recording operations")
return fiber.NewError(fiber.StatusInternalServerError, "FIFO service is required for recording operations")
}
return nil
}
@@ -1,5 +1,7 @@
package validation
import "time"
type (
Stock struct {
ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"`
@@ -35,6 +37,7 @@ type Update struct {
type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
Offset int `query:"-" validate:"omitempty,number,min=0"`
ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"`
Search string `query:"search" validate:"omitempty,max=50"`
}
@@ -44,3 +47,9 @@ type Approve struct {
ApprovableIds []uint `json:"approvable_ids" validate:"required_strict,min=1,dive,gt=0"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
}
type GetRecordingNextDay struct {
ProjectFlockKandangId uint `json:"project_flock_kandang_id" query:"project_flock_kandang_id" validate:"required,number,min=1"`
RecordTime *string `json:"record_date" query:"record_date" validate:"required,datetime=2006-01-02"`
RecordTimeValue *time.Time `query:"-" validate:"-"`
}