Merge branch 'dev/fifo-v2' into 'development'

Implement delete transfer to laying, chickin, and stock adjustment

See merge request mbugroup/lti-api!371
This commit is contained in:
Adnan Zahir
2026-03-17 11:07:52 +07:00
52 changed files with 4244 additions and 820 deletions
@@ -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)
@@ -3,6 +3,7 @@ package dto
import (
"time"
"gitlab.com/mbugroup/lti-api.git/internal/config"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
areaRelationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto"
flockRelationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/dto"
@@ -35,13 +36,13 @@ type ChickinRelationDTO struct {
}
type ProjectFlockDTO struct {
Id uint `json:"id"`
Period int `json:"period"`
Category string `json:"category"`
Flock *flockRelationDTO.FlockRelationDTO `json:"flock"`
Area *areaRelationDTO.AreaRelationDTO `json:"area"`
StandardFcr *float64 `json:"standard_fcr"`
Location *locationRelationDTO.LocationRelationDTO `json:"location"`
Id uint `json:"id"`
Period int `json:"period"`
Category string `json:"category"`
Flock *flockRelationDTO.FlockRelationDTO `json:"flock"`
Area *areaRelationDTO.AreaRelationDTO `json:"area"`
StandardFcr *float64 `json:"standard_fcr"`
Location *locationRelationDTO.LocationRelationDTO `json:"location"`
}
type ProjectFlockKandangDTO struct {
@@ -123,13 +124,13 @@ func ToProjectFlockDTO(pfk entity.ProjectFlockKandang) ProjectFlockDTO {
location = &mapped
}
return ProjectFlockDTO{
Id: e.Id,
Period: pfk.Period,
Category: e.Category,
Flock: flock,
Area: area,
Id: e.Id,
Period: pfk.Period,
Category: e.Category,
Flock: flock,
Area: area,
StandardFcr: resolveProjectFlockStandardFcr(e),
Location: location,
Location: location,
}
}
@@ -219,7 +220,7 @@ func resolveProjectFlockStandardFcr(e entity.ProjectFlock) *float64 {
}
week := 1
if e.Category == string(utils.ProjectFlockCategoryLaying) {
week = 18
week = config.LayingWeekStart()
}
for _, detail := range e.ProductionStandard.ProductionStandardDetails {
if detail.Week == week && detail.StandardFCR != nil {
@@ -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)
}
File diff suppressed because it is too large Load Diff
@@ -7,14 +7,14 @@ import (
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
sProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project-flock-kandangs/services"
rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
rExpense "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories"
rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories"
rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories"
sProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project-flock-kandangs/services"
rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
@@ -33,13 +33,14 @@ func (ProjectFlockKandangModule) RegisterRoutes(router fiber.Router, db *gorm.DB
approvalRepo := commonRepo.NewApprovalRepository(db)
approvalService := commonSvc.NewApprovalService(approvalRepo)
fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log)
// register workflow steps for chickin approvals
if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowProjectFlockKandang, utils.ProjectFlockKandangApprovalSteps); err != nil {
panic(fmt.Sprintf("failed to register chickin approval workflow: %v", err))
}
expenseRepo := rExpense.NewExpenseRepository(db)
projectFlockKandangService := sProjectFlockKandang.NewProjectFlockKandangService(projectFlockKandangRepo, approvalService, expenseRepo, warehouseRepo, productWarehouseRepo, projectFlockPopulationRepo,kandangRepo, validate)
projectFlockKandangService := sProjectFlockKandang.NewProjectFlockKandangService(projectFlockKandangRepo, approvalService, fifoStockV2Service, expenseRepo, warehouseRepo, productWarehouseRepo, projectFlockPopulationRepo, kandangRepo, validate)
userService := sUser.NewUserService(userRepo, validate)
ProjectFlockKandangRoutes(router, userService, projectFlockKandangService)
@@ -1,8 +1,10 @@
package service
import (
"context"
"errors"
"fmt"
"math"
"strings"
"time"
@@ -35,6 +37,7 @@ type projectFlockKandangService struct {
Validate *validator.Validate
Repository repository.ProjectFlockKandangRepository
ApprovalSvc commonSvc.ApprovalService
FifoStockV2Svc commonSvc.FifoStockV2Service
ExpenseRepo expenseRepo.ExpenseRepository
WarehouseRepo rWarehouse.WarehouseRepository
ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository
@@ -69,12 +72,13 @@ type ExpenseSummary struct {
Reference string `json:"reference_number"`
}
func NewProjectFlockKandangService(repo repository.ProjectFlockKandangRepository, approvalSvc commonSvc.ApprovalService, expenseRepo expenseRepo.ExpenseRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, populationRepo repository.ProjectFlockPopulationRepository, kandangRepo kandangRepo.KandangRepository, validate *validator.Validate) ProjectFlockKandangService {
func NewProjectFlockKandangService(repo repository.ProjectFlockKandangRepository, approvalSvc commonSvc.ApprovalService, fifoStockV2Svc commonSvc.FifoStockV2Service, expenseRepo expenseRepo.ExpenseRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, populationRepo repository.ProjectFlockPopulationRepository, kandangRepo kandangRepo.KandangRepository, validate *validator.Validate) ProjectFlockKandangService {
return &projectFlockKandangService{
Log: utils.Log,
Validate: validate,
Repository: repo,
ApprovalSvc: approvalSvc,
FifoStockV2Svc: fifoStockV2Svc,
ExpenseRepo: expenseRepo,
WarehouseRepo: warehouseRepo,
ProductWarehouseRepo: productWarehouseRepo,
@@ -694,7 +698,91 @@ func (s projectFlockKandangService) calculateAvailableQuantityForProductWarehous
if availableQty < 0 {
availableQty = 0
}
sourceAvailable, err := s.resolveLayingSourceAvailableQty(c.Context(), nil, productWarehouse.Id, nil)
if err != nil {
return 0, err
}
if sourceAvailable < availableQty {
availableQty = sourceAvailable
}
}
return availableQty, nil
}
func (s projectFlockKandangService) resolveLayingSourceAvailableQty(ctx context.Context, tx *gorm.DB, productWarehouseID uint, asOf *time.Time) (float64, error) {
if productWarehouseID == 0 || s.FifoStockV2Svc == nil {
return 0, nil
}
flagGroupCode, err := s.resolveFlagGroupByProductWarehouse(ctx, tx, productWarehouseID)
if err != nil {
return 0, err
}
if strings.TrimSpace(flagGroupCode) == "" {
return 0, nil
}
gatherRows, err := s.FifoStockV2Svc.Gather(ctx, commonSvc.FifoStockV2GatherRequest{
FlagGroupCode: flagGroupCode,
Lane: commonSvc.FifoStockV2Lane("STOCKABLE"),
AllocationPurpose: entity.StockAllocationPurposeConsume,
ProductWarehouseID: productWarehouseID,
AsOf: asOf,
Limit: 10000,
Tx: tx,
})
if err != nil {
return 0, err
}
total := 0.0
for _, row := range gatherRows {
if row.AvailableQuantity <= 0 {
continue
}
total += row.AvailableQuantity
}
return math.Max(total, 0), nil
}
func (s projectFlockKandangService) resolveFlagGroupByProductWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) (string, error) {
type row struct {
FlagGroupCode string `gorm:"column:flag_group_code"`
}
selected := row{}
db := s.Repository.DB()
if tx != nil {
db = tx
}
err := db.WithContext(ctx).
Table("fifo_stock_v2_route_rules rr").
Select("rr.flag_group_code").
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 = 'STOCKABLE'").
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("fg.priority ASC, rr.id ASC").
Limit(1).
Take(&selected).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", nil
}
return "", err
}
return strings.TrimSpace(selected.FlagGroupCode), nil
}
@@ -6,6 +6,7 @@ import (
"math"
"strconv"
"strings"
"time"
warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto"
"gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/dto"
@@ -62,6 +63,7 @@ func (u *ProjectflockController) GetAll(c *fiber.Ctx) error {
Search: c.Query("search", ""),
SortBy: c.Query("sort_by", ""),
SortOrder: c.Query("sort_order", ""),
Status: strings.TrimSpace(c.Query("status", "")),
}
if area := c.QueryInt("area_id", 0); area > 0 {
@@ -272,10 +274,20 @@ func (u *ProjectflockController) LookupProjectFlockKandang(c *fiber.Ctx) error {
projectFlockId := c.QueryInt("project_flock_id", 0)
kandangId := c.QueryInt("kandang_id", 0)
withPopulation := c.QueryBool("withpopulation", false)
recordDateRaw := strings.TrimSpace(c.Query("record_date", ""))
var recordDate *time.Time
if projectFlockId == 0 || kandangId == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id or kandang_id")
}
if recordDateRaw != "" {
parsed, err := time.Parse("2006-01-02", recordDateRaw)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "record_date must be in YYYY-MM-DD format")
}
utc := parsed.UTC()
recordDate = &utc
}
result, availableStock, err := u.ProjectflockService.GetProjectFlockKandangByProjectAndKandang(c, uint(projectFlockId), uint(kandangId))
if err != nil {
@@ -300,6 +312,12 @@ func (u *ProjectflockController) LookupProjectFlockKandang(c *fiber.Ctx) error {
mapped := warehouseDTO.ToWarehouseRelationDTO(*warehouse)
dtoResult.Warehouse = &mapped
}
if isTransition, isLaying, serr := u.ProjectflockService.GetProjectFlockKandangTransferStateAtDate(c, result.Id, recordDate); serr != nil {
return serr
} else {
dtoResult.IsTransition = isTransition
dtoResult.IsLaying = isLaying
}
if withPopulation {
population := dtoResult.AvailableQuantity
dtoResult.Population = &population
@@ -3,6 +3,7 @@ package dto
import (
"time"
"gitlab.com/mbugroup/lti-api.git/internal/config"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
areaDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto"
@@ -25,17 +26,17 @@ type ProjectFlockRelationDTO struct {
type ProjectFlockListDTO struct {
ProjectFlockRelationDTO
Area *areaDTO.AreaRelationDTO `json:"area,omitempty"`
Category string `json:"category"`
StandardFcr *float64 `json:"standard_fcr,omitempty"`
Area *areaDTO.AreaRelationDTO `json:"area,omitempty"`
Category string `json:"category"`
StandardFcr *float64 `json:"standard_fcr,omitempty"`
ProductionStandard *productionStandardDTO.ProductionStandardRelationDTO `json:"production_standard,omitempty"`
Location *locationDTO.LocationRelationDTO `json:"location,omitempty"`
Kandangs []KandangWithProjectFlockIdDTO `json:"kandangs,omitempty"`
ProjectBudgets []ProjectBudgetDTO `json:"project_budgets,omitempty"`
CreatedUser *userDTO.UserRelationDTO `json:"created_user"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Approval approvalDTO.ApprovalRelationDTO `json:"approval"`
Location *locationDTO.LocationRelationDTO `json:"location,omitempty"`
Kandangs []KandangWithProjectFlockIdDTO `json:"kandangs,omitempty"`
ProjectBudgets []ProjectBudgetDTO `json:"project_budgets,omitempty"`
CreatedUser *userDTO.UserRelationDTO `json:"created_user"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Approval approvalDTO.ApprovalRelationDTO `json:"approval"`
}
type KandangWithProjectFlockIdDTO struct {
@@ -212,7 +213,7 @@ func resolveProjectFlockStandardFcr(e entity.ProjectFlock) *float64 {
}
week := 1
if e.Category == string(utils.ProjectFlockCategoryLaying) {
week = 18
week = config.LayingWeekStart()
}
for _, detail := range e.ProductionStandard.ProductionStandardDetails {
if detail.Week == week && detail.StandardFCR != nil {
@@ -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)
@@ -51,6 +51,7 @@ func (r *projectFlockPopulationRepositoryImpl) GetByProjectFlockKandangID(ctx co
err := r.DB().WithContext(ctx).
Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id").
Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangID).
Where("project_chickins.deleted_at IS NULL").
Preload("ProjectChickin").
Find(&records).Error
if err != nil {
@@ -87,6 +88,7 @@ func (r *projectFlockPopulationRepositoryImpl) GetByProjectFlockKandangIDAndProd
err := r.DB().WithContext(ctx).
Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id").
Where("project_chickins.project_flock_kandang_id = ? AND project_flock_populations.product_warehouse_id = ?", projectFlockKandangID, productWarehouseID).
Where("project_chickins.deleted_at IS NULL").
Find(&records).Error
if err != nil {
return nil, err
@@ -99,8 +101,10 @@ func (r *projectFlockPopulationRepositoryImpl) GetTotalQtyByProjectFlockKandangI
err := r.DB().WithContext(ctx).
Table("project_flock_populations").
Select("COALESCE(SUM(total_qty - total_used_qty), 0) AS available_qty").
Joins("JOIN product_warehouses pw ON project_flock_populations.product_warehouse_id = pw.id").
Where("pw.project_flock_kandang_id = ?", projectFlockKandangID).
Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id").
Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangID).
Where("project_chickins.deleted_at IS NULL").
Where("project_flock_populations.deleted_at IS NULL").
Scan(&total).Error
if err != nil {
return 0, err
@@ -111,9 +115,12 @@ func (r *projectFlockPopulationRepositoryImpl) GetTotalQtyByProjectFlockKandangI
func (r *projectFlockPopulationRepositoryImpl) GetTotalQtyByProductWarehouseID(ctx context.Context, productWarehouseID uint) (float64, error) {
var total float64
err := r.DB().WithContext(ctx).
Model(&entity.ProjectFlockPopulation{}).
Where("product_warehouse_id = ?", productWarehouseID).
Select("COALESCE(SUM(total_qty - total_used_qty), 0)").
Table("project_flock_populations").
Select("COALESCE(SUM(project_flock_populations.total_qty - project_flock_populations.total_used_qty), 0)").
Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id").
Where("project_flock_populations.product_warehouse_id = ?", productWarehouseID).
Where("project_chickins.deleted_at IS NULL").
Where("project_flock_populations.deleted_at IS NULL").
Scan(&total).Error
if err != nil {
return 0, err
@@ -128,6 +135,8 @@ func (r *projectFlockPopulationRepositoryImpl) GetAvailableQtyByProjectFlockKand
Select("COALESCE(SUM(total_qty - total_used_qty), 0) AS total_qty").
Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id").
Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangID).
Where("project_chickins.deleted_at IS NULL").
Where("project_flock_populations.deleted_at IS NULL").
Scan(&total).Error
if err != nil {
return 0, err
@@ -145,6 +154,8 @@ func (r *projectFlockPopulationRepositoryImpl) GetTotalChickInByProjectFlockKand
Select("COALESCE(SUM(project_flock_populations.total_qty - project_flock_populations.total_used_qty), 0) AS total_qty").
Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id").
Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangID).
Where("project_chickins.deleted_at IS NULL").
Where("project_flock_populations.deleted_at IS NULL").
Scan(&total).Error
if err != nil {
return 0, err
@@ -8,6 +8,7 @@ import (
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gorm.io/gorm"
)
@@ -110,6 +111,28 @@ func (r *ProjectflockRepositoryImpl) applyQueryFilters(db *gorm.DB, params *vali
AND pfk.kandang_id IN ?
)`, params.KandangIds)
}
if params.Status != "" {
db = db.Where(`
EXISTS (
SELECT 1
FROM approvals latest_approval
WHERE latest_approval.approvable_type = ?
AND latest_approval.approvable_id = project_flocks.id
AND latest_approval.id = (
SELECT a2.id
FROM approvals a2
WHERE a2.approvable_type = ?
AND a2.approvable_id = project_flocks.id
ORDER BY a2.id DESC
LIMIT 1
)
AND LOWER(latest_approval.step_name) = LOWER(?)
)`,
utils.ApprovalWorkflowProjectFlock.String(),
utils.ApprovalWorkflowProjectFlock.String(),
params.Status,
)
}
db = r.applySearchFilters(db, params.Search)
@@ -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,8 @@ 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)
GetProjectFlockKandangTransferStateAtDate(ctx *fiber.Ctx, projectFlockKandangID uint, referenceDate *time.Time) (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 +67,7 @@ type projectflockService struct {
PivotRepo repository.ProjectFlockKandangRepository
PopulationRepo repository.ProjectFlockPopulationRepository
RecordingRepo recordingRepo.RecordingRepository
TransferLayingRepo transferLayingRepo.TransferLayingRepository
ApprovalSvc commonSvc.ApprovalService
approvalWorkflow approvalutils.ApprovalWorkflowKey
}
@@ -85,6 +89,7 @@ func NewProjectflockService(
nonstockRepo nonstockRepository.NonstockRepository,
populationRepo repository.ProjectFlockPopulationRepository,
recordingRepo recordingRepo.RecordingRepository,
transferLayingRepo transferLayingRepo.TransferLayingRepository,
approvalSvc commonSvc.ApprovalService,
validate *validator.Validate,
@@ -102,6 +107,7 @@ func NewProjectflockService(
PivotRepo: pivotRepo,
PopulationRepo: populationRepo,
RecordingRepo: recordingRepo,
TransferLayingRepo: transferLayingRepo,
ApprovalSvc: approvalSvc,
approvalWorkflow: utils.ApprovalWorkflowProjectFlock,
}
@@ -538,6 +544,70 @@ func (s projectflockService) GetProjectFlockKandangChickinDate(ctx *fiber.Ctx, p
return earliest, nil
}
func (s projectflockService) GetProjectFlockKandangTransferState(ctx *fiber.Ctx, projectFlockKandangID uint) (bool, bool, error) {
return s.GetProjectFlockKandangTransferStateAtDate(ctx, projectFlockKandangID, nil)
}
func (s projectflockService) GetProjectFlockKandangTransferStateAtDate(ctx *fiber.Ctx, projectFlockKandangID uint, referenceDate *time.Time) (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
}
reference := normalizeDateOnlyUTC(time.Now().UTC())
if referenceDate != nil && !referenceDate.IsZero() {
reference = normalizeDateOnlyUTC(referenceDate.UTC())
}
isTransition := !reference.Before(physicalMoveDate) && reference.Before(economicCutoffDate)
isLaying := !reference.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 +649,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")
@@ -20,6 +20,7 @@ type Query struct {
LocationId uint `query:"location_id" validate:"omitempty,number,gt=0"`
Period int `query:"period" validate:"omitempty,number,gt=0"`
Category string `query:"category" validate:"omitempty"`
Status string `query:"status" validate:"omitempty,oneof=Pengajuan Aktif Selesai"`
KandangIds []uint `query:"kandang_id" validate:"omitempty,dive,gt=0"`
TransferContext string `query:"transfer_context" validate:"omitempty,oneof=transfer_to_laying"`
}
@@ -5,6 +5,7 @@ import (
"strings"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/config"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
productWarehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/dto"
@@ -86,6 +87,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 +250,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,
}
}
@@ -304,7 +309,7 @@ func recordingWeekValue(e entity.Recording) int {
}
weekBase := 1
if isLayingRecording(e) {
weekBase = 18
weekBase = config.LayingWeekStart()
}
return ((day - 1) / 7) + weekBase
}
@@ -125,6 +125,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
nonstockRepo,
projectFlockPopulationRepo,
recordingRepo,
transferLayingRepo,
approvalService,
validate,
)
@@ -154,7 +155,6 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
productWarehouseRepo,
warehouseRepo,
approvalService,
fifoService,
fifoStockV2Service,
validate,
)
@@ -71,6 +71,7 @@ type RecordingRepository interface {
GetLatestAvgWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (avgWeight float64, err error)
GetTotalEggProductionWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeightKg float64, err error)
GetAverageTargetMetricsByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint, includeTargets bool) (RecordingTargetAverages, error)
GetProjectFlockKandangIDsByPopulationWarehouseIDs(ctx context.Context, tx *gorm.DB, productWarehouseIDs []uint) ([]uint, error)
ResyncProjectFlockPopulationUsage(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error
ValidateProductWarehousesByFlags(ctx context.Context, ids []uint, flags []string) (uint, error)
}
@@ -874,6 +875,34 @@ func (r *RecordingRepositoryImpl) GetAverageTargetMetricsByProjectFlockKandangID
return result, nil
}
func (r *RecordingRepositoryImpl) GetProjectFlockKandangIDsByPopulationWarehouseIDs(
ctx context.Context,
tx *gorm.DB,
productWarehouseIDs []uint,
) ([]uint, error) {
if len(productWarehouseIDs) == 0 {
return nil, nil
}
db := r.DB().WithContext(ctx)
if tx != nil {
db = tx.WithContext(ctx)
}
var kandangIDs []uint
if err := db.Table("project_flock_populations pfp").
Select("DISTINCT pc.project_flock_kandang_id").
Joins("JOIN project_chickins pc ON pc.id = pfp.project_chickin_id").
Where("pfp.product_warehouse_id IN ?", productWarehouseIDs).
Where("pfp.deleted_at IS NULL").
Where("pc.deleted_at IS NULL").
Pluck("pc.project_flock_kandang_id", &kandangIDs).Error; err != nil {
return nil, err
}
return kandangIDs, nil
}
func (r *RecordingRepositoryImpl) ResyncProjectFlockPopulationUsage(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error {
if projectFlockKandangID == 0 {
return nil
@@ -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
}
@@ -320,6 +324,15 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
if err := s.enforceTransferRecordingRoute(ctx, pfk, recordTime, routePayload); err != nil {
return nil, err
}
if routePayload.DepletionCount > 0 {
if err := s.ensureDepletionMutationAllowed(ctx, &entity.Recording{
ProjectFlockKandangId: req.ProjectFlockKandangId,
RecordDatetime: recordTime,
ProjectFlockKandang: pfk,
}, "buat"); err != nil {
return nil, err
}
}
if err := s.ProjectFlockSvc.EnsureProjectFlockApproved(ctx, pfk.ProjectFlockId); err != nil {
return nil, err
@@ -518,9 +531,6 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
}
recordingEntity = recording
if err := s.ensurePopulationMutationAllowed(ctx, recordingEntity, "ubah"); err != nil {
return err
}
pfkForRoute := recordingEntity.ProjectFlockKandang
if pfkForRoute == nil || pfkForRoute.Id == 0 {
fetchedPfk, fetchErr := s.ProjectFlockKandangRepo.GetByIDLight(ctx, recordingEntity.ProjectFlockKandangId)
@@ -533,7 +543,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
}
pfkForRoute = fetchedPfk
}
routePayload := buildRecordingRoutePayloadFromUpdate(req, recordingEntity)
routePayload := buildRecordingRoutePayloadFromUpdate(req)
if err := s.enforceTransferRecordingRoute(ctx, pfkForRoute, recordingEntity.RecordDatetime, routePayload); err != nil {
return err
}
@@ -590,6 +600,9 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
if match {
hasDepletionChanges = false
} else {
if err := s.ensurePopulationMutationAllowed(ctx, recordingEntity, "ubah"); err != nil {
return err
}
if err := s.ensureDepletionMutationAllowed(ctx, recordingEntity, "ubah"); err != nil {
return err
}
@@ -931,15 +944,15 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error {
s.Log.Errorf("Failed to find recording: %+v", err)
return err
}
if err := s.ensurePopulationMutationAllowed(ctx, recording, "hapus"); err != nil {
return err
}
existingDepletions, err := s.Repository.ListDepletions(tx, recording.Id)
if err != nil {
s.Log.Errorf("Failed to list existing depletions: %+v", err)
return err
}
if len(existingDepletions) > 0 {
if err := s.ensurePopulationMutationAllowed(ctx, recording, "hapus"); err != nil {
return err
}
if err := s.ensureDepletionMutationAllowed(ctx, recording, "hapus"); err != nil {
return err
}
@@ -990,46 +1003,122 @@ 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))
if transferExecuted && !recordDate.Before(transferDate) {
hasTargetLayingRecording, checkErr := s.hasAnyRecordingOnTransferTargets(ctx, transfer)
if checkErr != nil {
s.Log.Errorf("Failed to resolve target laying recording state for transfer %d: %+v", transfer.Id, checkErr)
return true, false, false, false, nil, time.Time{}, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi status transisi recording")
}
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
}
targetIDs, err := s.transferTargetProjectFlockKandangIDs(ctx, transfer.Id)
if err != nil {
return false, err
}
if len(targetIDs) == 0 {
// Keep existing behavior for legacy or incomplete target mapping.
return true, nil
}
var count int64
err = s.Repository.DB().
WithContext(ctx).
Table("recordings").
Where("deleted_at IS NULL").
Where("project_flock_kandangs_id IN ?", targetIDs).
Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
func (s *recordingService) transferTargetProjectFlockKandangIDs(ctx context.Context, transferID uint) ([]uint, error) {
if transferID == 0 {
return nil, nil
}
var targetIDs []uint
err := s.Repository.DB().
WithContext(ctx).
Table("laying_transfer_targets").
Where("laying_transfer_id = ?", transferID).
Where("deleted_at IS NULL").
Pluck("target_project_flock_kandang_id", &targetIDs).Error
if err != nil {
return nil, err
}
return targetIDs, 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
}
@@ -1056,7 +1145,7 @@ func (s *recordingService) ensurePopulationMutationAllowed(ctx context.Context,
}
func (s *recordingService) ensureDepletionMutationAllowed(ctx context.Context, recording *entity.Recording, operation string) error {
if recording == nil || recording.Id == 0 || recording.ProjectFlockKandangId == 0 || s.TransferLayingRepo == nil {
if recording == nil || recording.ProjectFlockKandangId == 0 || s.TransferLayingRepo == nil {
return nil
}
@@ -1075,19 +1164,16 @@ func (s *recordingService) ensureDepletionMutationAllowed(ctx context.Context, r
category = strings.ToUpper(strings.TrimSpace(pfk.ProjectFlock.Category))
}
if !shouldGuardDepletionMutation(category) {
return nil
}
var (
transfer *entity.LayingTransfer
err error
)
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 nil
}
transfer, err = s.TransferLayingRepo.GetLatestApprovedBySourceKandang(ctx, recording.ProjectFlockKandangId)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
@@ -1100,22 +1186,28 @@ func (s *recordingService) ensureDepletionMutationAllowed(ctx context.Context, r
}
recordDate := normalizeDateOnlyUTC(recording.RecordDatetime)
physicalMoveDate := transferPhysicalMoveDate(transfer)
if physicalMoveDate.IsZero() || recordDate.Before(physicalMoveDate) {
return nil
transferNumber := strings.TrimSpace(transfer.TransferNumber)
if transferNumber == "" {
transferNumber = "-"
}
executedDate := normalizeDateOnlyUTC(*transfer.ExecutedAt)
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf(
"Deplesi recording tanggal %s tidak dapat di%s karena sudah mempengaruhi transfer laying %s yang sudah dieksekusi. Lakukan unexecute transfer terlebih dahulu bila belum ada pemakaian downstream.",
"Deplesi recording tanggal %s tidak dapat di%s karena transfer laying %s sudah dieksekusi pada %s. Setelah transfer dieksekusi, mutasi deplesi di kandang growing tidak diizinkan (termasuk backdate).",
recordDate.Format("2006-01-02"),
operation,
transfer.TransferNumber,
transferNumber,
executedDate.Format("2006-01-02"),
),
)
}
func shouldGuardDepletionMutation(category string) bool {
return strings.EqualFold(strings.TrimSpace(category), string(utils.ProjectFlockCategoryGrowing))
}
func (s *recordingService) tryAutoExecuteTransferForRecordingCreate(c *fiber.Ctx, pfk *entity.ProjectFlockKandang, recordTime time.Time) error {
if pfk == nil || pfk.Id == 0 || s.TransferLayingRepo == nil || s.TransferLayingSvc == nil {
return nil
@@ -1295,60 +1387,34 @@ func buildRecordingRoutePayloadFromCreate(req *validation.Create) recordingRoute
return payload
}
func buildRecordingRoutePayloadFromUpdate(req *validation.Update, existing *entity.Recording) recordingRoutePayload {
func buildRecordingRoutePayloadFromUpdate(req *validation.Update) recordingRoutePayload {
payload := recordingRoutePayload{}
if req == nil && existing == nil {
if req == nil {
return payload
}
if req != nil && req.Stocks != nil {
if req.Stocks != nil {
for _, stock := range req.Stocks {
if stock.Qty > 0 {
payload.StockCount++
}
}
} else if existing != nil {
for _, stock := range existing.Stocks {
usageQty := 0.0
if stock.UsageQty != nil {
usageQty = *stock.UsageQty
}
pendingQty := 0.0
if stock.PendingQty != nil {
pendingQty = *stock.PendingQty
}
if usageQty > 0 || pendingQty > 0 {
payload.StockCount++
}
}
}
if req != nil && req.Depletions != nil {
if req.Depletions != nil {
for _, depletion := range req.Depletions {
if depletion.Qty > 0 {
payload.DepletionCount++
}
}
} else if existing != nil {
for _, depletion := range existing.Depletions {
if depletion.Qty > 0 {
payload.DepletionCount++
}
}
}
if req != nil && req.Eggs != nil {
if req.Eggs != nil {
for _, egg := range req.Eggs {
if egg.Qty > 0 {
payload.EggCount++
}
}
} else if existing != nil {
for _, egg := range existing.Eggs {
if egg.Qty > 0 {
payload.EggCount++
}
}
}
return payload
@@ -1590,7 +1656,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 {
@@ -2010,10 +2076,7 @@ func (s *recordingService) reflowApplyRecordingStocks(
}
s.logStockTrace("reflow_apply:done", *refreshed, fmt.Sprintf("desired=%.3f used=%.3f pending=%.3f", desiredTotal, actualUsage, actualPending))
logDecrease := actualUsage
if actualPending > 0 {
logDecrease += actualPending
}
logDecrease := recordingStockRollbackQty(*refreshed)
if logDecrease > 0 && shouldWriteLog {
log := &entity.StockLog{
ProductWarehouseId: refreshed.ProductWarehouseId,
@@ -2057,11 +2120,8 @@ func (s *recordingService) reflowResetRecordingStocks(
continue
}
currentUsage := 0.0
if stock.UsageQty != nil {
currentUsage = *stock.UsageQty
}
s.logStockTrace("reflow_reset:start", stock, "")
rollbackQty := recordingStockRollbackQty(stock)
s.logStockTrace("reflow_reset:start", stock, fmt.Sprintf("rollback_qty=%.3f", rollbackQty))
if err := s.Repository.UpdateStockUsage(tx, stock.Id, 0, 0); err != nil {
return err
@@ -2078,13 +2138,13 @@ func (s *recordingService) reflowResetRecordingStocks(
s.Log.Errorf("Failed to reflow FIFO v2 rollback for recording stock %d: %+v", stock.Id, err)
return err
}
s.logStockTrace("reflow_reset:done", stock, "")
s.logStockTrace("reflow_reset:done", stock, fmt.Sprintf("rollback_qty=%.3f", rollbackQty))
if currentUsage > 0 && shouldWriteLog {
if rollbackQty > 0 && shouldWriteLog {
log := &entity.StockLog{
ProductWarehouseId: stock.ProductWarehouseId,
CreatedBy: actorID,
Increase: currentUsage,
Increase: rollbackQty,
LoggableType: string(utils.StockLogTypeRecording),
LoggableId: stock.RecordingId,
Notes: note,
@@ -2098,6 +2158,24 @@ func (s *recordingService) reflowResetRecordingStocks(
return nil
}
func recordingStockRollbackQty(stock entity.RecordingStock) float64 {
usage := 0.0
if stock.UsageQty != nil {
usage = *stock.UsageQty
}
pending := 0.0
if stock.PendingQty != nil {
pending = *stock.PendingQty
}
if usage < 0 {
usage = 0
}
if pending < 0 {
pending = 0
}
return usage + pending
}
type desiredStock struct {
Usage float64
Pending float64
@@ -2316,15 +2394,10 @@ func (s *recordingService) reflowResetRecordingDepletionsOut(
return errors.New("stock log repository is not available")
}
logState := newRecordingStockLogState()
stockAllocationRepo := commonRepo.NewStockAllocationRepository(tx)
for _, depletion := range depletions {
if depletion.Id == 0 {
continue
}
if err := stockAllocationRepo.ReleaseByUsable(ctx, fifo.UsableKeyRecordingDepletion.String(), depletion.Id, nil, nil); err != nil {
return err
}
s.logDepletionTrace("reflow_reset:start", depletion, "")
sourceWarehouseID := uint(0)
@@ -2611,19 +2684,8 @@ func (s *recordingService) resyncPopulationUsageForDepletions(
}
if len(sourceWarehouseIDs) > 0 {
db := s.Repository.DB().WithContext(ctx)
if tx != nil {
db = tx.WithContext(ctx)
}
var sourceKandangIDs []uint
if err := db.Table("project_flock_populations pfp").
Select("DISTINCT pc.project_flock_kandang_id").
Joins("JOIN project_chickins pc ON pc.id = pfp.project_chickin_id").
Where("pfp.product_warehouse_id IN ?", sourceWarehouseIDs).
Where("pfp.deleted_at IS NULL").
Where("pc.deleted_at IS NULL").
Pluck("pc.project_flock_kandang_id", &sourceKandangIDs).Error; err != nil {
sourceKandangIDs, err := s.Repository.GetProjectFlockKandangIDsByPopulationWarehouseIDs(ctx, tx, sourceWarehouseIDs)
if err != nil {
return err
}
@@ -2635,62 +2697,7 @@ func (s *recordingService) resyncPopulationUsageForDepletions(
}
for kandangID := range kandangIDs {
if err := s.resyncPopulationUsageByProjectFlockKandang(ctx, tx, kandangID); err != nil {
return err
}
}
return nil
}
func (s *recordingService) resyncPopulationUsageByProjectFlockKandang(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error {
if projectFlockKandangID == 0 {
return nil
}
db := s.Repository.DB().WithContext(ctx)
if tx != nil {
db = tx.WithContext(ctx)
}
var populationIDs []uint
if err := db.Table("project_flock_populations pfp").
Select("pfp.id").
Joins("JOIN project_chickins pc ON pc.id = pfp.project_chickin_id").
Where("pc.project_flock_kandang_id = ?", projectFlockKandangID).
Pluck("pfp.id", &populationIDs).Error; err != nil {
return err
}
if len(populationIDs) == 0 {
return nil
}
type usageRow struct {
StockableID uint `gorm:"column:stockable_id"`
Used float64 `gorm:"column:used"`
}
var usageRows []usageRow
if err := db.Table("stock_allocations").
Select("stockable_id, COALESCE(SUM(qty), 0) AS used").
Where("stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()).
Where("status = ?", entity.StockAllocationStatusActive).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("stockable_id IN ?", populationIDs).
Group("stockable_id").
Scan(&usageRows).Error; err != nil {
return err
}
if err := db.Model(&entity.ProjectFlockPopulation{}).
Where("id IN ?", populationIDs).
Update("total_used_qty", 0).Error; err != nil {
return err
}
for _, row := range usageRows {
if err := db.Model(&entity.ProjectFlockPopulation{}).
Where("id = ?", row.StockableID).
Update("total_used_qty", row.Used).Error; err != nil {
if err := s.Repository.ResyncProjectFlockPopulationUsage(ctx, tx, kandangID); err != nil {
return err
}
}
@@ -91,7 +91,6 @@ func (TransferLayingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, val
productWarehouseRepo,
warehouseRepo,
approvalService,
fifoService,
fifoStockV2Service,
validate,
)
@@ -2,15 +2,22 @@ package repository
import (
"context"
"errors"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
)
type LayingTransferTargetRepository interface {
repository.BaseRepository[entity.LayingTransferTarget]
GetByLayingTransferId(ctx context.Context, layingTransferId uint) ([]entity.LayingTransferTarget, error)
GetActiveDownstreamConsumptions(ctx context.Context, targetIDs []uint) ([]TargetDownstreamConsumption, error)
GetEarliestRecordingDateByTarget(ctx context.Context, targetProjectFlockKandangID uint, sinceDate time.Time) (*time.Time, error)
CountActiveTransferSourceConsumeAllocations(ctx context.Context, transferID uint, productWarehouseID uint) (int64, error)
SyncPopulationUsageByProjectFlockKandang(ctx context.Context, projectFlockKandangID uint) error
}
type LayingTransferTargetRepositoryImpl struct {
@@ -18,6 +25,11 @@ type LayingTransferTargetRepositoryImpl struct {
db *gorm.DB
}
type TargetDownstreamConsumption struct {
UsableType string `gorm:"column:usable_type"`
UsableID uint `gorm:"column:usable_id"`
}
func NewLayingTransferTargetRepository(db *gorm.DB) LayingTransferTargetRepository {
return &LayingTransferTargetRepositoryImpl{
BaseRepositoryImpl: repository.NewBaseRepository[entity.LayingTransferTarget](db),
@@ -36,3 +48,123 @@ func (r *LayingTransferTargetRepositoryImpl) GetByLayingTransferId(ctx context.C
}
return targets, nil
}
func (r *LayingTransferTargetRepositoryImpl) GetActiveDownstreamConsumptions(ctx context.Context, targetIDs []uint) ([]TargetDownstreamConsumption, error) {
if len(targetIDs) == 0 {
return nil, nil
}
var rows []TargetDownstreamConsumption
err := r.db.WithContext(ctx).
Table("stock_allocations").
Select("usable_type, usable_id").
Where("stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()).
Where("stockable_id IN ?", targetIDs).
Where("status = ?", entity.StockAllocationStatusActive).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("deleted_at IS NULL").
Group("usable_type, usable_id").
Scan(&rows).Error
if err != nil {
return nil, err
}
return rows, nil
}
func (r *LayingTransferTargetRepositoryImpl) GetEarliestRecordingDateByTarget(ctx context.Context, targetProjectFlockKandangID uint, sinceDate time.Time) (*time.Time, error) {
if targetProjectFlockKandangID == 0 {
return nil, nil
}
var earliest entity.Recording
query := r.db.WithContext(ctx).
Model(&entity.Recording{}).
Where("project_flock_kandangs_id = ?", targetProjectFlockKandangID).
Where("deleted_at IS NULL")
if !sinceDate.IsZero() {
query = query.Where("record_datetime >= ?", sinceDate)
}
if err := query.Order("record_datetime ASC").Limit(1).Take(&earliest).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
d := earliest.RecordDatetime.UTC()
return &d, nil
}
func (r *LayingTransferTargetRepositoryImpl) CountActiveTransferSourceConsumeAllocations(ctx context.Context, transferID uint, productWarehouseID uint) (int64, error) {
if transferID == 0 || productWarehouseID == 0 {
return 0, nil
}
var count int64
err := r.db.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
if err != nil {
return 0, err
}
return count, nil
}
func (r *LayingTransferTargetRepositoryImpl) SyncPopulationUsageByProjectFlockKandang(ctx context.Context, projectFlockKandangID uint) error {
if projectFlockKandangID == 0 {
return nil
}
var populationIDs []uint
if err := r.db.WithContext(ctx).
Table("project_flock_populations pfp").
Select("pfp.id").
Joins("JOIN project_chickins pc ON pc.id = pfp.project_chickin_id").
Where("pc.project_flock_kandang_id = ?", projectFlockKandangID).
Pluck("pfp.id", &populationIDs).Error; err != nil {
return err
}
if len(populationIDs) == 0 {
return nil
}
type usageRow struct {
StockableID uint `gorm:"column:stockable_id"`
Used float64 `gorm:"column:used"`
}
var usageRows []usageRow
if err := r.db.WithContext(ctx).
Table("stock_allocations").
Select("stockable_id, COALESCE(SUM(qty), 0) AS used").
Where("stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()).
Where("status = ?", entity.StockAllocationStatusActive).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("stockable_id IN ?", populationIDs).
Group("stockable_id").
Scan(&usageRows).Error; err != nil {
return err
}
if err := r.db.WithContext(ctx).
Model(&entity.ProjectFlockPopulation{}).
Where("id IN ?", populationIDs).
Update("total_used_qty", 0).Error; err != nil {
return err
}
for _, row := range usageRows {
if err := r.db.WithContext(ctx).
Model(&entity.ProjectFlockPopulation{}).
Where("id = ?", row.StockableID).
Update("total_used_qty", row.Used).Error; err != nil {
return err
}
}
return nil
}
@@ -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)
}
})
}
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"sort"
"strings"
"time"
@@ -56,12 +57,12 @@ type transferLayingService struct {
WarehouseRepo rWarehouse.WarehouseRepository
StockLogRepo rStockLogs.StockLogRepository
ApprovalService commonSvc.ApprovalService
FifoSvc commonSvc.FifoService
FifoStockV2Svc commonSvc.FifoStockV2Service
}
const (
transferToLayingFlagGroupCode = "AYAM"
transferToLayingFlagGroupCode = "AYAM"
transferLayingDeleteDownstreamGuardMessage = "Transfer laying tidak dapat dihapus karena stok target transfer sudah dipakai transaksi turunan. Hapus dependensi terkait secara manual terlebih dahulu."
)
func NewTransferLayingService(
@@ -74,7 +75,6 @@ func NewTransferLayingService(
productWarehouseRepo rInventory.ProductWarehouseRepository,
warehouseRepo rWarehouse.WarehouseRepository,
approvalService commonSvc.ApprovalService,
fifoSvc commonSvc.FifoService,
fifoStockV2Svc commonSvc.FifoStockV2Service,
validate *validator.Validate,
) TransferLayingService {
@@ -91,7 +91,6 @@ func NewTransferLayingService(
WarehouseRepo: warehouseRepo,
StockLogRepo: rStockLogs.NewStockLogRepository(repo.DB()),
ApprovalService: approvalService,
FifoSvc: fifoSvc,
FifoStockV2Svc: fifoStockV2Svc,
}
}
@@ -610,6 +609,9 @@ func (s transferLayingService) DeleteOne(c *fiber.Ctx, id uint) error {
if isLegacyTransfer(transfer) {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Transfer laying legacy %s tidak dapat dihapus", transfer.TransferNumber))
}
if err := s.ensureNoDownstreamConsumptionForDelete(c.Context(), nil, transfer.TransferNumber, transfer.Targets); err != nil {
return err
}
approvalRepo := commonRepo.NewApprovalRepository(s.Repository.DB())
@@ -635,6 +637,16 @@ func (s transferLayingService) DeleteOne(c *fiber.Ctx, id uint) error {
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
repoTx := s.Repository.WithTx(dbTransaction)
// Lock header row to keep delete deterministic after single downstream guard check.
if _, err := repoTx.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
return db.Clauses(clause.Locking{Strength: "UPDATE"})
}); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "TransferLaying not found")
}
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get transfer laying")
}
if err := repoTx.DeleteOne(c.Context(), id); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete transfer laying")
}
@@ -1026,6 +1038,38 @@ 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),
)
}
type targetReflowKey struct {
productWarehouseID uint
}
targetReflow := make(map[targetReflowKey]struct{})
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))
@@ -1033,15 +1077,6 @@ func (s transferLayingService) Unexecute(c *fiber.Ctx, id uint) (*entity.LayingT
if target.TotalQty <= 0 {
continue
}
if err := s.FifoSvc.AdjustStockableQuantity(c.Context(), commonSvc.StockAdjustRequest{
StockableKey: fifo.StockableKeyTransferToLayingIn,
StockableID: target.Id,
ProductWarehouseID: *target.ProductWarehouseId,
Quantity: -target.TotalQty,
Tx: dbTransaction,
}); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal rollback stok target transfer laying: %v", err))
}
stockLogDecrease := &entity.StockLog{
ProductWarehouseId: *target.ProductWarehouseId,
@@ -1065,23 +1100,56 @@ func (s transferLayingService) Unexecute(c *fiber.Ctx, id uint) (*entity.LayingT
if err := stockLogRepoTx.CreateOne(c.Context(), stockLogDecrease, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar target saat unexecute")
}
if err := targetRepoTx.PatchOne(c.Context(), target.Id, map[string]any{
"total_qty": 0,
"total_used": 0,
}, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal rollback kuantitas target transfer laying")
}
targetReflow[targetReflowKey{productWarehouseID: *target.ProductWarehouseId}] = struct{}{}
}
asOf := normalizeDateOnlyUTC(transfer.TransferDate)
for key := range targetReflow {
if err := reflowTransferLayingScope(c.Context(), s.FifoStockV2Svc, dbTransaction, key.productWarehouseID, &asOf); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal rollback FIFO v2 target transfer laying: %v", err))
}
}
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,
),
)
}
asOf := normalizeDateOnlyUTC(transfer.TransferDate)
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,
@@ -1183,9 +1251,6 @@ func (s *transferLayingService) executeApprovedTransferMovement(
if s.FifoStockV2Svc == nil {
return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available")
}
if s.FifoSvc == nil {
return fiber.NewError(fiber.StatusInternalServerError, "FIFO service is not available")
}
stockAllocationRepo := commonRepo.NewStockAllocationRepository(tx)
targetRepoTx := repository.NewLayingTransferTargetRepository(tx)
@@ -1281,29 +1346,22 @@ func (s *transferLayingService) executeApprovedTransferMovement(
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar")
}
type targetReflowKey struct {
productWarehouseID uint
}
targetReflow := make(map[targetReflowKey]struct{})
for _, target := range targets {
if target.ProductWarehouseId == nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Target product warehouse tidak ditemukan untuk transfer %d", transfer.Id))
}
note := fmt.Sprintf("Transfer to Laying #%s", transfer.TransferNumber)
_, err := s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{
StockableKey: fifo.StockableKeyTransferToLayingIn,
StockableID: target.Id,
ProductWarehouseID: *target.ProductWarehouseId,
Quantity: target.TotalQty,
Note: &note,
Tx: tx,
})
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal replenish stock ke target warehouse: %v", err))
}
if err := targetRepoTx.PatchOne(ctx, target.Id, map[string]any{
"total_qty": target.TotalQty,
}, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal update target total qty")
}
targetReflow[targetReflowKey{productWarehouseID: *target.ProductWarehouseId}] = struct{}{}
stockLogIncrease := &entity.StockLog{
ProductWarehouseId: *target.ProductWarehouseId,
@@ -1330,6 +1388,11 @@ func (s *transferLayingService) executeApprovedTransferMovement(
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok masuk")
}
}
for key := range targetReflow {
if err := reflowTransferLayingScope(ctx, s.FifoStockV2Svc, tx, key.productWarehouseID, &asOf); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal reflow FIFO v2 target transfer laying: %v", err))
}
}
return nil
}
@@ -1549,86 +1612,66 @@ func (s *transferLayingService) hasDownstreamRecordingOnTarget(
targetProjectFlockKandangID uint,
sinceDate time.Time,
) (bool, time.Time, error) {
if targetProjectFlockKandangID == 0 {
return false, time.Time{}, nil
}
db := s.Repository.DB().WithContext(ctx)
targetRepo := s.LayingTransferTargetRepo
if tx != nil {
db = tx.WithContext(ctx)
targetRepo = repository.NewLayingTransferTargetRepository(tx)
}
var earliest entity.Recording
query := db.Model(&entity.Recording{}).
Where("project_flock_kandangs_id = ?", targetProjectFlockKandangID).
Where("deleted_at IS NULL")
if !sinceDate.IsZero() {
query = query.Where("record_datetime >= ?", sinceDate)
}
if err := query.Order("record_datetime ASC").Limit(1).Take(&earliest).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, time.Time{}, nil
}
recordDate, err := targetRepo.GetEarliestRecordingDateByTarget(ctx, targetProjectFlockKandangID, sinceDate)
if err != nil {
return false, time.Time{}, err
}
if recordDate == nil {
return false, time.Time{}, nil
}
return true, normalizeDateOnlyUTC(*recordDate), nil
}
return true, normalizeDateOnlyUTC(earliest.RecordDatetime), nil
func (s *transferLayingService) countActiveTransferSourceConsumeAllocations(
ctx context.Context,
tx *gorm.DB,
transferID uint,
productWarehouseID uint,
) (int64, error) {
targetRepo := s.LayingTransferTargetRepo
if tx != nil {
targetRepo = repository.NewLayingTransferTargetRepository(tx)
}
return targetRepo.CountActiveTransferSourceConsumeAllocations(ctx, transferID, productWarehouseID)
}
func (s *transferLayingService) resyncPopulationUsageByProjectFlockKandang(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error {
if projectFlockKandangID == 0 {
return nil
}
db := s.Repository.DB().WithContext(ctx)
targetRepo := s.LayingTransferTargetRepo
if tx != nil {
db = tx.WithContext(ctx)
targetRepo = repository.NewLayingTransferTargetRepository(tx)
}
return targetRepo.SyncPopulationUsageByProjectFlockKandang(ctx, projectFlockKandangID)
}
var populationIDs []uint
if err := db.Table("project_flock_populations pfp").
Select("pfp.id").
Joins("JOIN project_chickins pc ON pc.id = pfp.project_chickin_id").
Where("pc.project_flock_kandang_id = ?", projectFlockKandangID).
Pluck("pfp.id", &populationIDs).Error; err != nil {
return err
}
if len(populationIDs) == 0 {
func sortedIDs(input map[uint]struct{}) []uint {
if len(input) == 0 {
return nil
}
type usageRow struct {
StockableID uint `gorm:"column:stockable_id"`
Used float64 `gorm:"column:used"`
}
var usageRows []usageRow
if err := db.Table("stock_allocations").
Select("stockable_id, COALESCE(SUM(qty), 0) AS used").
Where("stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()).
Where("status = ?", entity.StockAllocationStatusActive).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("stockable_id IN ?", populationIDs).
Group("stockable_id").
Scan(&usageRows).Error; err != nil {
return err
}
if err := db.Model(&entity.ProjectFlockPopulation{}).
Where("id IN ?", populationIDs).
Update("total_used_qty", 0).Error; err != nil {
return err
}
for _, row := range usageRows {
if err := db.Model(&entity.ProjectFlockPopulation{}).
Where("id = ?", row.StockableID).
Update("total_used_qty", row.Used).Error; err != nil {
return err
out := make([]uint, 0, len(input))
for id := range input {
if id == 0 {
continue
}
out = append(out, id)
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
return nil
func joinUint(values []uint) string {
if len(values) == 0 {
return "-"
}
parts := make([]string, 0, len(values))
for _, value := range values {
parts = append(parts, fmt.Sprintf("%d", value))
}
return strings.Join(parts, "|")
}
func normalizeDateOnlyUTC(value time.Time) time.Time {
@@ -1647,3 +1690,84 @@ func isLegacyTransfer(transfer *entity.LayingTransfer) bool {
}
return false
}
func (s *transferLayingService) ensureNoDownstreamConsumptionForDelete(
ctx context.Context,
tx *gorm.DB,
transferNumber string,
targets []entity.LayingTransferTarget,
) error {
targetIDs := make([]uint, 0, len(targets))
for _, target := range targets {
if target.Id == 0 {
continue
}
targetIDs = append(targetIDs, target.Id)
}
if len(targetIDs) == 0 {
return nil
}
targetRepo := s.LayingTransferTargetRepo
if tx != nil {
targetRepo = repository.NewLayingTransferTargetRepository(tx)
}
rows, err := targetRepo.GetActiveDownstreamConsumptions(ctx, targetIDs)
if err != nil {
s.Log.Errorf("Failed to validate downstream consumption for transfer laying %s: %+v", transferNumber, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transaksi turunan transfer laying")
}
if len(rows) == 0 {
return nil
}
dependencyMap := make(map[string]map[uint]struct{})
for _, row := range rows {
label := mapTransferLayingDownstreamUsableLabel(row.UsableType)
if _, ok := dependencyMap[label]; !ok {
dependencyMap[label] = make(map[uint]struct{})
}
dependencyMap[label][row.UsableID] = struct{}{}
}
labels := make([]string, 0, len(dependencyMap))
for label := range dependencyMap {
labels = append(labels, label)
}
sort.Strings(labels)
details := make([]string, 0, len(labels))
for _, label := range labels {
details = append(details, fmt.Sprintf("%s=%s", label, joinUint(sortedIDs(dependencyMap[label]))))
}
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf(
"%s Transfer %s. Dependensi aktif: %s.",
transferLayingDeleteDownstreamGuardMessage,
transferNumber,
strings.Join(details, ", "),
),
)
}
func mapTransferLayingDownstreamUsableLabel(usableType string) string {
switch strings.ToUpper(strings.TrimSpace(usableType)) {
case fifo.UsableKeyRecordingStock.String(), fifo.UsableKeyRecordingDepletion.String():
return "Recording"
case fifo.UsableKeyProjectChickin.String():
return "Chickin"
case fifo.UsableKeyMarketingDelivery.String():
return "Marketing"
case fifo.UsableKeyTransferToLayingOut.String():
return "TransferToLaying"
case fifo.UsableKeyStockTransferOut.String():
return "TransferStock"
case fifo.UsableKeyAdjustmentOut.String():
return "Adjustment"
default:
return strings.ToUpper(strings.TrimSpace(usableType))
}
}
@@ -13,6 +13,7 @@ import (
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
"gitlab.com/mbugroup/lti-api.git/internal/config"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories"
@@ -380,12 +381,13 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file
}
}
weekBase := 1
if strings.EqualFold(category, string(utils.ProjectFlockCategoryLaying)) {
weekBase = 18
isLayingCategory := strings.EqualFold(category, string(utils.ProjectFlockCategoryLaying))
if isLayingCategory {
weekBase = config.LayingWeekStart()
}
if req.Week < weekBase {
if weekBase == 18 {
return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 18 for laying projects")
if isLayingCategory {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("week must start from %d for laying projects", weekBase))
}
return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects")
}
@@ -399,8 +401,8 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate uniformity week sequence")
}
if latestWeek == 0 && req.Week != weekBase {
if weekBase == 18 {
return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 18 for laying projects")
if isLayingCategory {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("week must start from %d for laying projects", weekBase))
}
return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects")
}
@@ -474,7 +476,7 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file
}); err != nil {
s.Log.Errorf("Failed to create uniformity: %+v", err)
return nil, err
}
}
if s.DocumentSvc != nil {
actorIDCopy := actorID
@@ -575,12 +577,13 @@ func (s uniformityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id ui
}
}
weekBase := 1
if strings.EqualFold(category, string(utils.ProjectFlockCategoryLaying)) {
weekBase = 18
isLayingCategory := strings.EqualFold(category, string(utils.ProjectFlockCategoryLaying))
if isLayingCategory {
weekBase = config.LayingWeekStart()
}
if targetWeek < weekBase {
if weekBase == 18 {
return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 18 for laying projects")
if isLayingCategory {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("week must start from %d for laying projects", weekBase))
}
return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects")
}