mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 21:41:55 +00:00
Merge branch 'development' into 'production'
fix: resolve dashboard OpenAPI integration issues See merge request mbugroup/lti-api!498
This commit is contained in:
@@ -207,6 +207,7 @@ const (
|
||||
P_Finances_Transaction_DeleteOne = "lti.finance.transactions.delete"
|
||||
)
|
||||
const (
|
||||
P_ChickinsGetAll = "lti.production.chickins.list"
|
||||
P_ChickinsCreateOne = "lti.production.chickins.create"
|
||||
P_ChickinsGetOne = "lti.production.chickins.detail"
|
||||
P_ChickinsApproval = "lti.production.chickins.approve"
|
||||
|
||||
@@ -238,17 +238,17 @@ func (u *DailyChecklistController) GetReport(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
result, totalResults, err := u.DailyChecklistService.GetReport(c, query)
|
||||
withoutActivities := func(src map[string]int) map[string]int {
|
||||
if src == nil {
|
||||
return map[string]int{}
|
||||
}
|
||||
return src
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
withoutActivities := func(src map[string]any) map[string]any {
|
||||
if src == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return src
|
||||
}
|
||||
|
||||
responseData := make([]dto.DailyChecklistReportDTO, len(result))
|
||||
for i, item := range result {
|
||||
responseData[i] = dto.DailyChecklistReportDTO{
|
||||
|
||||
@@ -72,13 +72,14 @@ type DailyChecklistPerformanceOverviewDTO struct {
|
||||
ActivityLeft int `json:"activity_left"`
|
||||
}
|
||||
|
||||
|
||||
type DailyChecklistReportDTO struct {
|
||||
Area DailyChecklistReportEntityDTO `json:"area"`
|
||||
Farm DailyChecklistReportEntityDTO `json:"farm"`
|
||||
Kandang DailyChecklistReportEntityDTO `json:"kandang"`
|
||||
ABK DailyChecklistReportEntityDTO `json:"abk"`
|
||||
Phase string `json:"phase"`
|
||||
DailyActivities map[string]int `json:"daily_activities"`
|
||||
DailyActivities map[string]any `json:"daily_activities"`
|
||||
Summary DailyChecklistReportSummaryDTO `json:"summary"`
|
||||
}
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ type DailyChecklistReportItem struct {
|
||||
EmployeeID uint
|
||||
EmployeeName string
|
||||
PhaseName string
|
||||
DailyActivities map[string]int
|
||||
DailyActivities map[string]any
|
||||
Summary DailyChecklistReportSummary
|
||||
}
|
||||
|
||||
@@ -123,12 +123,13 @@ type DailyChecklistReportCategory struct {
|
||||
}
|
||||
|
||||
const (
|
||||
dailyChecklistDateLayout = "2006-01-02"
|
||||
dailyChecklistCategoryEmptyKandang = "empty_kandang"
|
||||
dailyChecklistStatusRejected = "REJECTED"
|
||||
dailyChecklistStatusDraft = "DRAFT"
|
||||
dailyChecklistErrEmptyKandangExist = "DailyChecklist cannot be created because empty_kandang already exists for at least one date in range"
|
||||
dailyChecklistErrDateOverlapExist = "DailyChecklist cannot be created because at least one date in range already has a checklist"
|
||||
dailyChecklistDateLayout = "2006-01-02"
|
||||
dailyChecklistCategoryEmptyKandang = "empty_kandang"
|
||||
dailyChecklistStatusRejected = "REJECTED"
|
||||
dailyChecklistStatusDraft = "DRAFT"
|
||||
dailyChecklistErrEmptyKandangExist = "DailyChecklist cannot be created because empty_kandang already exists for at least one date in range"
|
||||
dailyChecklistErrDateOverlapExist = "DailyChecklist cannot be created because at least one date in range already has a checklist"
|
||||
dailyChecklistErrDeletedNonEmptyKandangExists = "DailyChecklist cannot be created as empty_kandang because a deleted non-empty_kandang checklist exists for this date"
|
||||
)
|
||||
|
||||
func NewDailyChecklistService(repo repository.DailyChecklistRepository, phaseRepo phaseRepo.PhasesRepository, validate *validator.Validate, documentSvc commonSvc.DocumentService) DailyChecklistService {
|
||||
@@ -519,21 +520,8 @@ func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create)
|
||||
|
||||
status := req.Status
|
||||
category := req.Category
|
||||
endDate := date
|
||||
|
||||
if req.EmptyKandang {
|
||||
if strings.TrimSpace(req.EmptyKandangEndDate) == "" {
|
||||
return nil, fiber.NewError(fiber.StatusBadRequest, "empty_kandang_end_date is required when empty_kandang is true")
|
||||
}
|
||||
|
||||
endDate, err = time.Parse(dailyChecklistDateLayout, strings.TrimSpace(req.EmptyKandangEndDate))
|
||||
if err != nil {
|
||||
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid empty_kandang_end_date format, use YYYY-MM-DD")
|
||||
}
|
||||
if endDate.Before(date) {
|
||||
return nil, fiber.NewError(fiber.StatusBadRequest, "empty_kandang_end_date must be greater than or equal to date")
|
||||
}
|
||||
|
||||
category = dailyChecklistCategoryEmptyKandang
|
||||
}
|
||||
|
||||
@@ -544,15 +532,17 @@ func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create)
|
||||
return err
|
||||
}
|
||||
|
||||
if req.EmptyKandang {
|
||||
if err := s.validateNoChecklistOverlapForEmptyKandang(tx, req.KandangId, date, endDate); err != nil {
|
||||
if category == dailyChecklistCategoryEmptyKandang {
|
||||
if err := s.validateNoChecklistOverlapForEmptyKandang(tx, req.KandangId, date, date); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.validateNoDeletedNonEmptyKandangForDate(tx, req.KandangId, date); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := s.validateNoEmptyKandangConflict(tx, req.KandangId, date, date); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.createBulkDailyChecklists(tx, req.KandangId, date, endDate, category, status, &targetID)
|
||||
}
|
||||
|
||||
if err := s.validateNoEmptyKandangConflict(tx, req.KandangId, date, endDate); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.createOrReuseSingleDailyChecklist(tx, req.KandangId, date, category, status, &targetID)
|
||||
@@ -615,6 +605,22 @@ func (s *dailyChecklistService) validateNoEmptyKandangConflict(tx *gorm.DB, kand
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *dailyChecklistService) validateNoDeletedNonEmptyKandangForDate(tx *gorm.DB, kandangID uint, date time.Time) error {
|
||||
var conflictCount int64
|
||||
if err := tx.Model(&entity.DailyChecklist{}).
|
||||
Unscoped().
|
||||
Where("kandang_id = ? AND date = ? AND deleted_at IS NULL AND category != ?", kandangID, date, dailyChecklistCategoryEmptyKandang).
|
||||
Count(&conflictCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if conflictCount > 0 {
|
||||
return fiber.NewError(fiber.StatusBadRequest, dailyChecklistErrDeletedNonEmptyKandangExists)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *dailyChecklistService) createOrReuseSingleDailyChecklist(tx *gorm.DB, kandangID uint, date time.Time, category, status string, targetID *uint) error {
|
||||
existing := new(entity.DailyChecklist)
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
@@ -1659,7 +1665,7 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report
|
||||
|
||||
totalChecklist := 0
|
||||
categoryCounts := DailyChecklistReportCategory{}
|
||||
activityOutput := make(map[string]int, len(activities))
|
||||
activityOutput := make(map[string]any, len(activities))
|
||||
|
||||
for day, stat := range activities {
|
||||
activityOutput[day] = stat.Completed
|
||||
@@ -1717,5 +1723,109 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report
|
||||
}
|
||||
}
|
||||
|
||||
// Flag empty kandang days within this report month
|
||||
if len(kandangIDs) > 0 {
|
||||
firstDay := time.Date(params.Year, time.Month(params.Month), 1, 0, 0, 0, 0, time.UTC)
|
||||
lastDay := firstDay.AddDate(0, 1, 0).AddDate(0, 0, -1)
|
||||
today := time.Now().UTC().Truncate(24 * time.Hour)
|
||||
|
||||
type emptyKandangRec struct {
|
||||
KandangID uint
|
||||
Date time.Time
|
||||
}
|
||||
var emptyRecs []emptyKandangRec
|
||||
if err := s.Repository.DB().WithContext(c.Context()).
|
||||
Model(&entity.DailyChecklist{}).
|
||||
Where("kandang_id IN ? AND category = ? AND date <= ? AND deleted_at IS NULL",
|
||||
kandangIDs, dailyChecklistCategoryEmptyKandang, lastDay).
|
||||
Select("kandang_id, date").
|
||||
Scan(&emptyRecs).Error; err != nil {
|
||||
s.Log.Errorf("Failed to get empty kandang records for report: %+v", err)
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
emptyDaysByKandang := make(map[uint]map[int]struct{})
|
||||
|
||||
if len(emptyRecs) > 0 {
|
||||
minEmptyDate := emptyRecs[0].Date
|
||||
for _, rec := range emptyRecs[1:] {
|
||||
if rec.Date.Before(minEmptyDate) {
|
||||
minEmptyDate = rec.Date
|
||||
}
|
||||
}
|
||||
|
||||
type checklistDateRec struct {
|
||||
KandangID uint
|
||||
Date time.Time
|
||||
}
|
||||
var nextDates []checklistDateRec
|
||||
if err := s.Repository.DB().WithContext(c.Context()).
|
||||
Model(&entity.DailyChecklist{}).
|
||||
Where("kandang_id IN ? AND category != ? AND date > ? AND (status IS NULL OR status != ?) AND deleted_at IS NULL",
|
||||
kandangIDs, dailyChecklistCategoryEmptyKandang, minEmptyDate, dailyChecklistStatusRejected).
|
||||
Select("kandang_id, date").
|
||||
Order("kandang_id ASC, date ASC").
|
||||
Scan(&nextDates).Error; err != nil {
|
||||
s.Log.Errorf("Failed to get next checklist dates for empty kandang: %+v", err)
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
nextDatesByKandang := make(map[uint][]time.Time)
|
||||
for _, row := range nextDates {
|
||||
nextDatesByKandang[row.KandangID] = append(nextDatesByKandang[row.KandangID], row.Date)
|
||||
}
|
||||
|
||||
for _, rec := range emptyRecs {
|
||||
var nextDate time.Time
|
||||
for _, d := range nextDatesByKandang[rec.KandangID] {
|
||||
if d.After(rec.Date) {
|
||||
nextDate = d
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If no next checklist, cap empty period at today (not end of month)
|
||||
ceiling := lastDay
|
||||
if today.Before(lastDay) {
|
||||
ceiling = today
|
||||
}
|
||||
periodEnd := ceiling
|
||||
if !nextDate.IsZero() {
|
||||
periodEnd = nextDate.AddDate(0, 0, -1)
|
||||
}
|
||||
|
||||
effectiveStart := rec.Date
|
||||
if effectiveStart.Before(firstDay) {
|
||||
effectiveStart = firstDay
|
||||
}
|
||||
effectiveEnd := periodEnd
|
||||
if effectiveEnd.After(lastDay) {
|
||||
effectiveEnd = lastDay
|
||||
}
|
||||
|
||||
if effectiveStart.After(effectiveEnd) {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, ok := emptyDaysByKandang[rec.KandangID]; !ok {
|
||||
emptyDaysByKandang[rec.KandangID] = make(map[int]struct{})
|
||||
}
|
||||
for d := effectiveStart; !d.After(effectiveEnd); d = d.AddDate(0, 0, 1) {
|
||||
emptyDaysByKandang[rec.KandangID][d.Day()] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i, item := range items {
|
||||
daySet := emptyDaysByKandang[item.KandangID]
|
||||
for day := range daySet {
|
||||
key := strconv.Itoa(day)
|
||||
if _, exists := items[i].DailyActivities[key]; !exists {
|
||||
items[i].DailyActivities[key] = "Kandang kosong"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
@@ -9,8 +9,7 @@ type Create struct {
|
||||
KandangId uint `json:"kandang_id" validate:"required"`
|
||||
Category string `json:"category" validate:"required"`
|
||||
Status string `json:"status" validate:"required"`
|
||||
EmptyKandang bool `json:"empty_kandang"`
|
||||
EmptyKandangEndDate string `json:"empty_kandang_end_date"`
|
||||
EmptyKandang bool `json:"empty_kandang"`
|
||||
}
|
||||
|
||||
type Update struct {
|
||||
|
||||
@@ -91,8 +91,10 @@ func (s transactionService) GetAll(c *fiber.Ctx, params *validation.Query) ([]en
|
||||
LOWER(COALESCE(notes, '')) LIKE ? OR
|
||||
LOWER(COALESCE(customers.name, '')) LIKE ? OR
|
||||
LOWER(COALESCE(suppliers.name, '')) LIKE ? OR
|
||||
LOWER(COALESCE(banks.name, '')) LIKE ?`,
|
||||
like, like, like, like, like, like, like, like,
|
||||
LOWER(COALESCE(banks.name, '')) LIKE ? OR
|
||||
CAST(payments.nominal AS TEXT) LIKE ? OR
|
||||
TO_CHAR(payments.payment_date, 'YYYY-MM-DD') LIKE ?`,
|
||||
like, like, like, like, like, like, like, like, like, like,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/dto"
|
||||
@@ -21,32 +22,32 @@ func NewChickinController(chickinService service.ChickinService) *ChickinControl
|
||||
}
|
||||
}
|
||||
|
||||
// func (u *ChickinController) GetAll(c *fiber.Ctx) error {
|
||||
// query := &validation.Query{
|
||||
// Page: c.QueryInt("page", 1),
|
||||
// Limit: c.QueryInt("limit", 10),
|
||||
// ProjectFlockKandangId: uint(c.QueryInt("project_flock_kandang_id", 0)),
|
||||
// }
|
||||
func (u *ChickinController) GetAll(c *fiber.Ctx) error {
|
||||
query := &validation.Query{
|
||||
Page: c.QueryInt("page", 1),
|
||||
Limit: c.QueryInt("limit", 10),
|
||||
ProjectFlockKandangId: uint(c.QueryInt("project_flock_kandang_id", 0)),
|
||||
}
|
||||
|
||||
// result, totalResults, err := u.ChickinService.GetAll(c, query)
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
result, totalResults, err := u.ChickinService.GetAll(c, query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// return c.Status(fiber.StatusOK).
|
||||
// JSON(response.SuccessWithPaginate[dto.ChickinListDTO]{
|
||||
// Code: fiber.StatusOK,
|
||||
// Status: "success",
|
||||
// Message: "Get all chickins successfully",
|
||||
// Meta: response.Meta{
|
||||
// Page: query.Page,
|
||||
// Limit: query.Limit,
|
||||
// TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))),
|
||||
// TotalResults: totalResults,
|
||||
// },
|
||||
// Data: dto.ToChickinListDTOs(result),
|
||||
// })
|
||||
// }
|
||||
return c.Status(fiber.StatusOK).
|
||||
JSON(response.SuccessWithPaginate[dto.ChickinListDTO]{
|
||||
Code: fiber.StatusOK,
|
||||
Status: "success",
|
||||
Message: "Get all chickins successfully",
|
||||
Meta: response.Meta{
|
||||
Page: query.Page,
|
||||
Limit: query.Limit,
|
||||
TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))),
|
||||
TotalResults: totalResults,
|
||||
},
|
||||
Data: dto.ToChickinListDTOs(result),
|
||||
})
|
||||
}
|
||||
|
||||
// func (u *ChickinController) GetOne(c *fiber.Ctx) error {
|
||||
// param := c.Params("id")
|
||||
|
||||
@@ -15,8 +15,8 @@ func ChickinRoutes(v1 fiber.Router, u user.UserService, s chickin.ChickinService
|
||||
route := v1.Group("/chickins")
|
||||
route.Use(m.Auth(u))
|
||||
|
||||
// route.Get("/", ctrl.GetAll)
|
||||
route.Post("/",m.RequirePermissions(m.P_ChickinsCreateOne), ctrl.CreateOne)
|
||||
route.Get("/", m.RequirePermissions(m.P_ChickinsGetAll), ctrl.GetAll)
|
||||
route.Post("/", m.RequirePermissions(m.P_ChickinsCreateOne), ctrl.CreateOne)
|
||||
route.Get("/:id",m.RequirePermissions(m.P_ChickinsGetOne), ctrl.GetOne)
|
||||
// route.Patch("/:id", ctrl.UpdateOne)
|
||||
route.Delete("/:id", ctrl.DeleteOne)
|
||||
|
||||
@@ -49,6 +49,7 @@ type RecordingRepository interface {
|
||||
DeleteEggs(tx *gorm.DB, recordingID uint) error
|
||||
ListEggs(tx *gorm.DB, recordingID uint) ([]entity.RecordingEgg, error)
|
||||
UpdateEggTotalQty(tx *gorm.DB, eggID uint, totalQty float64) error
|
||||
UpdateEggWeight(tx *gorm.DB, eggID uint, weight *float64) error
|
||||
GetRecordingEggByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.RecordingEgg, error)
|
||||
|
||||
ExistsOnDate(ctx context.Context, projectFlockKandangId uint, recordTime time.Time) (bool, error)
|
||||
@@ -543,6 +544,12 @@ func (r *RecordingRepositoryImpl) UpdateEggTotalQty(tx *gorm.DB, eggID uint, tot
|
||||
Update("total_qty", totalQty).Error
|
||||
}
|
||||
|
||||
func (r *RecordingRepositoryImpl) UpdateEggWeight(tx *gorm.DB, eggID uint, weight *float64) error {
|
||||
return tx.Model(&entity.RecordingEgg{}).
|
||||
Where("id = ?", eggID).
|
||||
Update("weight", weight).Error
|
||||
}
|
||||
|
||||
func (r *RecordingRepositoryImpl) GetRecordingEggByID(
|
||||
ctx context.Context,
|
||||
id uint,
|
||||
|
||||
@@ -173,6 +173,37 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Pre-fetch transfer maps by category to avoid N+1 per-recording queries.
|
||||
growingPFKIDs := make([]uint, 0, len(pfkIDs))
|
||||
layingPFKIDs := make([]uint, 0, len(pfkIDs))
|
||||
seenCat := make(map[uint]bool, len(pfkIDs))
|
||||
for i := range recordings {
|
||||
pfkID := recordings[i].ProjectFlockKandangId
|
||||
if pfkID == 0 || seenCat[pfkID] {
|
||||
continue
|
||||
}
|
||||
seenCat[pfkID] = true
|
||||
cat := ""
|
||||
if recordings[i].ProjectFlockKandang != nil && recordings[i].ProjectFlockKandang.ProjectFlock.Id != 0 {
|
||||
cat = strings.ToUpper(strings.TrimSpace(recordings[i].ProjectFlockKandang.ProjectFlock.Category))
|
||||
}
|
||||
switch cat {
|
||||
case string(utils.ProjectFlockCategoryGrowing):
|
||||
growingPFKIDs = append(growingPFKIDs, pfkID)
|
||||
case string(utils.ProjectFlockCategoryLaying):
|
||||
layingPFKIDs = append(layingPFKIDs, pfkID)
|
||||
}
|
||||
}
|
||||
sourceTransferByPFK, err := s.TransferLayingRepo.GetLatestApprovedBySourceKandangs(c.Context(), growingPFKIDs)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
targetTransferByPFK, err := s.TransferLayingRepo.GetLatestApprovedByTargetKandangs(c.Context(), layingPFKIDs)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
hasTargetRecordingCache := make(map[uint]bool)
|
||||
|
||||
cutOverChickinAvailability := make(map[uint]bool)
|
||||
for i := range recordings {
|
||||
if recordings[i].ProjectFlockKandangId != 0 && !recordings[i].RecordDatetime.IsZero() {
|
||||
@@ -192,7 +223,7 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
|
||||
rate := recordingutil.ComputeDepletionRate(prev, current, totalChick)
|
||||
recordings[i].DepletionRate = &rate
|
||||
|
||||
populationCanChange, transferExecuted, isTransition, isLaying, _, _, stateErr := s.evaluatePopulationMutationState(c.Context(), &recordings[i])
|
||||
populationCanChange, transferExecuted, isTransition, isLaying, _, _, stateErr := s.evaluatePopulationMutationStateFromCaches(c.Context(), &recordings[i], sourceTransferByPFK, targetTransferByPFK, hasTargetRecordingCache)
|
||||
if stateErr != nil {
|
||||
return nil, 0, stateErr
|
||||
}
|
||||
@@ -768,6 +799,12 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
|
||||
match := recordingutil.EggTotalsEqual(existingTotals, incomingTotals)
|
||||
if match {
|
||||
hasEggChanges = false
|
||||
} else if recordingutil.EggQtyByWarehouseEqual(existingTotals, incomingTotals) {
|
||||
// Weight-only change: update weight fields directly without touching FIFO
|
||||
if err := s.updateEggWeightsOnly(tx, existingEggs, req.Eggs); err != nil {
|
||||
return err
|
||||
}
|
||||
// hasEggChanges stays true so metrics are recomputed
|
||||
} else {
|
||||
category := ""
|
||||
if recordingEntity.ProjectFlockKandang != nil && recordingEntity.ProjectFlockKandang.ProjectFlock.Id != 0 {
|
||||
@@ -785,7 +822,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
|
||||
if err := s.ensureProductWarehousesByFlags(ctx, eggIDs, []string{"TELUR-UTUH", "TELUR-PECAH", "TELUR-PUTIH", "TELUR-RETAK", "TELUR"}, "egg"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureRecordingEggsUnused(existingEggs); err != nil {
|
||||
if err := ensureRecordingEggQtyChangeSafe(existingEggs, req.Eggs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.logRecordingEggUsage(ctx, tx, existingEggs, note, actorID); err != nil {
|
||||
@@ -1308,6 +1345,82 @@ func (s *recordingService) evaluatePopulationMutationState(ctx context.Context,
|
||||
return populationCanChange, transferExecuted, isTransition, isLaying, transfer, transferDate, nil
|
||||
}
|
||||
|
||||
// evaluatePopulationMutationStateFromCaches is identical to evaluatePopulationMutationState
|
||||
// but uses pre-fetched transfer maps to avoid N+1 queries in list endpoints.
|
||||
func (s *recordingService) evaluatePopulationMutationStateFromCaches(
|
||||
ctx context.Context,
|
||||
recording *entity.Recording,
|
||||
sourceTransferByPFK map[uint]*entity.LayingTransfer,
|
||||
targetTransferByPFK map[uint]*entity.LayingTransfer,
|
||||
hasTargetRecordingCache map[uint]bool,
|
||||
) (bool, bool, bool, bool, *entity.LayingTransfer, time.Time, error) {
|
||||
if recording == nil || recording.ProjectFlockKandangId == 0 || s.TransferLayingRepo == nil {
|
||||
return true, false, false, false, nil, time.Time{}, nil
|
||||
}
|
||||
|
||||
category, err := s.resolveRecordingCategory(ctx, recording)
|
||||
if err != nil {
|
||||
s.Log.Errorf("Failed to resolve recording category for population mutation check (recording=%d): %+v", recording.Id, err)
|
||||
return true, false, false, false, nil, time.Time{}, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi perubahan populasi recording")
|
||||
}
|
||||
|
||||
var transfer *entity.LayingTransfer
|
||||
switch category {
|
||||
case strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)):
|
||||
transfer = sourceTransferByPFK[recording.ProjectFlockKandangId]
|
||||
case strings.ToUpper(string(utils.ProjectFlockCategoryLaying)):
|
||||
transfer = targetTransferByPFK[recording.ProjectFlockKandangId]
|
||||
default:
|
||||
return true, false, false, false, nil, time.Time{}, nil
|
||||
}
|
||||
|
||||
if transfer == nil {
|
||||
return true, false, false, false, nil, time.Time{}, nil
|
||||
}
|
||||
|
||||
transferDate := transferPhysicalMoveDate(transfer)
|
||||
if transferDate.IsZero() {
|
||||
return true, false, false, false, transfer, transferDate, nil
|
||||
}
|
||||
|
||||
transferExecuted := transfer.ExecutedAt != nil && !transfer.ExecutedAt.IsZero()
|
||||
recordDate := normalizeDateOnlyUTC(recording.RecordDatetime)
|
||||
_, economicCutoffDate := transferRecordingWindow(transfer)
|
||||
isTransition := !recordDate.Before(transferDate) && recordDate.Before(economicCutoffDate)
|
||||
isLaying := !recordDate.Before(economicCutoffDate)
|
||||
|
||||
populationCanChange := true
|
||||
if category == strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)) {
|
||||
populationCanChange = !(transferExecuted && !recordDate.Before(transferDate))
|
||||
|
||||
if transferExecuted && !recordDate.Before(transferDate) {
|
||||
var hasTargetLayingRecording bool
|
||||
if cached, ok := hasTargetRecordingCache[transfer.Id]; ok {
|
||||
hasTargetLayingRecording = cached
|
||||
} else {
|
||||
hasTargetLayingRecording, err = s.hasAnyRecordingOnTransferTargets(ctx, transfer)
|
||||
if err != nil {
|
||||
s.Log.Errorf("Failed to resolve target laying recording state for transfer %d: %+v", transfer.Id, err)
|
||||
return true, false, false, false, nil, time.Time{}, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi status transisi recording")
|
||||
}
|
||||
hasTargetRecordingCache[transfer.Id] = hasTargetLayingRecording
|
||||
}
|
||||
if hasTargetLayingRecording {
|
||||
isTransition = false
|
||||
isLaying = true
|
||||
} else {
|
||||
today := normalizeDateOnlyUTC(time.Now().UTC())
|
||||
if !today.Before(economicCutoffDate) {
|
||||
isTransition = true
|
||||
isLaying = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return populationCanChange, transferExecuted, isTransition, isLaying, transfer, transferDate, nil
|
||||
}
|
||||
|
||||
func (s *recordingService) hasAnyRecordingOnTransferTargets(ctx context.Context, transfer *entity.LayingTransfer) (bool, error) {
|
||||
if transfer == nil || transfer.Id == 0 {
|
||||
return false, nil
|
||||
@@ -3672,6 +3785,44 @@ func ensureRecordingEggsUnused(eggs []entity.RecordingEgg) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureRecordingEggQtyChangeSafe(existingEggs []entity.RecordingEgg, reqEggs []validation.Egg) error {
|
||||
usedByWarehouse := make(map[uint]float64)
|
||||
for _, egg := range existingEggs {
|
||||
usedByWarehouse[egg.ProductWarehouseId] += egg.TotalUsed
|
||||
}
|
||||
newQtyByWarehouse := make(map[uint]int)
|
||||
for _, egg := range reqEggs {
|
||||
newQtyByWarehouse[egg.ProductWarehouseId] += egg.Qty
|
||||
}
|
||||
for warehouseID, used := range usedByWarehouse {
|
||||
if used <= 0 {
|
||||
continue
|
||||
}
|
||||
if float64(newQtyByWarehouse[warehouseID]) < used {
|
||||
return fiber.NewError(fiber.StatusBadRequest,
|
||||
fmt.Sprintf("Jumlah telur tidak dapat dikurangi di bawah jumlah yang sudah terjual (%.0f butir)", used))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *recordingService) updateEggWeightsOnly(tx *gorm.DB, existingEggs []entity.RecordingEgg, reqEggs []validation.Egg) error {
|
||||
weightByWarehouse := make(map[uint]*float64)
|
||||
for i := range reqEggs {
|
||||
weightByWarehouse[reqEggs[i].ProductWarehouseId] = reqEggs[i].Weight
|
||||
}
|
||||
for _, egg := range existingEggs {
|
||||
newWeight, ok := weightByWarehouse[egg.ProductWarehouseId]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if err := s.Repository.UpdateEggWeight(tx, egg.Id, newWeight); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
+120
@@ -17,6 +17,8 @@ type TransferLayingRepository interface {
|
||||
IdExists(ctx context.Context, id uint) (bool, error)
|
||||
GetLatestApprovedBySourceKandang(ctx context.Context, sourceProjectFlockKandangID uint) (*entity.LayingTransfer, error)
|
||||
GetLatestApprovedByTargetKandang(ctx context.Context, targetProjectFlockKandangID uint) (*entity.LayingTransfer, error)
|
||||
GetLatestApprovedBySourceKandangs(ctx context.Context, pfkIDs []uint) (map[uint]*entity.LayingTransfer, error)
|
||||
GetLatestApprovedByTargetKandangs(ctx context.Context, pfkIDs []uint) (map[uint]*entity.LayingTransfer, error)
|
||||
|
||||
// Tambah method baru untuk query dengan filter lengkap
|
||||
GetAllWithFilters(ctx context.Context, offset int, limit int, params *GetAllFilterParams) ([]entity.LayingTransfer, int64, error)
|
||||
@@ -242,3 +244,121 @@ func (r *TransferLayingRepositoryImpl) GetLatestApprovedByTargetKandang(ctx cont
|
||||
}
|
||||
return &transfer, nil
|
||||
}
|
||||
|
||||
type pfkTransferIDRow struct {
|
||||
SourcePFKID uint `gorm:"column:source_pfk_id"`
|
||||
TransferID uint `gorm:"column:transfer_id"`
|
||||
}
|
||||
|
||||
func (r *TransferLayingRepositoryImpl) GetLatestApprovedBySourceKandangs(ctx context.Context, pfkIDs []uint) (map[uint]*entity.LayingTransfer, error) {
|
||||
result := make(map[uint]*entity.LayingTransfer)
|
||||
if len(pfkIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var rows []pfkTransferIDRow
|
||||
err := r.db.WithContext(ctx).Raw(`
|
||||
SELECT DISTINCT ON (source_pfk_id) source_pfk_id, transfer_id
|
||||
FROM (
|
||||
SELECT id AS transfer_id, source_project_flock_kandang_id AS source_pfk_id
|
||||
FROM laying_transfers
|
||||
WHERE source_project_flock_kandang_id IN ?
|
||||
AND deleted_at IS NULL
|
||||
AND (
|
||||
SELECT a.action FROM approvals a
|
||||
WHERE a.approvable_type = ? AND a.approvable_id = id
|
||||
ORDER BY a.id DESC LIMIT 1
|
||||
) = ?
|
||||
UNION ALL
|
||||
SELECT lts.laying_transfer_id AS transfer_id, lts.source_project_flock_kandang_id AS source_pfk_id
|
||||
FROM laying_transfer_sources lts
|
||||
JOIN laying_transfers t ON t.id = lts.laying_transfer_id AND t.deleted_at IS NULL
|
||||
WHERE lts.source_project_flock_kandang_id IN ?
|
||||
AND lts.deleted_at IS NULL
|
||||
AND (
|
||||
SELECT a.action FROM approvals a
|
||||
WHERE a.approvable_type = ? AND a.approvable_id = t.id
|
||||
ORDER BY a.id DESC LIMIT 1
|
||||
) = ?
|
||||
) combined
|
||||
ORDER BY source_pfk_id, transfer_id DESC
|
||||
`,
|
||||
pfkIDs, string(utils.ApprovalWorkflowTransferToLaying), string(entity.ApprovalActionApproved),
|
||||
pfkIDs, string(utils.ApprovalWorkflowTransferToLaying), string(entity.ApprovalActionApproved),
|
||||
).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
transferIDs := make([]uint, 0, len(rows))
|
||||
pfkByTransfer := make(map[uint]uint, len(rows))
|
||||
for _, row := range rows {
|
||||
transferIDs = append(transferIDs, row.TransferID)
|
||||
pfkByTransfer[row.TransferID] = row.SourcePFKID
|
||||
}
|
||||
|
||||
var transfers []entity.LayingTransfer
|
||||
if err := r.db.WithContext(ctx).Where("id IN ? AND deleted_at IS NULL", transferIDs).Find(&transfers).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range transfers {
|
||||
if pfkID := pfkByTransfer[transfers[i].Id]; pfkID != 0 {
|
||||
result[pfkID] = &transfers[i]
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *TransferLayingRepositoryImpl) GetLatestApprovedByTargetKandangs(ctx context.Context, pfkIDs []uint) (map[uint]*entity.LayingTransfer, error) {
|
||||
result := make(map[uint]*entity.LayingTransfer)
|
||||
if len(pfkIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var rows []pfkTransferIDRow
|
||||
err := r.db.WithContext(ctx).Raw(`
|
||||
SELECT DISTINCT ON (source_pfk_id) source_pfk_id, transfer_id
|
||||
FROM (
|
||||
SELECT ltt.laying_transfer_id AS transfer_id, ltt.target_project_flock_kandang_id AS source_pfk_id
|
||||
FROM laying_transfer_targets ltt
|
||||
JOIN laying_transfers t ON t.id = ltt.laying_transfer_id AND t.deleted_at IS NULL
|
||||
WHERE ltt.target_project_flock_kandang_id IN ?
|
||||
AND ltt.deleted_at IS NULL
|
||||
AND (
|
||||
SELECT a.action FROM approvals a
|
||||
WHERE a.approvable_type = ? AND a.approvable_id = t.id
|
||||
ORDER BY a.id DESC LIMIT 1
|
||||
) = ?
|
||||
) combined
|
||||
ORDER BY source_pfk_id, transfer_id DESC
|
||||
`,
|
||||
pfkIDs, string(utils.ApprovalWorkflowTransferToLaying), string(entity.ApprovalActionApproved),
|
||||
).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
transferIDs := make([]uint, 0, len(rows))
|
||||
pfkByTransfer := make(map[uint]uint, len(rows))
|
||||
for _, row := range rows {
|
||||
transferIDs = append(transferIDs, row.TransferID)
|
||||
pfkByTransfer[row.TransferID] = row.SourcePFKID
|
||||
}
|
||||
|
||||
var transfers []entity.LayingTransfer
|
||||
if err := r.db.WithContext(ctx).Where("id IN ? AND deleted_at IS NULL", transferIDs).Find(&transfers).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range transfers {
|
||||
if pfkID := pfkByTransfer[transfers[i].Id]; pfkID != 0 {
|
||||
result[pfkID] = &transfers[i]
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
+114
-11
@@ -44,14 +44,15 @@ type parameterMeta struct {
|
||||
}
|
||||
|
||||
type routeMeta struct {
|
||||
Group string
|
||||
Tag string
|
||||
Summary string
|
||||
Description string
|
||||
Security securityMode
|
||||
ListStyle bool
|
||||
QueryParams []parameterMeta
|
||||
Exclude bool
|
||||
Group string
|
||||
Tag string
|
||||
Summary string
|
||||
Description string
|
||||
Security securityMode
|
||||
ListStyle bool
|
||||
QueryParams []parameterMeta
|
||||
ExampleResponse any
|
||||
Exclude bool
|
||||
}
|
||||
|
||||
func RegisterRoutes(router fiber.Router) {
|
||||
@@ -187,6 +188,13 @@ func buildOpenAPIDocument(routes []normalizedRoute) map[string]any {
|
||||
}
|
||||
|
||||
openAPIPath := toOpenAPIPath(route.Path)
|
||||
responseContent := map[string]any{
|
||||
"schema": successSchema(meta),
|
||||
}
|
||||
if meta.ExampleResponse != nil {
|
||||
responseContent["example"] = meta.ExampleResponse
|
||||
}
|
||||
|
||||
operation := map[string]any{
|
||||
"summary": meta.Summary,
|
||||
"description": meta.Description,
|
||||
@@ -195,9 +203,7 @@ func buildOpenAPIDocument(routes []normalizedRoute) map[string]any {
|
||||
"200": map[string]any{
|
||||
"description": "Successful response",
|
||||
"content": map[string]any{
|
||||
"application/json": map[string]any{
|
||||
"schema": successSchema(meta),
|
||||
},
|
||||
"application/json": responseContent,
|
||||
},
|
||||
},
|
||||
"401": map[string]any{
|
||||
@@ -777,6 +783,31 @@ func describeRoute(route normalizedRoute) routeMeta {
|
||||
{Name: "limit", In: "query", Description: "Page size.", Example: 10},
|
||||
{Name: "search", In: "query", Description: "Search keyword.", Example: "fcr"},
|
||||
}
|
||||
meta.ExampleResponse = map[string]any{
|
||||
"code": 200, "status": "success", "message": "Get all fcrs successfully",
|
||||
"meta": map[string]any{"page": 1, "limit": 10, "total_pages": 1, "total_results": 1},
|
||||
"data": []map[string]any{
|
||||
{
|
||||
"id": 1, "name": "FCR Broiler Standard",
|
||||
"created_user": map[string]any{"id": 1, "name": "Admin"},
|
||||
"created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
}
|
||||
case "/api/master-data/fcrs/:id":
|
||||
meta.ExampleResponse = map[string]any{
|
||||
"code": 200, "status": "success", "message": "Get fcr successfully",
|
||||
"data": map[string]any{
|
||||
"id": 1, "name": "FCR Broiler Standard",
|
||||
"created_user": map[string]any{"id": 1, "name": "Admin"},
|
||||
"created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z",
|
||||
"fcr_standards": []map[string]any{
|
||||
{"id": 1, "weight": 0.5, "fcr_number": 1.2, "mortality": 0.5},
|
||||
{"id": 2, "weight": 1.0, "fcr_number": 1.35, "mortality": 0.3},
|
||||
{"id": 3, "weight": 1.5, "fcr_number": 1.5, "mortality": 0.25},
|
||||
},
|
||||
},
|
||||
}
|
||||
case "/api/master-data/flocks":
|
||||
meta.QueryParams = []parameterMeta{
|
||||
{Name: "page", In: "query", Description: "Page number.", Example: 1},
|
||||
@@ -926,6 +957,31 @@ func describeRoute(route normalizedRoute) routeMeta {
|
||||
{Name: "project_flock_kandang_id", In: "query", Description: "Project flock kandang id.", Required: true, Example: 1, PostmanValue: "{{project_flock_kandang_id}}"},
|
||||
{Name: "record_date", In: "query", Description: "Recording date (YYYY-MM-DD).", Required: true, Example: "2026-01-01"},
|
||||
}
|
||||
case "/api/production/chickins":
|
||||
meta.QueryParams = []parameterMeta{
|
||||
{Name: "page", In: "query", Description: "Page number.", Example: 1},
|
||||
{Name: "limit", In: "query", Description: "Page size.", Example: 10},
|
||||
{Name: "project_flock_kandang_id", In: "query", Description: "Project flock kandang id filter.", Example: 1, PostmanValue: "{{project_flock_kandang_id}}"},
|
||||
}
|
||||
meta.ExampleResponse = map[string]any{
|
||||
"code": 200, "status": "success", "message": "Get all chickins successfully",
|
||||
"meta": map[string]any{"page": 1, "limit": 10, "total_pages": 1, "total_results": 1},
|
||||
"data": []map[string]any{
|
||||
{
|
||||
"id": 1, "project_flock_kandang_id": 1,
|
||||
"chick_in_date": "2026-01-01T00:00:00Z",
|
||||
"product_warehouse_id": 1,
|
||||
"product_warehouse": map[string]any{
|
||||
"id": 1,
|
||||
"product": map[string]any{"id": 1, "name": "DOC Broiler"},
|
||||
"warehouse": map[string]any{"id": 1, "name": "Gudang DOC"},
|
||||
},
|
||||
"usage_qty": 10000.0, "pending_usage_qty": 0.0, "notes": "",
|
||||
"created_user": map[string]any{"id": 1, "name": "Admin"},
|
||||
"created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
}
|
||||
case "/api/production/transfer_layings":
|
||||
meta.QueryParams = []parameterMeta{
|
||||
{Name: "page", In: "query", Description: "Page number.", Example: 1},
|
||||
@@ -937,6 +993,53 @@ func describeRoute(route normalizedRoute) routeMeta {
|
||||
{Name: "flock_destination", In: "query", Description: "Comma separated destination flock ids.", Example: "3,4"},
|
||||
{Name: "status", In: "query", Description: "Comma separated status values.", Example: "DRAFT,APPROVED"},
|
||||
}
|
||||
meta.ExampleResponse = map[string]any{
|
||||
"code": 200, "status": "success", "message": "Get all transferLayings successfully",
|
||||
"meta": map[string]any{"page": 1, "limit": 10, "total_pages": 1, "total_results": 1},
|
||||
"data": []map[string]any{
|
||||
{
|
||||
"id": 1, "transfer_number": "TL-00001",
|
||||
"transfer_date": "2026-01-15T00:00:00Z",
|
||||
"economic_cutoff_date": "2026-01-20T00:00:00Z",
|
||||
"effective_move_date": "2026-01-18T00:00:00Z",
|
||||
"executed_at": nil, "notes": "",
|
||||
"from_project_flock": map[string]any{"id": 1, "flock_name": "Flock A Period 1"},
|
||||
"to_project_flock": map[string]any{"id": 2, "flock_name": "Flock B Period 1"},
|
||||
"created_by": 1,
|
||||
"created_user": map[string]any{"id": 1, "name": "Admin"},
|
||||
"created_at": "2026-01-15T00:00:00Z",
|
||||
"approval": map[string]any{"step_number": 1, "step_name": "Pengajuan", "action": nil},
|
||||
},
|
||||
},
|
||||
}
|
||||
case "/api/production/transfer_layings/:id":
|
||||
meta.ExampleResponse = map[string]any{
|
||||
"code": 200, "status": "success", "message": "Get transferLaying successfully",
|
||||
"data": map[string]any{
|
||||
"id": 1, "transfer_number": "TL-00001",
|
||||
"transfer_date": "2026-01-15T00:00:00Z",
|
||||
"economic_cutoff_date": "2026-01-20T00:00:00Z",
|
||||
"effective_move_date": "2026-01-18T00:00:00Z",
|
||||
"executed_at": nil, "notes": "",
|
||||
"from_project_flock": map[string]any{"id": 1, "flock_name": "Flock A Period 1"},
|
||||
"to_project_flock": map[string]any{"id": 2, "flock_name": "Flock B Period 1"},
|
||||
"created_by": 1, "created_user": map[string]any{"id": 1, "name": "Admin"},
|
||||
"created_at": "2026-01-15T00:00:00Z",
|
||||
"approval": map[string]any{"step_number": 1, "step_name": "Pengajuan", "action": nil},
|
||||
"sources": []map[string]any{
|
||||
{
|
||||
"source_project_flock_kandang": map[string]any{"id": 1, "kandang_id": 1, "project_flock_id": 1, "kandang": map[string]any{"id": 1, "name": "Kandang A"}},
|
||||
"qty": 5000.0, "note": "",
|
||||
},
|
||||
},
|
||||
"targets": []map[string]any{
|
||||
{
|
||||
"target_project_flock_kandang": map[string]any{"id": 2, "kandang_id": 2, "project_flock_id": 2, "kandang": map[string]any{"id": 2, "name": "Kandang B"}},
|
||||
"qty": 5000.0, "note": "",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
case "/api/production/uniformities":
|
||||
meta.QueryParams = []parameterMeta{
|
||||
{Name: "page", In: "query", Description: "Page number.", Example: 1},
|
||||
|
||||
@@ -148,6 +148,19 @@ func EggTotalsEqual(a, b map[uint]EggTotals) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func EggQtyByWarehouseEqual(a, b map[uint]EggTotals) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for key, value := range a {
|
||||
other, ok := b[key]
|
||||
if !ok || value.Qty != other.Qty {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func DepletionRouteMapsEqual(a, b map[DepletionRoute]float64) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
|
||||
Reference in New Issue
Block a user