Fix transfer to laying delete and fix chikin delete with response recording

This commit is contained in:
ragilap
2026-03-09 13:10:06 +07:00
parent 45cc057dd4
commit 3a8cc47fa0
14 changed files with 1091 additions and 86 deletions
+2
View File
@@ -45,4 +45,6 @@ type Recording struct {
StandardFcr *float64 `gorm:"-"`
PopulationCanChange *bool `gorm:"-"`
TransferExecuted *bool `gorm:"-"`
IsTransition *bool `gorm:"-"`
IsLaying *bool `gorm:"-"`
}
@@ -151,25 +151,25 @@ func (u *ChickinController) GetOne(c *fiber.Ctx) error {
// })
// }
// func (u *ChickinController) DeleteOne(c *fiber.Ctx) error {
// param := c.Params("id")
func (u *ChickinController) DeleteOne(c *fiber.Ctx) error {
param := c.Params("id")
// id, err := strconv.Atoi(param)
// if err != nil {
// return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
// }
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
// if err := u.ChickinService.DeleteOne(c, uint(id)); err != nil {
// return err
// }
if err := u.ChickinService.DeleteOne(c, uint(id)); err != nil {
return err
}
// return c.Status(fiber.StatusOK).
// JSON(response.Common{
// Code: fiber.StatusOK,
// Status: "success",
// Message: "Delete chickin successfully",
// })
// }
return c.Status(fiber.StatusOK).
JSON(response.Common{
Code: fiber.StatusOK,
Status: "success",
Message: "Delete chickin successfully",
})
}
func (u *ChickinController) Approval(c *fiber.Ctx) error {
req := new(validation.Approve)
@@ -19,6 +19,6 @@ func ChickinRoutes(v1 fiber.Router, u user.UserService, s chickin.ChickinService
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)
route.Delete("/:id", ctrl.DeleteOne)
route.Post("/approvals",m.RequirePermissions(m.P_ChickinsApproval), ctrl.Approval)
}
@@ -33,7 +33,10 @@ import (
"gorm.io/gorm/clause"
)
const chickinDeletePopulationGuardMessage = "Chickin tidak dapat dihapus karena sudah memiliki population. Lakukan rollback/penyesuaian population terlebih dahulu"
const (
chickinDeletePopulationGuardMessage = "Chickin tidak dapat dihapus karena masih memiliki population aktif"
chickinDeleteRecordingGuardMessage = "Chickin tidak dapat dihapus karena masih terkait recording. Lakukan rollback/delete recording terlebih dahulu"
)
type ChickinService interface {
GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectChickin, int64, error)
@@ -133,16 +136,31 @@ func (s chickinService) ensureNotTransferred(ctx context.Context, projectFlockKa
return nil
}
transfer, err := s.TransferLayingRepo.GetLatestApprovedBySourceKandang(ctx, projectFlockKandangID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// Restriction transfer->laying untuk chickin hanya berlaku pada kandang kategori growing.
if s.ProjectflockKandangRepo != nil {
pfk, err := s.ProjectflockKandangRepo.GetByIDLight(ctx, projectFlockKandangID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Failed to resolve project flock kandang %d: %+v", projectFlockKandangID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transfer laying")
}
if err == nil && pfk != nil {
category := strings.ToUpper(strings.TrimSpace(pfk.ProjectFlock.Category))
if category != strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)) {
return nil
}
}
}
checkExecuted := func(transfer *entity.LayingTransfer) bool {
return transfer != nil && transfer.ExecutedAt != nil && !transfer.ExecutedAt.IsZero()
}
sourceTransfer, err := s.TransferLayingRepo.GetLatestApprovedBySourceKandang(ctx, projectFlockKandangID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Failed to resolve transfer laying by source kandang %d: %+v", projectFlockKandangID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transfer laying")
}
if transfer != nil && transfer.ExecutedAt != nil && !transfer.ExecutedAt.IsZero() {
if checkExecuted(sourceTransfer) {
return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang sudah dipindahkan ke laying")
}
@@ -175,6 +193,7 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti
newChikins := make([]*entity.ProjectChickin, 0)
chickinQtyMap := make(map[uint]float64)
flockCategory := strings.ToUpper(strings.TrimSpace(projectFlockKandang.ProjectFlock.Category))
for idx, chickinReq := range req.ChickinRequests {
@@ -194,8 +213,7 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti
}
if productWarehouse.Product.Id != 0 {
category := strings.ToUpper(strings.TrimSpace(projectFlockKandang.ProjectFlock.Category))
if category != string(utils.ProjectFlockCategoryGrowing) && category != string(utils.ProjectFlockCategoryLaying) {
if flockCategory != string(utils.ProjectFlockCategoryGrowing) && flockCategory != string(utils.ProjectFlockCategoryLaying) {
return nil, fmt.Errorf("invalid flock category for chickin")
}
@@ -244,6 +262,19 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti
if availableQty < 0 {
availableQty = 0
}
if flockCategory == strings.ToUpper(string(utils.ProjectFlockCategoryLaying)) {
transferAvailable, err := s.resolveLayingTransferAvailableQty(c.Context(), nil, req.ProjectFlockKandangId, chickinReq.ProductWarehouseId)
if err != nil {
s.Log.Errorf("Failed to resolve laying transfer availability for pfk=%d pw=%d: %+v", req.ProjectFlockKandangId, chickinReq.ProductWarehouseId, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi stok transfer laying")
}
if transferAvailable < 0 {
transferAvailable = 0
}
if transferAvailable < availableQty {
availableQty = transferAvailable
}
}
chickinQtyMap[uint(idx)] = availableQty
}
@@ -253,6 +284,9 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti
}
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
if err := s.ensurePopulationRouteScope(c.Context(), dbTransaction); err != nil {
return err
}
repositoryTx := repository.NewChickinRepository(dbTransaction)
existingChikins, err := repositoryTx.GetByProjectFlockKandangIDForUpdate(c.Context(), req.ProjectFlockKandangId)
@@ -425,13 +459,8 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error {
if err := s.ensureNotTransferred(c.Context(), chickin.ProjectFlockKandangId); err != nil {
return err
}
hasPopulation, err := s.ProjectflockPopulationRepo.ExistsByProjectChickinID(c.Context(), chickin.Id)
if err != nil {
s.Log.Errorf("Failed to check population by chickin %d: %+v", chickin.Id, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi population chickin")
}
if hasPopulation {
return fiber.NewError(fiber.StatusBadRequest, chickinDeletePopulationGuardMessage)
if err := s.ensureNoExecutedTransferForDelete(c.Context(), chickin.ProjectFlockKandangId); err != nil {
return err
}
actorID, err := m.ActorIDFromContext(c)
@@ -440,27 +469,51 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error {
}
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
if err := s.ensurePopulationRouteScope(c.Context(), tx); err != nil {
return err
}
chickinRepoTx := repository.NewChickinRepository(tx)
if chickin.UsageQty > 0 || chickin.PendingUsageQty > 0 {
if err := s.ReleaseChickinStocks(c.Context(), tx, chickin, actorID); err != nil {
lockedChickin, err := chickinRepoTx.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
return db.Clauses(clause.Locking{Strength: "UPDATE"})
})
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "Chickin not found")
}
return err
}
consumeAllocBefore, traceAllocBefore, err := s.countActiveChickinAllocations(c.Context(), tx, lockedChickin.Id)
if err != nil {
return err
}
s.Log.Infof(
"Delete chickin start id=%d usage=%.3f pending=%.3f active_consume_alloc=%d active_trace_alloc=%d",
lockedChickin.Id,
lockedChickin.UsageQty,
lockedChickin.PendingUsageQty,
consumeAllocBefore,
traceAllocBefore,
)
if err := s.ensureNoRelatedRecording(c.Context(), tx, lockedChickin); err != nil {
return err
}
hasActiveConsumeAlloc, err := s.hasActiveChickinConsumeAllocations(c.Context(), tx, lockedChickin.Id)
if err != nil {
return err
}
if lockedChickin.UsageQty > 0 || lockedChickin.PendingUsageQty > 0 || hasActiveConsumeAlloc {
if err := s.ReleaseChickinStocks(c.Context(), tx, lockedChickin, actorID); err != nil {
return err
}
}
now := time.Now().UTC()
note := "delete chickin rollback"
if err := tx.WithContext(c.Context()).
Model(&entity.StockAllocation{}).
Where("usable_type = ? AND usable_id = ? AND status = ?",
fifo.UsableKeyProjectChickin.String(),
chickin.Id,
entity.StockAllocationStatusActive,
).
Updates(map[string]any{
"status": entity.StockAllocationStatusReleased,
"released_at": now,
"note": note,
}).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal release alokasi FIFO chickin")
if err := s.rollbackChickinPopulation(c.Context(), tx, lockedChickin.Id); err != nil {
return err
}
if err := chickinRepoTx.DeleteOne(c.Context(), id); err != nil {
@@ -473,10 +526,29 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error {
return err
}
if err := s.syncChickinTraceForProductWarehouse(c.Context(), tx, chickin.ProductWarehouseId); err != nil {
reflowAsOf := normalizeDateOnlyUTC(lockedChickin.ChickInDate)
if reflowAsOf.IsZero() {
reflowAsOf = time.Now().UTC()
}
if err := s.reflowWarehouseAfterChickinDelete(c.Context(), tx, lockedChickin.ProductWarehouseId, reflowAsOf); err != nil {
return err
}
if err := s.syncChickinTraceForProductWarehouse(c.Context(), tx, lockedChickin.ProductWarehouseId); err != nil {
return err
}
consumeAllocAfter, traceAllocAfter, err := s.countActiveChickinAllocations(c.Context(), tx, lockedChickin.Id)
if err != nil {
return err
}
s.Log.Infof(
"Delete chickin complete id=%d active_consume_alloc=%d active_trace_alloc=%d",
lockedChickin.Id,
consumeAllocAfter,
traceAllocAfter,
)
return nil
})
if err != nil {
@@ -489,6 +561,325 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error {
return nil
}
func (s chickinService) ensureNoExecutedTransferForDelete(ctx context.Context, projectFlockKandangID uint) error {
if projectFlockKandangID == 0 || s.TransferLayingRepo == nil {
return nil
}
// Delete guard by executed transfer hanya untuk kandang kategori growing.
if s.ProjectflockKandangRepo != nil {
pfk, err := s.ProjectflockKandangRepo.GetByIDLight(ctx, projectFlockKandangID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Failed to resolve project flock kandang %d for delete guard: %+v", projectFlockKandangID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transfer laying")
}
if err == nil && pfk != nil {
category := strings.ToUpper(strings.TrimSpace(pfk.ProjectFlock.Category))
if category != strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)) {
return nil
}
}
}
isExecuted := func(transfer *entity.LayingTransfer) bool {
return transfer != nil && transfer.ExecutedAt != nil && !transfer.ExecutedAt.IsZero()
}
sourceTransfer, err := s.TransferLayingRepo.GetLatestApprovedBySourceKandang(ctx, projectFlockKandangID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Failed to resolve transfer laying by source kandang %d for delete guard: %+v", projectFlockKandangID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transfer laying")
}
targetTransfer, err := s.TransferLayingRepo.GetLatestApprovedByTargetKandang(ctx, projectFlockKandangID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Failed to resolve transfer laying by target kandang %d for delete guard: %+v", projectFlockKandangID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transfer laying")
}
if isExecuted(sourceTransfer) || isExecuted(targetTransfer) {
return fiber.NewError(
fiber.StatusBadRequest,
"Chickin tidak dapat dihapus karena transfer laying sudah dieksekusi. Lakukan unexecute transfer terlebih dahulu",
)
}
return nil
}
func (s *chickinService) resolveLayingTransferAvailableQty(ctx context.Context, tx *gorm.DB, targetProjectFlockKandangID, productWarehouseID uint) (float64, error) {
if targetProjectFlockKandangID == 0 || productWarehouseID == 0 {
return 0, nil
}
db := s.Repository.DB().WithContext(ctx)
if tx != nil {
db = tx.WithContext(ctx)
}
var available float64
err := db.Table("laying_transfer_targets ltt").
Select("COALESCE(SUM(GREATEST(0, COALESCE(ltt.total_qty,0) - COALESCE(ltt.total_used,0))), 0) AS available").
Joins("JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id AND lt.deleted_at IS NULL").
Where("ltt.deleted_at IS NULL").
Where("ltt.target_project_flock_kandang_id = ?", targetProjectFlockKandangID).
Where("ltt.product_warehouse_id = ?", productWarehouseID).
Where("lt.executed_at IS NOT NULL").
Where(`(
SELECT a.action
FROM approvals a
WHERE a.approvable_type = ?
AND a.approvable_id = lt.id
ORDER BY a.id DESC
LIMIT 1
) = ?`, string(utils.ApprovalWorkflowTransferToLaying), entity.ApprovalActionApproved).
Scan(&available).Error
if err != nil {
return 0, err
}
return available, nil
}
func (s chickinService) ensurePopulationRouteScope(ctx context.Context, tx *gorm.DB) error {
db := tx
if db == nil {
db = s.Repository.DB()
}
if db == nil {
return nil
}
now := time.Now().UTC()
result := db.WithContext(ctx).
Table("fifo_stock_v2_route_rules").
Where("is_active = TRUE").
Where("lane = ?", "STOCKABLE").
Where("function_code = ?", "POPULATION_IN").
Where("source_table = ?", "project_flock_populations").
Where("(scope_sql IS NULL OR TRIM(scope_sql) = '')").
Updates(map[string]any{
"scope_sql": "deleted_at IS NULL",
"updated_at": now,
})
if result.Error != nil {
s.Log.Errorf("Failed to enforce FIFO population route scope: %+v", result.Error)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi konfigurasi FIFO chickin")
}
if result.RowsAffected > 0 {
s.Log.Warnf(
"Auto-fixed FIFO population route scope for chickin flow (rows=%d)",
result.RowsAffected,
)
}
return nil
}
func (s *chickinService) ensureNoRelatedRecording(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin) error {
if chickin == nil || chickin.ProjectFlockKandangId == 0 {
return nil
}
db := s.Repository.DB().WithContext(ctx)
if tx != nil {
db = tx.WithContext(ctx)
}
recordDateFloor := normalizeDateOnlyUTC(chickin.ChickInDate)
var earliest entity.Recording
query := db.Model(&entity.Recording{}).
Where("project_flock_kandangs_id = ?", chickin.ProjectFlockKandangId).
Where("deleted_at IS NULL")
if !recordDateFloor.IsZero() {
query = query.Where("record_datetime >= ?", recordDateFloor)
}
if err := query.Order("record_datetime ASC, id ASC").Take(&earliest).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
s.Log.Errorf("Failed to validate related recordings for chickin %d: %+v", chickin.Id, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi recording terkait chickin")
}
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf(
"%s (recording tanggal %s)",
chickinDeleteRecordingGuardMessage,
normalizeDateOnlyUTC(earliest.RecordDatetime).Format("2006-01-02"),
),
)
}
func (s *chickinService) hasActiveChickinConsumeAllocations(ctx context.Context, tx *gorm.DB, chickinID uint) (bool, error) {
if tx == nil || chickinID == 0 {
return false, nil
}
var count int64
if err := tx.WithContext(ctx).
Model(&entity.StockAllocation{}).
Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ?",
fifo.UsableKeyProjectChickin.String(),
chickinID,
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
).
Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
func (s *chickinService) countActiveChickinAllocations(ctx context.Context, tx *gorm.DB, chickinID uint) (consume int64, trace int64, err error) {
if tx == nil || chickinID == 0 {
return 0, 0, nil
}
baseQuery := tx.WithContext(ctx).Model(&entity.StockAllocation{}).
Where("usable_type = ? AND usable_id = ? AND status = ?",
fifo.UsableKeyProjectChickin.String(),
chickinID,
entity.StockAllocationStatusActive,
)
if err := baseQuery.Session(&gorm.Session{}).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Count(&consume).Error; err != nil {
return 0, 0, err
}
if err := baseQuery.Session(&gorm.Session{}).
Where("allocation_purpose = ?", entity.StockAllocationPurposeTraceChickin).
Count(&trace).Error; err != nil {
return 0, 0, err
}
return consume, trace, nil
}
func (s *chickinService) reflowWarehouseAfterChickinDelete(ctx context.Context, tx *gorm.DB, productWarehouseID uint, asOf time.Time) error {
if tx == nil || productWarehouseID == 0 || s.FifoStockV2Svc == nil {
return nil
}
if err := s.ensurePopulationRouteScope(ctx, tx); err != nil {
return err
}
qtyBefore, hasQtyBefore := s.tryLoadWarehouseQty(ctx, tx, productWarehouseID)
flagGroupCode, err := s.resolveFlagGroupByProductWarehouse(ctx, tx, productWarehouseID)
if err != nil {
s.Log.Errorf("Failed to resolve flag group for delete chickin reflow pw=%d: %+v", productWarehouseID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal sinkronisasi stok setelah delete chickin")
}
if strings.TrimSpace(flagGroupCode) == "" {
return nil
}
result, err := s.FifoStockV2Svc.Reflow(ctx, commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: flagGroupCode,
ProductWarehouseID: productWarehouseID,
AsOf: &asOf,
Tx: tx,
})
if err != nil {
s.Log.Errorf("Failed to reflow warehouse after delete chickin pw=%d: %+v", productWarehouseID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal sinkronisasi stok setelah delete chickin")
}
processedUsables := 0
rollbackQty := 0.0
allocateQty := 0.0
if result != nil {
processedUsables = result.ProcessedUsables
rollbackQty = result.Rollback.ReleasedQty
allocateQty = result.Allocate.AllocatedQty
}
s.Log.Infof(
"Delete chickin warehouse reflow pw=%d processed_usables=%d rollback_qty=%.3f allocate_qty=%.3f",
productWarehouseID,
processedUsables,
rollbackQty,
allocateQty,
)
s.logWarehouseQtySnapshot(
ctx,
tx,
productWarehouseID,
"reflow_after_delete_chickin",
0,
hasQtyBefore,
qtyBefore,
)
return nil
}
func (s *chickinService) rollbackChickinPopulation(ctx context.Context, tx *gorm.DB, chickinID uint) error {
if tx == nil || chickinID == 0 {
return nil
}
var populationIDs []uint
if err := tx.WithContext(ctx).
Model(&entity.ProjectFlockPopulation{}).
Where("project_chickin_id = ?", chickinID).
Pluck("id", &populationIDs).Error; err != nil {
s.Log.Errorf("Failed to list population ids for chickin %d: %+v", chickinID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil population chickin")
}
if len(populationIDs) == 0 {
return nil
}
now := time.Now().UTC()
note := "delete chickin rollback population"
releaseResult := tx.WithContext(ctx).
Model(&entity.StockAllocation{}).
Where("stockable_type = ? AND stockable_id IN ? AND status = ?",
fifo.StockableKeyProjectFlockPopulation.String(),
populationIDs,
entity.StockAllocationStatusActive,
).
Where("NOT (usable_type = ? AND usable_id = ?)",
fifo.UsableKeyProjectChickin.String(),
chickinID,
).
Updates(map[string]any{
"status": entity.StockAllocationStatusReleased,
"released_at": now,
"note": note,
})
if releaseResult.Error != nil {
err := releaseResult.Error
s.Log.Errorf("Failed to release population allocation for chickin %d: %+v", chickinID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal release alokasi population chickin")
}
if releaseResult.RowsAffected > 0 {
s.Log.Infof(
"Delete chickin rollback population id=%d released_population_alloc=%d",
chickinID,
releaseResult.RowsAffected,
)
}
deleteResult := tx.WithContext(ctx).
Where("id IN ?", populationIDs).
Delete(&entity.ProjectFlockPopulation{})
if deleteResult.Error != nil {
err := deleteResult.Error
s.Log.Errorf("Failed to delete populations for chickin %d: %+v", chickinID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus population chickin")
}
if deleteResult.RowsAffected > 0 {
s.Log.Infof(
"Delete chickin rollback population id=%d deleted_population=%d",
chickinID,
deleteResult.RowsAffected,
)
}
return nil
}
func isForeignKeyViolation(err error) bool {
if err == nil {
return false
@@ -561,6 +952,9 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit
}
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
if err := s.ensurePopulationRouteScope(c.Context(), dbTransaction); err != nil {
return err
}
approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction))
chickinRepoTx := repository.NewChickinRepository(dbTransaction)
@@ -818,6 +1212,9 @@ func (s *chickinService) ReplenishChickinStocks(ctx context.Context, tx *gorm.DB
if asOf.IsZero() {
asOf = chickin.CreatedAt
}
if err := s.ensurePopulationRouteScope(ctx, tx); err != nil {
return err
}
return reflowChickinScope(ctx, s.FifoStockV2Svc, tx, targetPW.Id, &asOf)
}
@@ -828,12 +1225,296 @@ func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB,
if tx == nil {
return errors.New("transaction is required")
}
if chickin.ProductWarehouseId == 0 {
return s.Repository.UpdateUsageFields(ctx, tx, chickin.Id, 0, 0)
}
qtyBefore, hasQtyBefore := s.tryLoadWarehouseQty(ctx, tx, chickin.ProductWarehouseId)
if err := s.Repository.UpdateUsageFields(ctx, tx, chickin.Id, 0, 0); err != nil {
var activeConsumeCount int64
if err := tx.WithContext(ctx).
Model(&entity.StockAllocation{}).
Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ?",
fifo.UsableKeyProjectChickin.String(),
chickin.Id,
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
).
Count(&activeConsumeCount).Error; err != nil {
return err
}
if activeConsumeCount == 0 || s.FifoStockV2Svc == nil {
s.Log.Infof(
"Release chickin stock fallback id=%d active_consume_alloc=%d fifo_available=%t",
chickin.Id,
activeConsumeCount,
s.FifoStockV2Svc != nil,
)
if err := s.Repository.UpdateUsageFields(ctx, tx, chickin.Id, 0, 0); err != nil {
return err
}
s.logWarehouseQtySnapshot(
ctx,
tx,
chickin.ProductWarehouseId,
"release_chickin_fallback_no_active_alloc",
chickin.Id,
hasQtyBefore,
qtyBefore,
)
return nil
}
shouldRestoreWarehouseQty := true
if s.ProjectflockKandangRepo != nil && chickin.ProjectFlockKandangId != 0 {
pfk, err := s.ProjectflockKandangRepo.GetByIDLight(ctx, chickin.ProjectFlockKandangId)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if err == nil && pfk != nil {
category := strings.ToUpper(strings.TrimSpace(pfk.ProjectFlock.Category))
if category == strings.ToUpper(string(utils.ProjectFlockCategoryLaying)) {
shouldRestoreWarehouseQty = false
}
}
}
if !shouldRestoreWarehouseQty {
var affectedTransferTargetIDs []uint
if err := tx.WithContext(ctx).
Model(&entity.StockAllocation{}).
Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ? AND stockable_type = ?",
fifo.UsableKeyProjectChickin.String(),
chickin.Id,
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
fifo.StockableKeyTransferToLayingIn.String(),
).
Pluck("stockable_id", &affectedTransferTargetIDs).Error; err != nil {
return err
}
now := time.Now().UTC()
releaseResult := tx.WithContext(ctx).
Model(&entity.StockAllocation{}).
Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ?",
fifo.UsableKeyProjectChickin.String(),
chickin.Id,
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
).
Updates(map[string]any{
"status": entity.StockAllocationStatusReleased,
"released_at": now,
"note": "chickin rollback without qty adjust",
})
if releaseResult.Error != nil {
err := releaseResult.Error
return err
}
s.Log.Infof(
"Release chickin stock laying id=%d released_consume_alloc=%d transfer_targets=%d",
chickin.Id,
releaseResult.RowsAffected,
len(affectedTransferTargetIDs),
)
if err := s.resyncTransferTargetUsageFromAllocations(ctx, tx, affectedTransferTargetIDs); err != nil {
return err
}
if err := s.Repository.UpdateUsageFields(ctx, tx, chickin.Id, 0, 0); err != nil {
return err
}
s.logWarehouseQtySnapshot(
ctx,
tx,
chickin.ProductWarehouseId,
"release_chickin_laying_no_restore",
chickin.Id,
hasQtyBefore,
qtyBefore,
)
return nil
}
rollbackResult, err := s.FifoStockV2Svc.Rollback(ctx, commonSvc.FifoStockV2RollbackRequest{
ProductWarehouseID: chickin.ProductWarehouseId,
Usable: commonSvc.FifoStockV2Ref{
ID: chickin.Id,
LegacyTypeKey: fifo.UsableKeyProjectChickin.String(),
FunctionCode: "CHICKIN_OUT",
},
Reason: "delete/reject chickin rollback",
Tx: tx,
})
if err != nil {
s.Log.Errorf("Failed to rollback FIFO v2 for chickin %d: %+v", chickin.Id, err)
return err
}
releasedQty := 0.0
detailCount := 0
if rollbackResult != nil {
releasedQty = rollbackResult.ReleasedQty
detailCount = len(rollbackResult.Details)
}
s.Log.Infof(
"Release chickin stock fifo rollback id=%d released_qty=%.3f detail_count=%d",
chickin.Id,
releasedQty,
detailCount,
)
s.logWarehouseQtySnapshot(
ctx,
tx,
chickin.ProductWarehouseId,
"release_chickin_fifo_rollback",
chickin.Id,
hasQtyBefore,
qtyBefore,
)
return nil
}
func (s *chickinService) tryLoadWarehouseQty(ctx context.Context, tx *gorm.DB, productWarehouseID uint) (float64, bool) {
if tx == nil || productWarehouseID == 0 {
return 0, false
}
type row struct {
Qty float64 `gorm:"column:qty"`
}
out := row{}
if err := tx.WithContext(ctx).
Table("product_warehouses").
Select("COALESCE(qty, 0) AS qty").
Where("id = ?", productWarehouseID).
Take(&out).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, false
}
errText := strings.ToLower(strings.TrimSpace(err.Error()))
if strings.Contains(errText, "no such column") && strings.Contains(errText, "qty") {
return 0, false
}
if strings.Contains(errText, "column") && strings.Contains(errText, "qty") && strings.Contains(errText, "does not exist") {
return 0, false
}
s.Log.Warnf("Failed to load warehouse qty snapshot pw=%d: %+v", productWarehouseID, err)
return 0, false
}
return out.Qty, true
}
func (s *chickinService) logWarehouseQtySnapshot(
ctx context.Context,
tx *gorm.DB,
productWarehouseID uint,
stage string,
chickinID uint,
hasBefore bool,
beforeQty float64,
) {
afterQty, hasAfter := s.tryLoadWarehouseQty(ctx, tx, productWarehouseID)
if !hasBefore && !hasAfter {
return
}
if hasBefore && hasAfter {
s.Log.Infof(
"Warehouse qty snapshot stage=%s chickin_id=%d pw=%d before=%.3f after=%.3f delta=%.3f",
stage,
chickinID,
productWarehouseID,
beforeQty,
afterQty,
afterQty-beforeQty,
)
return
}
if hasAfter {
s.Log.Infof(
"Warehouse qty snapshot stage=%s chickin_id=%d pw=%d after=%.3f",
stage,
chickinID,
productWarehouseID,
afterQty,
)
return
}
s.Log.Infof(
"Warehouse qty snapshot stage=%s chickin_id=%d pw=%d before=%.3f",
stage,
chickinID,
productWarehouseID,
beforeQty,
)
}
func (s *chickinService) resyncTransferTargetUsageFromAllocations(ctx context.Context, tx *gorm.DB, transferTargetIDs []uint) error {
if tx == nil || len(transferTargetIDs) == 0 {
return nil
}
unique := make([]uint, 0, len(transferTargetIDs))
seen := make(map[uint]struct{}, len(transferTargetIDs))
for _, id := range transferTargetIDs {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
unique = append(unique, id)
}
if len(unique) == 0 {
return nil
}
if err := tx.WithContext(ctx).
Model(&entity.LayingTransferTarget{}).
Where("id IN ?", unique).
Update("total_used", 0).Error; err != nil {
return err
}
type usageRow struct {
StockableID uint `gorm:"column:stockable_id"`
Used float64 `gorm:"column:used"`
}
var usageRows []usageRow
if err := tx.WithContext(ctx).
Table("stock_allocations").
Select("stockable_id, COALESCE(SUM(qty), 0) AS used").
Where("stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()).
Where("status = ?", entity.StockAllocationStatusActive).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("stockable_id IN ?", unique).
Group("stockable_id").
Scan(&usageRows).Error; err != nil {
return err
}
for _, row := range usageRows {
if err := tx.WithContext(ctx).
Model(&entity.LayingTransferTarget{}).
Where("id = ?", row.StockableID).
Update("total_used", row.Used).Error; err != nil {
return err
}
}
return nil
}
func normalizeDateOnlyUTC(value time.Time) time.Time {
if value.IsZero() {
return time.Time{}
}
return time.Date(value.UTC().Year(), value.UTC().Month(), value.UTC().Day(), 0, 0, 0, 0, time.UTC)
}
func (s *chickinService) syncChickinTraceForProductWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) error {
@@ -849,14 +1530,9 @@ func (s *chickinService) syncChickinTraceForProductWarehouse(ctx context.Context
return s.syncChickinTraceForProductWarehouse(ctx, innerTx, productWarehouseID)
})
}
flagGroupCode, err := s.resolveFlagGroupByProductWarehouse(ctx, tx, productWarehouseID)
if err != nil {
if err := s.ensurePopulationRouteScope(ctx, tx); err != nil {
return err
}
if strings.TrimSpace(flagGroupCode) == "" {
return nil
}
now := time.Now()
if err := tx.WithContext(ctx).
@@ -874,6 +1550,14 @@ func (s *chickinService) syncChickinTraceForProductWarehouse(ctx context.Context
return err
}
flagGroupCode, err := s.resolveFlagGroupByProductWarehouse(ctx, tx, productWarehouseID)
if err != nil {
return err
}
if strings.TrimSpace(flagGroupCode) == "" {
return nil
}
type chickinTraceRow struct {
ID uint `gorm:"column:id"`
UsageQty float64 `gorm:"column:usage_qty"`
@@ -300,6 +300,12 @@ func (u *ProjectflockController) LookupProjectFlockKandang(c *fiber.Ctx) error {
mapped := warehouseDTO.ToWarehouseRelationDTO(*warehouse)
dtoResult.Warehouse = &mapped
}
if isTransition, isLaying, serr := u.ProjectflockService.GetProjectFlockKandangTransferState(c, result.Id); serr != nil {
return serr
} else {
dtoResult.IsTransition = isTransition
dtoResult.IsLaying = isLaying
}
if withPopulation {
population := dtoResult.AvailableQuantity
dtoResult.Population = &population
@@ -40,6 +40,8 @@ type ProjectFlockKandangDTO struct {
AvailableQuantity float64 `json:"available_quantity"`
Population *float64 `json:"population,omitempty"`
ChickInDate *time.Time `json:"chick_in_date,omitempty"`
IsTransition bool `json:"is_transition"`
IsLaying bool `json:"is_laying"`
}
func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangDTO {
@@ -17,6 +17,7 @@ import (
rProjectBudget "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
rProjectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
rTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/repositories"
sProjectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services"
utils "gitlab.com/mbugroup/lti-api.git/internal/utils"
@@ -35,6 +36,7 @@ func (ProjectflockModule) RegisterRoutes(router fiber.Router, db *gorm.DB, valid
projectflockKandangRepo := rProjectflock.NewProjectFlockKandangRepository(db)
projectFlockPopulationRepo := rProjectflock.NewProjectFlockPopulationRepository(db)
recordingRepo := rRecording.NewRecordingRepository(db)
transferLayingRepo := rTransferLaying.NewTransferLayingRepository(db)
warehouseRepo := rWarehouse.NewWarehouseRepository(db)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
projectBudgetRepo := rProjectBudget.NewProjectBudgetRepository(db)
@@ -46,7 +48,7 @@ func (ProjectflockModule) RegisterRoutes(router fiber.Router, db *gorm.DB, valid
panic(fmt.Sprintf("failed to register project flock approval workflow: %v", err))
}
projectflockService := sProjectflock.NewProjectflockService(projectflockRepo, flockRepo, kandangRepo, projectflockKandangRepo, warehouseRepo, productWarehouseRepo, projectBudgetRepo, nonstockRepo, projectFlockPopulationRepo, recordingRepo, approvalService, validate)
projectflockService := sProjectflock.NewProjectflockService(projectflockRepo, flockRepo, kandangRepo, projectflockKandangRepo, warehouseRepo, productWarehouseRepo, projectBudgetRepo, nonstockRepo, projectFlockPopulationRepo, recordingRepo, transferLayingRepo, approvalService, validate)
userService := sUser.NewUserService(userRepo, validate)
ProjectflockRoutes(router, userService, projectflockService)
@@ -23,6 +23,7 @@ import (
pfutils "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/utils"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations"
recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
transferLayingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/repositories"
uniformityRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/repositories"
purchaseRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories"
utils "gitlab.com/mbugroup/lti-api.git/internal/utils"
@@ -44,6 +45,7 @@ type ProjectflockService interface {
GetProjectFlockKandangByProjectAndKandang(ctx *fiber.Ctx, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, float64, error)
GetProjectFlockKandangPopulation(ctx *fiber.Ctx, projectFlockKandangID uint) (float64, error)
GetProjectFlockKandangChickinDate(ctx *fiber.Ctx, projectFlockKandangID uint) (*time.Time, error)
GetProjectFlockKandangTransferState(ctx *fiber.Ctx, projectFlockKandangID uint) (bool, bool, error)
GetPeriodSummary(ctx *fiber.Ctx, locationID uint) ([]KandangPeriodSummary, error)
GetProjectPeriods(ctx *fiber.Ctx, projectIDs []uint) (map[uint]int, error)
Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlock, error)
@@ -64,6 +66,7 @@ type projectflockService struct {
PivotRepo repository.ProjectFlockKandangRepository
PopulationRepo repository.ProjectFlockPopulationRepository
RecordingRepo recordingRepo.RecordingRepository
TransferLayingRepo transferLayingRepo.TransferLayingRepository
ApprovalSvc commonSvc.ApprovalService
approvalWorkflow approvalutils.ApprovalWorkflowKey
}
@@ -85,6 +88,7 @@ func NewProjectflockService(
nonstockRepo nonstockRepository.NonstockRepository,
populationRepo repository.ProjectFlockPopulationRepository,
recordingRepo recordingRepo.RecordingRepository,
transferLayingRepo transferLayingRepo.TransferLayingRepository,
approvalSvc commonSvc.ApprovalService,
validate *validator.Validate,
@@ -102,6 +106,7 @@ func NewProjectflockService(
PivotRepo: pivotRepo,
PopulationRepo: populationRepo,
RecordingRepo: recordingRepo,
TransferLayingRepo: transferLayingRepo,
ApprovalSvc: approvalSvc,
approvalWorkflow: utils.ApprovalWorkflowProjectFlock,
}
@@ -538,6 +543,63 @@ func (s projectflockService) GetProjectFlockKandangChickinDate(ctx *fiber.Ctx, p
return earliest, nil
}
func (s projectflockService) GetProjectFlockKandangTransferState(ctx *fiber.Ctx, projectFlockKandangID uint) (bool, bool, error) {
if projectFlockKandangID == 0 || s.TransferLayingRepo == nil || s.PivotRepo == nil {
return false, false, nil
}
pfk, err := s.PivotRepo.GetByIDLight(ctx.Context(), projectFlockKandangID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, false, nil
}
s.Log.Errorf("Failed to resolve project flock kandang %d for transfer state: %+v", projectFlockKandangID, err)
return false, false, fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve transfer state")
}
category := strings.ToUpper(strings.TrimSpace(pfk.ProjectFlock.Category))
var transfer *entity.LayingTransfer
switch category {
case strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)):
transfer, err = s.TransferLayingRepo.GetLatestApprovedBySourceKandang(ctx.Context(), projectFlockKandangID)
case strings.ToUpper(string(utils.ProjectFlockCategoryLaying)):
transfer, err = s.TransferLayingRepo.GetLatestApprovedByTargetKandang(ctx.Context(), projectFlockKandangID)
default:
return false, false, nil
}
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, false, nil
}
s.Log.Errorf("Failed to resolve transfer state for project flock kandang %d: %+v", projectFlockKandangID, err)
return false, false, fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve transfer state")
}
if transfer == nil {
return false, false, nil
}
physicalMoveDate := normalizeDateOnlyUTC(transfer.TransferDate)
if physicalMoveDate.IsZero() {
return false, false, nil
}
economicCutoffDate := physicalMoveDate
if transfer.EconomicCutoffDate != nil && !transfer.EconomicCutoffDate.IsZero() {
economicCutoffDate = normalizeDateOnlyUTC(*transfer.EconomicCutoffDate)
} else if transfer.EffectiveMoveDate != nil && !transfer.EffectiveMoveDate.IsZero() {
economicCutoffDate = normalizeDateOnlyUTC(*transfer.EffectiveMoveDate)
}
if economicCutoffDate.Before(physicalMoveDate) {
economicCutoffDate = physicalMoveDate
}
referenceDate := normalizeDateOnlyUTC(time.Now().UTC())
isTransition := !referenceDate.Before(physicalMoveDate) && referenceDate.Before(economicCutoffDate)
isLaying := !referenceDate.Before(economicCutoffDate)
return isTransition, isLaying, nil
}
func (s projectflockService) GetProjectFlockKandangByParams(ctx *fiber.Ctx, idStr string, projectFlockIdStr string, kandangIdStr string) (*entity.ProjectFlockKandang, float64, error) {
idStr = strings.TrimSpace(idStr)
projectFlockIdStr = strings.TrimSpace(projectFlockIdStr)
@@ -579,6 +641,10 @@ func (s projectflockService) GetProjectFlockKandangByParams(ctx *fiber.Ctx, idSt
return s.GetProjectFlockKandangByProjectAndKandang(ctx, uint(pfid), uint(kid))
}
func normalizeDateOnlyUTC(value time.Time) time.Time {
return time.Date(value.UTC().Year(), value.UTC().Month(), value.UTC().Day(), 0, 0, 0, 0, time.UTC)
}
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")
@@ -86,6 +86,8 @@ type RecordingRelationDTO struct {
EggWeight float64 `json:"egg_weight"`
PopulationCanChange bool `json:"population_can_change"`
TransferExecuted *bool `json:"transfer_executed,omitempty"`
IsTransition bool `json:"is_transition"`
IsLaying bool `json:"is_laying"`
Approval approvalDTO.ApprovalRelationDTO `json:"approval"`
}
@@ -247,6 +249,8 @@ func toRecordingRelationDTO(e entity.Recording) RecordingRelationDTO {
EggWeight: floatValue(e.EggWeight),
PopulationCanChange: boolValueDefault(e.PopulationCanChange, true),
TransferExecuted: e.TransferExecuted,
IsTransition: boolValueDefault(e.IsTransition, false),
IsLaying: boolValueDefault(e.IsLaying, false),
Approval: latestApproval,
}
}
@@ -125,6 +125,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
nonstockRepo,
projectFlockPopulationRepo,
recordingRepo,
transferLayingRepo,
approvalService,
validate,
)
@@ -185,12 +185,14 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
rate := recordingutil.ComputeDepletionRate(prev, current, totalChick)
recordings[i].DepletionRate = &rate
populationCanChange, transferExecuted, _, _, stateErr := s.evaluatePopulationMutationState(c.Context(), &recordings[i])
populationCanChange, transferExecuted, isTransition, isLaying, _, _, stateErr := s.evaluatePopulationMutationState(c.Context(), &recordings[i])
if stateErr != nil {
return nil, 0, stateErr
}
recordings[i].PopulationCanChange = boolPtr(populationCanChange)
recordings[i].TransferExecuted = boolPtr(transferExecuted)
recordings[i].IsTransition = boolPtr(isTransition)
recordings[i].IsLaying = boolPtr(isLaying)
}
return recordings, total, nil
}
@@ -251,12 +253,14 @@ func (s recordingService) GetOne(c *fiber.Ctx, id uint) (*entity.Recording, erro
recording.DepletionRate = &rate
}
populationCanChange, transferExecuted, _, _, stateErr := s.evaluatePopulationMutationState(c.Context(), recording)
populationCanChange, transferExecuted, isTransition, isLaying, _, _, stateErr := s.evaluatePopulationMutationState(c.Context(), recording)
if stateErr != nil {
return nil, stateErr
}
recording.PopulationCanChange = boolPtr(populationCanChange)
recording.TransferExecuted = boolPtr(transferExecuted)
recording.IsTransition = boolPtr(isTransition)
recording.IsLaying = boolPtr(isLaying)
return recording, nil
}
@@ -990,46 +994,58 @@ func (s *recordingService) resolveRecordingCategory(ctx context.Context, recordi
return strings.ToUpper(strings.TrimSpace(pfk.ProjectFlock.Category)), nil
}
func (s *recordingService) evaluatePopulationMutationState(ctx context.Context, recording *entity.Recording) (bool, bool, *entity.LayingTransfer, time.Time, error) {
func (s *recordingService) evaluatePopulationMutationState(ctx context.Context, recording *entity.Recording) (bool, bool, bool, bool, *entity.LayingTransfer, time.Time, error) {
if recording == nil || recording.ProjectFlockKandangId == 0 || s.TransferLayingRepo == nil {
return true, false, nil, time.Time{}, 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, nil, time.Time{}, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi perubahan populasi recording")
}
if category != strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)) {
return true, false, nil, time.Time{}, nil
return true, false, false, false, nil, time.Time{}, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi perubahan populasi recording")
}
transfer, err := s.TransferLayingRepo.GetLatestApprovedBySourceKandang(ctx, recording.ProjectFlockKandangId)
var transfer *entity.LayingTransfer
switch category {
case strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)):
transfer, err = s.TransferLayingRepo.GetLatestApprovedBySourceKandang(ctx, recording.ProjectFlockKandangId)
case strings.ToUpper(string(utils.ProjectFlockCategoryLaying)):
transfer, err = s.TransferLayingRepo.GetLatestApprovedByTargetKandang(ctx, recording.ProjectFlockKandangId)
default:
return true, false, false, false, nil, time.Time{}, nil
}
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return true, false, nil, time.Time{}, nil
return true, false, false, false, nil, time.Time{}, nil
}
s.Log.Errorf("Failed to resolve approved transfer by source kandang for recording %d: %+v", recording.Id, err)
return true, false, nil, time.Time{}, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi perubahan populasi recording")
s.Log.Errorf("Failed to resolve approved transfer for recording %d: %+v", recording.Id, err)
return true, false, false, false, nil, time.Time{}, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi perubahan populasi recording")
}
if transfer == nil {
return true, false, nil, time.Time{}, nil
return true, false, false, false, nil, time.Time{}, nil
}
transferDate := transferPhysicalMoveDate(transfer)
if transferDate.IsZero() {
return true, false, transfer, transferDate, nil
return true, false, false, false, transfer, transferDate, nil
}
transferExecuted := transfer.ExecutedAt != nil && !transfer.ExecutedAt.IsZero()
recordDate := normalizeDateOnlyUTC(recording.RecordDatetime)
populationCanChange := !(transferExecuted && !recordDate.Before(transferDate))
_, economicCutoffDate := transferRecordingWindow(transfer)
isTransition := !recordDate.Before(transferDate) && recordDate.Before(economicCutoffDate)
isLaying := !recordDate.Before(economicCutoffDate)
return populationCanChange, transferExecuted, transfer, transferDate, nil
populationCanChange := true
if category == strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)) {
populationCanChange = !(transferExecuted && !recordDate.Before(transferDate))
}
return populationCanChange, transferExecuted, isTransition, isLaying, transfer, transferDate, nil
}
func (s *recordingService) ensurePopulationMutationAllowed(ctx context.Context, recording *entity.Recording, operation string) error {
populationCanChange, _, transfer, transferDate, err := s.evaluatePopulationMutationState(ctx, recording)
populationCanChange, _, _, _, transfer, transferDate, err := s.evaluatePopulationMutationState(ctx, recording)
if err != nil {
return err
}
@@ -1590,7 +1606,7 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm
var feedIntake float64
if remainingChick > 0 && usageInGrams > 0 {
feedIntake = (usageInGrams / remainingChick) * 1000
feedIntake = usageInGrams / remainingChick
updates["feed_intake"] = feedIntake
recording.FeedIntake = &feedIntake
} else {
@@ -15,6 +15,11 @@ const (
transferLayingInFunctionCode = "TRANSFER_TO_LAYING_IN"
transferLayingStockableLane = "STOCKABLE"
transferLayingSourceTable = "laying_transfer_targets"
transferLayingOutFunctionCode = "TRANSFER_TO_LAYING_OUT"
transferLayingUsableLane = "USABLE"
transferLayingUsableSourceTable = "laying_transfers"
transferLayingLegacyUsableSourceTable = "laying_transfer_sources"
)
func reflowTransferLayingScope(
@@ -85,3 +90,90 @@ func resolveTransferLayingFlagGroupByProductWarehouse(ctx context.Context, tx *g
return strings.TrimSpace(selected.FlagGroupCode), nil
}
type transferLayingUsableRouteRule struct {
FlagGroupCode string `gorm:"column:flag_group_code"`
SourceTable string `gorm:"column:source_table"`
}
func resolveTransferLayingUsableFlagGroupByProductWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) (string, error) {
rows := make([]transferLayingUsableRouteRule, 0)
err := tx.WithContext(ctx).
Table("fifo_stock_v2_route_rules rr").
Select("rr.flag_group_code, rr.source_table").
Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE").
Where("rr.is_active = TRUE").
Where("rr.lane = ?", transferLayingUsableLane).
Where("rr.function_code = ?", transferLayingOutFunctionCode).
Where(`
EXISTS (
SELECT 1
FROM product_warehouses pw
JOIN flags f ON f.flagable_id = pw.product_id
JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE
WHERE pw.id = ?
AND f.flagable_type = ?
AND fm.flag_group_code = rr.flag_group_code
)
`, productWarehouseID, entity.FlagableTypeProduct).
Order("rr.id ASC").
Find(&rows).Error
if err != nil {
return "", err
}
return validateTransferLayingUsableRouteRules(rows, productWarehouseID)
}
func validateTransferLayingUsableRouteRules(rows []transferLayingUsableRouteRule, productWarehouseID uint) (string, error) {
if len(rows) == 0 {
return "", fmt.Errorf(
"konfigurasi FIFO v2 TRANSFER_TO_LAYING_OUT tidak ditemukan untuk source warehouse %d",
productWarehouseID,
)
}
var selectedFlagGroup string
hasHeaderRule := false
hasLegacyRule := false
for _, row := range rows {
sourceTable := strings.ToLower(strings.TrimSpace(row.SourceTable))
flagGroupCode := strings.TrimSpace(row.FlagGroupCode)
switch sourceTable {
case transferLayingUsableSourceTable:
if flagGroupCode == "" {
return "", fmt.Errorf("konfigurasi FIFO v2 TRANSFER_TO_LAYING_OUT memiliki flag_group_code kosong")
}
hasHeaderRule = true
if selectedFlagGroup == "" {
selectedFlagGroup = flagGroupCode
continue
}
if selectedFlagGroup != flagGroupCode {
return "", fmt.Errorf(
"konfigurasi FIFO v2 TRANSFER_TO_LAYING_OUT ambigu untuk source warehouse %d",
productWarehouseID,
)
}
case transferLayingLegacyUsableSourceTable:
hasLegacyRule = true
}
}
if hasLegacyRule {
return "", fmt.Errorf(
"konfigurasi FIFO v2 legacy untuk TRANSFER_TO_LAYING_OUT masih aktif (source_table=%s)",
transferLayingLegacyUsableSourceTable,
)
}
if !hasHeaderRule {
return "", fmt.Errorf(
"konfigurasi FIFO v2 TRANSFER_TO_LAYING_OUT aktif untuk source_table=%s tidak ditemukan",
transferLayingUsableSourceTable,
)
}
return selectedFlagGroup, nil
}
@@ -0,0 +1,56 @@
package service
import (
"strings"
"testing"
)
func TestValidateTransferLayingUsableRouteRules(t *testing.T) {
t.Run("valid header rule", func(t *testing.T) {
flagGroup, err := validateTransferLayingUsableRouteRules([]transferLayingUsableRouteRule{
{FlagGroupCode: "AYAM", SourceTable: transferLayingUsableSourceTable},
}, 10)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if flagGroup != "AYAM" {
t.Fatalf("unexpected flag group: %s", flagGroup)
}
})
t.Run("missing usable header rule", func(t *testing.T) {
_, err := validateTransferLayingUsableRouteRules(nil, 10)
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(strings.ToLower(err.Error()), "tidak ditemukan") {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("legacy rule still active", func(t *testing.T) {
_, err := validateTransferLayingUsableRouteRules([]transferLayingUsableRouteRule{
{FlagGroupCode: "AYAM", SourceTable: transferLayingUsableSourceTable},
{FlagGroupCode: "AYAM", SourceTable: transferLayingLegacyUsableSourceTable},
}, 10)
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(strings.ToLower(err.Error()), "legacy") {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("ambiguous active header rules", func(t *testing.T) {
_, err := validateTransferLayingUsableRouteRules([]transferLayingUsableRouteRule{
{FlagGroupCode: "AYAM", SourceTable: transferLayingUsableSourceTable},
{FlagGroupCode: "PAKAN", SourceTable: transferLayingUsableSourceTable},
}, 10)
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(strings.ToLower(err.Error()), "ambigu") {
t.Fatalf("unexpected error: %v", err)
}
})
}
@@ -1026,6 +1026,33 @@ func (s transferLayingService) Unexecute(c *fiber.Ctx, id uint) (*entity.LayingT
}
}
flagGroupCode, err := resolveTransferLayingUsableFlagGroupByProductWarehouse(
c.Context(),
dbTransaction,
*transfer.SourceProductWarehouseId,
)
if err != nil {
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Konfigurasi FIFO v2 transfer laying tidak valid: %v", err),
)
}
activeConsumeAllocCount, err := s.countActiveTransferSourceConsumeAllocations(
c.Context(),
dbTransaction,
transfer.Id,
*transfer.SourceProductWarehouseId,
)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi alokasi FIFO source transfer laying")
}
if transfer.SourceUsageQty > 1e-6 && activeConsumeAllocCount == 0 {
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Unexecute transfer laying %s gagal: alokasi FIFO source tidak ditemukan", transfer.TransferNumber),
)
}
for _, target := range targets {
if target.ProductWarehouseId == nil || *target.ProductWarehouseId == 0 {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Target product warehouse tidak ditemukan untuk transfer %d", transfer.Id))
@@ -1067,21 +1094,40 @@ func (s transferLayingService) Unexecute(c *fiber.Ctx, id uint) (*entity.LayingT
}
}
asOf := normalizeDateOnlyUTC(transfer.TransferDate)
rollbackResult, err := s.FifoStockV2Svc.Rollback(c.Context(), commonSvc.FifoStockV2RollbackRequest{
ProductWarehouseID: *transfer.SourceProductWarehouseId,
Usable: commonSvc.FifoStockV2Ref{
ID: transfer.Id,
LegacyTypeKey: fifo.UsableKeyTransferToLayingOut.String(),
FunctionCode: transferLayingOutFunctionCode,
},
Reason: fmt.Sprintf("transfer laying unexecute #%s [%s]", transfer.TransferNumber, flagGroupCode),
Tx: dbTransaction,
})
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal rollback FIFO v2 source transfer laying: %v", err))
}
releasedQty := 0.0
if rollbackResult != nil {
releasedQty = rollbackResult.ReleasedQty
}
if transfer.SourceUsageQty > 1e-6 && releasedQty < transfer.SourceUsageQty-1e-6 {
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf(
"Rollback FIFO v2 source transfer laying tidak lengkap. Dibutuhkan %.3f, terlepas %.3f",
transfer.SourceUsageQty,
releasedQty,
),
)
}
if err := repoTx.PatchOne(c.Context(), transfer.Id, map[string]any{
"source_usage_qty": 0,
"source_pending_usage_qty": 0,
}, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal reset kuantitas source transfer laying")
}
if _, err := s.FifoStockV2Svc.Reflow(c.Context(), commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: transferToLayingFlagGroupCode,
ProductWarehouseID: *transfer.SourceProductWarehouseId,
AsOf: &asOf,
Tx: dbTransaction,
}); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal rollback FIFO v2 source transfer laying: %v", err))
}
if err := fifoV2.ReleasePopulationConsumptionByUsable(
c.Context(),
dbTransaction,
@@ -1576,6 +1622,34 @@ func (s *transferLayingService) hasDownstreamRecordingOnTarget(
return true, normalizeDateOnlyUTC(earliest.RecordDatetime), nil
}
func (s *transferLayingService) countActiveTransferSourceConsumeAllocations(
ctx context.Context,
tx *gorm.DB,
transferID uint,
productWarehouseID uint,
) (int64, error) {
if transferID == 0 || productWarehouseID == 0 {
return 0, nil
}
if tx == nil {
return 0, errors.New("transaction is required")
}
var count int64
if err := tx.WithContext(ctx).
Model(&entity.StockAllocation{}).
Where("product_warehouse_id = ?", productWarehouseID).
Where("usable_type = ?", fifo.UsableKeyTransferToLayingOut.String()).
Where("usable_id = ?", transferID).
Where("status = ?", entity.StockAllocationStatusActive).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (s *transferLayingService) resyncPopulationUsageByProjectFlockKandang(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error {
if projectFlockKandangID == 0 {
return nil