Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into dev/teguh

This commit is contained in:
aguhh18
2025-12-31 10:29:32 +07:00
40 changed files with 2973 additions and 369 deletions
@@ -215,21 +215,19 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get active project flocks for location")
}
if len(activeProjectFlocks) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "No active project flocks found for this location")
}
if len(activeProjectFlocks) > 0 {
projectFlockIDs := make([]uint64, len(activeProjectFlocks))
for i, pf := range activeProjectFlocks {
projectFlockIDs[i] = uint64(pf.Id)
}
projectFlockIDs := make([]uint64, len(activeProjectFlocks))
for i, pf := range activeProjectFlocks {
projectFlockIDs[i] = uint64(pf.Id)
projectFlockIdsJSON, err := json.Marshal(projectFlockIDs)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to marshal project_flock_ids")
}
jsonStr := string(projectFlockIdsJSON)
projectFlockIdJSON = &jsonStr
}
projectFlockIdsJSON, err := json.Marshal(projectFlockIDs)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to marshal project_flock_ids")
}
jsonStr := string(projectFlockIdsJSON)
projectFlockIdJSON = &jsonStr
}
expense = &entity.Expense{
@@ -29,6 +29,8 @@ type ProductWarehouseRepository interface {
IdExists(ctx context.Context, id uint) (bool, error)
CleanupEmpty(ctx context.Context, affected map[uint]struct{}) error
EnsureProductWarehouse(ctx context.Context, productID, warehouseID uint, projectFlockKandangID *uint, createdBy uint) (uint, error)
GetByProductWarehouseAndProjectFlockKandang(ctx context.Context, productId, warehouseId, projectFlockKandangId uint) (*entity.ProductWarehouse, error)
DeleteByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) error
}
type ProductWarehouseRepositoryImpl struct {
@@ -214,6 +216,31 @@ func (r *ProductWarehouseRepositoryImpl) CleanupEmpty(ctx context.Context, affec
return nil
}
var inUseIDs []uint
if err := r.DB().WithContext(ctx).
Model(&entity.PurchaseItem{}).
Where("product_warehouse_id IN ?", emptyIDs).
Distinct().
Pluck("product_warehouse_id", &inUseIDs).Error; err != nil {
return err
}
if len(inUseIDs) > 0 {
inUse := make(map[uint]struct{}, len(inUseIDs))
for _, id := range inUseIDs {
inUse[id] = struct{}{}
}
filtered := make([]uint, 0, len(emptyIDs))
for _, id := range emptyIDs {
if _, exists := inUse[id]; !exists {
filtered = append(filtered, id)
}
}
emptyIDs = filtered
}
if len(emptyIDs) == 0 {
return nil
}
if err := r.DB().WithContext(ctx).
Model(&entity.PurchaseItem{}).
Where("product_warehouse_id IN ?", emptyIDs).
@@ -272,6 +299,30 @@ func (r *ProductWarehouseRepositoryImpl) EnsureProductWarehouse(
return entity.Id, nil
}
func (r *ProductWarehouseRepositoryImpl) GetByProductWarehouseAndProjectFlockKandang(
ctx context.Context,
productId uint,
warehouseId uint,
projectFlockKandangId uint,
) (*entity.ProductWarehouse, error) {
var productWarehouse entity.ProductWarehouse
if err := r.DB().WithContext(ctx).
Where("product_id = ? AND warehouse_id = ? AND project_flock_kandang_id = ?", productId, warehouseId, projectFlockKandangId).
First(&productWarehouse).Error; err != nil {
return nil, err
}
return &productWarehouse, nil
}
func (r *ProductWarehouseRepositoryImpl) DeleteByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) error {
if len(projectFlockKandangIDs) == 0 {
return nil
}
return r.DB().WithContext(ctx).
Where("project_flock_kandang_id IN ?", projectFlockKandangIDs).
Delete(&entity.ProductWarehouse{}).Error
}
func (r *ProductWarehouseRepositoryImpl) GetDetailByID(ctx context.Context, id uint) (*entity.ProductWarehouse, error) {
var productWarehouse entity.ProductWarehouse
err := r.DB().WithContext(ctx).
@@ -268,6 +268,7 @@ func (u *ProjectflockController) GetPeriodSummary(c *fiber.Ctx) error {
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)
if projectFlockId == 0 || kandangId == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id or kandang_id")
@@ -280,6 +281,13 @@ func (u *ProjectflockController) LookupProjectFlockKandang(c *fiber.Ctx) error {
dtoResult := dto.ToProjectFlockKandangDTO(*result)
dtoResult.AvailableQuantity = float64(availableStock)
if withPopulation {
population, err := u.ProjectflockService.GetProjectFlockKandangPopulation(c, result.Id)
if err != nil {
return err
}
dtoResult.Population = &population
}
if dtoResult.ProjectFlock != nil {
for i := range dtoResult.ProjectFlock.Kandangs {
@@ -34,6 +34,7 @@ type ProjectFlockKandangDTO struct {
Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"`
ProjectFlock *ProjectFlockWithPivotDTO `json:"project_flock,omitempty"`
AvailableQuantity float64 `json:"available_quantity"`
Population *float64 `json:"population,omitempty"`
}
func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangDTO {
@@ -16,6 +16,7 @@ import (
rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories"
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"
sProjectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services"
utils "gitlab.com/mbugroup/lti-api.git/internal/utils"
@@ -32,6 +33,8 @@ func (ProjectflockModule) RegisterRoutes(router fiber.Router, db *gorm.DB, valid
nonstockRepo := rNonstock.NewNonstockRepository(db)
projectflockRepo := rProjectflock.NewProjectflockRepository(db)
projectflockKandangRepo := rProjectflock.NewProjectFlockKandangRepository(db)
projectFlockPopulationRepo := rProjectflock.NewProjectFlockPopulationRepository(db)
recordingRepo := rRecording.NewRecordingRepository(db)
warehouseRepo := rWarehouse.NewWarehouseRepository(db)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
projectBudgetRepo := rProjectBudget.NewProjectBudgetRepository(db)
@@ -43,7 +46,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, approvalService, validate)
projectflockService := sProjectflock.NewProjectflockService(projectflockRepo, flockRepo, kandangRepo, projectflockKandangRepo, warehouseRepo, productWarehouseRepo, projectBudgetRepo, nonstockRepo, projectFlockPopulationRepo, recordingRepo, approvalService, validate)
userService := sUser.NewUserService(userRepo, validate)
ProjectflockRoutes(router, userService, projectflockService)
@@ -15,6 +15,7 @@ type ProjectFlockPopulationRepository interface {
GetByProjectChickinIDAndProductWarehouseID(ctx context.Context, projectChickinID uint, productWarehouseID uint) ([]entity.ProjectFlockPopulation, error)
GetByProjectFlockKandangIDAndProductWarehouseID(ctx context.Context, projectFlockKandangID uint, productWarehouseID uint) ([]entity.ProjectFlockPopulation, error)
GetTotalQtyByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error)
GetAvailableQtyByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error)
// subset of base repository methods used by services
CreateOne(ctx context.Context, entity *entity.ProjectFlockPopulation, modifier func(*gorm.DB) *gorm.DB) error
@@ -106,3 +107,20 @@ func (r *projectFlockPopulationRepositoryImpl) GetTotalQtyByProjectFlockKandangI
}
return total, nil
}
func (r *projectFlockPopulationRepositoryImpl) GetAvailableQtyByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) {
var total float64
err := r.DB().WithContext(ctx).
Table("project_flock_populations").
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).
Scan(&total).Error
if err != nil {
return 0, err
}
if total < 0 {
total = 0
}
return total, nil
}
@@ -27,6 +27,7 @@ type ProjectFlockKandangRepository interface {
MaxPeriodByBaseName(ctx context.Context, baseName string) (int, error)
ProjectPeriodsByProjectIDs(ctx context.Context, projectIDs []uint) (map[uint]int, error)
HasOpenNewerPeriod(ctx context.Context, kandangID uint, currentPeriod int, excludeID *uint) (bool, error)
ListIDsByProjectAndKandang(ctx context.Context, projectFlockID uint, kandangIDs []uint) ([]uint, error)
WithTx(tx *gorm.DB) ProjectFlockKandangRepository
DB() *gorm.DB
IdExists(ctx context.Context, id uint) (bool, error)
@@ -89,6 +90,20 @@ func (r *projectFlockKandangRepositoryImpl) GetByProjectFlockID(ctx context.Cont
return records, nil
}
func (r *projectFlockKandangRepositoryImpl) ListIDsByProjectAndKandang(ctx context.Context, projectFlockID uint, kandangIDs []uint) ([]uint, error) {
if len(kandangIDs) == 0 {
return []uint{}, nil
}
var ids []uint
if err := r.db.WithContext(ctx).
Model(&entity.ProjectFlockKandang{}).
Where("project_flock_id = ? AND kandang_id IN ?", projectFlockID, kandangIDs).
Pluck("id", &ids).Error; err != nil {
return nil, err
}
return ids, nil
}
func (r *projectFlockKandangRepositoryImpl) GetAllWithFilters(ctx context.Context, offset int, limit int, params interface{}) ([]entity.ProjectFlockKandang, int64, error) {
var records []entity.ProjectFlockKandang
var total int64
@@ -21,6 +21,7 @@ import (
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
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"
utils "gitlab.com/mbugroup/lti-api.git/internal/utils"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
@@ -37,6 +38,7 @@ type ProjectflockService interface {
GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID uint) (float64, error)
DeleteOne(ctx *fiber.Ctx, id uint) error
GetProjectFlockKandangByProjectAndKandang(ctx *fiber.Ctx, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, float64, error)
GetProjectFlockKandangPopulation(ctx *fiber.Ctx, projectFlockKandangID uint) (float64, 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)
@@ -54,6 +56,8 @@ type projectflockService struct {
ProductWarehouseRepo productWarehouseRepository.ProductWarehouseRepository
ProjectBudgetRepo projectBudgetRepository.ProjectBudgetRepository
PivotRepo repository.ProjectFlockKandangRepository
PopulationRepo repository.ProjectFlockPopulationRepository
RecordingRepo recordingRepo.RecordingRepository
ApprovalSvc commonSvc.ApprovalService
approvalWorkflow approvalutils.ApprovalWorkflowKey
}
@@ -73,6 +77,8 @@ func NewProjectflockService(
productWarehouseRepo productWarehouseRepository.ProductWarehouseRepository,
projectBudgetRepo projectBudgetRepository.ProjectBudgetRepository,
nonstockRepo nonstockRepository.NonstockRepository,
populationRepo repository.ProjectFlockPopulationRepository,
recordingRepo recordingRepo.RecordingRepository,
approvalSvc commonSvc.ApprovalService,
validate *validator.Validate,
@@ -86,7 +92,10 @@ func NewProjectflockService(
NonstockRepo: nonstockRepo,
WarehouseRepo: warehouseRepo,
ProductWarehouseRepo: productWarehouseRepo,
ProjectBudgetRepo: projectBudgetRepo,
PivotRepo: pivotRepo,
PopulationRepo: populationRepo,
RecordingRepo: recordingRepo,
ApprovalSvc: approvalSvc,
approvalWorkflow: utils.ApprovalWorkflowProjectFlock,
}
@@ -419,6 +428,34 @@ func (s projectflockService) GetProjectFlockKandangByProjectAndKandang(ctx *fibe
return pfk, availableQuantity, nil
}
func (s projectflockService) GetProjectFlockKandangPopulation(ctx *fiber.Ctx, projectFlockKandangID uint) (float64, error) {
if s.PopulationRepo == nil {
return 0, fiber.NewError(fiber.StatusInternalServerError, "Project flock population repository is not configured")
}
if projectFlockKandangID == 0 {
return 0, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required")
}
if s.RecordingRepo != nil {
latest, err := s.RecordingRepo.GetLatestByProjectFlockKandangID(ctx.Context(), projectFlockKandangID)
if err != nil {
s.Log.Errorf("Failed to fetch latest recording for project flock kandang %d: %+v", projectFlockKandangID, err)
return 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang population")
}
if latest != nil && latest.TotalChickQty != nil && *latest.TotalChickQty > 0 {
return *latest.TotalChickQty, nil
}
}
total, err := s.PopulationRepo.GetAvailableQtyByProjectFlockKandangID(ctx.Context(), projectFlockKandangID)
if err != nil {
s.Log.Errorf("Failed to fetch project flock kandang population %d: %+v", projectFlockKandangID, err)
return 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang population")
}
return total, nil
}
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)
@@ -795,6 +832,9 @@ func (s projectflockService) attachKandangs(ctx context.Context, dbTransaction *
}
return fiber.NewError(fiber.StatusInternalServerError, "Failed to persist project flock history")
}
if err := s.ensureProjectFlockKandangProductWarehouses(ctx, dbTransaction, records); err != nil {
return err
}
return nil
}
@@ -820,6 +860,23 @@ func (s projectflockService) detachKandangs(ctx context.Context, dbTransaction *
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Tidak dapat melepas kandang karena sudah memiliki recording: %s", strings.Join(names, ", ")))
}
pfkIDs, err := s.pivotRepoWithTx(dbTransaction).ListIDsByProjectAndKandang(ctx, projectFlockID, kandangIDs)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to load project flock kandang ids")
}
if len(pfkIDs) > 0 {
pwRepo := s.ProductWarehouseRepo
if dbTransaction != nil {
pwRepo = productWarehouseRepository.NewProductWarehouseRepository(dbTransaction)
} else if pwRepo == nil {
pwRepo = productWarehouseRepository.NewProductWarehouseRepository(s.Repository.DB())
}
if err := pwRepo.DeleteByProjectFlockKandangIDs(ctx, pfkIDs); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to remove product warehouses for project flock kandang")
}
}
if resetStatus {
if err := s.kandangRepoWithTx(dbTransaction).UpdateStatusByIDs(ctx, kandangIDs, utils.KandangStatusNonActive); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs status")
@@ -856,6 +913,81 @@ func (s projectflockService) kandangRepoWithTx(tx *gorm.DB) kandangRepository.Ka
return kandangRepository.NewKandangRepository(s.Repository.DB())
}
func (s projectflockService) ensureProjectFlockKandangProductWarehouses(ctx context.Context, dbTransaction *gorm.DB, records []*entity.ProjectFlockKandang) error {
if len(records) == 0 {
return nil
}
pwRepo := s.ProductWarehouseRepo
if dbTransaction != nil {
pwRepo = productWarehouseRepository.NewProductWarehouseRepository(dbTransaction)
} else if pwRepo == nil {
pwRepo = productWarehouseRepository.NewProductWarehouseRepository(s.Repository.DB())
}
warehouseRepo := s.WarehouseRepo
if dbTransaction != nil {
warehouseRepo = warehouseRepository.NewWarehouseRepository(dbTransaction)
} else if warehouseRepo == nil {
warehouseRepo = warehouseRepository.NewWarehouseRepository(s.Repository.DB())
}
flags := []utils.FlagType{
utils.FlagAyamAfkir,
utils.FlagAyamCulling,
utils.FlagAyamMati,
utils.FlagTelurPecah,
utils.FlagTelurUtuh,
}
productIDs := make(map[utils.FlagType]uint, len(flags))
for _, flag := range flags {
product, err := pwRepo.GetFirstProductByFlag(ctx, string(flag))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Product untuk flag %s tidak ditemukan", flag))
}
return err
}
productIDs[flag] = product.Id
}
for _, record := range records {
if record == nil || record.Id == 0 {
continue
}
warehouse, err := warehouseRepo.GetByKandangID(ctx, record.KandangId)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Warehouse untuk kandang %d belum tersedia", record.KandangId))
}
return err
}
for _, flag := range flags {
productID := productIDs[flag]
if _, err := pwRepo.GetByProductWarehouseAndProjectFlockKandang(ctx, productID, warehouse.Id, record.Id); err == nil {
continue
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
newPW := entity.ProductWarehouse{
ProductId: productID,
WarehouseId: warehouse.Id,
ProjectFlockKandangId: &record.Id,
Quantity: 0,
}
if err := pwRepo.CreateOne(ctx, &newPW, nil); err != nil {
return err
}
}
}
return nil
}
func (s projectflockService) Resubmit(c *fiber.Ctx, req *validation.Resubmit, id uint) (*entity.ProjectFlock, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
@@ -22,11 +22,21 @@ type RecordingRelationDTO struct {
ProjectFlockCategory string `json:"project_flock_category"`
TotalDepletionQty float64 `json:"total_depletion_qty"`
CumDepletionRate float64 `json:"cum_depletion_rate"`
DailyGain float64 `json:"daily_gain"`
AvgDailyGain float64 `json:"avg_daily_gain"`
CumIntake int `json:"cum_intake"`
FcrValue float64 `json:"fcr_value"`
TotalChickQty float64 `json:"total_chick_qty"`
HandDay float64 `json:"hand_day"`
HandHouse float64 `json:"hand_house"`
FeedIntake float64 `json:"feed_intake"`
EggMesh float64 `json:"egg_mesh"`
EggWeight float64 `json:"egg_weight"`
StandardHandDay *float64 `json:"hand_day_std,omitempty"`
StandardHandHouse *float64 `json:"hand_house_std,omitempty"`
StandardFeedIntake *float64 `json:"feed_intake_std,omitempty"`
StandardMaxDepletion *float64 `json:"max_depletion_std,omitempty"`
StandardEggMesh *float64 `json:"egg_mesh_std,omitempty"`
StandardEggWeight *float64 `json:"egg_weight_std,omitempty"`
StandardFcr *float64 `json:"fcr_std,omitempty"`
Approval approvalDTO.ApprovalRelationDTO `json:"approval"`
}
@@ -39,16 +49,9 @@ type RecordingListDTO struct {
type RecordingDetailDTO struct {
RecordingListDTO
BodyWeights []RecordingBodyWeightDTO `json:"body_weights"`
Depletions []RecordingDepletionDTO `json:"depletions"`
Stocks []RecordingStockDTO `json:"stocks"`
Eggs []RecordingEggDTO `json:"eggs"`
}
type RecordingBodyWeightDTO struct {
AvgWeight float64 `json:"avg_weight"`
Qty float64 `json:"qty"`
TotalWeight float64 `json:"total_weight"`
Depletions []RecordingDepletionDTO `json:"depletions"`
Stocks []RecordingStockDTO `json:"stocks"`
Eggs []RecordingEggDTO `json:"eggs"`
}
type RecordingDepletionDTO struct {
@@ -88,11 +91,14 @@ func ToRecordingRelationDTO(e entity.Recording) RecordingRelationDTO {
day int
totalDepletionQty float64
cumDepletionRate float64
dailyGain float64
avgDailyGain float64
cumIntake int
fcrValue float64
totalChickQty float64
handDay float64
handHouse float64
feedIntake float64
eggMesh float64
eggWeight float64
)
if e.Day != nil {
@@ -104,12 +110,6 @@ func ToRecordingRelationDTO(e entity.Recording) RecordingRelationDTO {
if e.CumDepletionRate != nil {
cumDepletionRate = *e.CumDepletionRate
}
if e.DailyGain != nil {
dailyGain = *e.DailyGain
}
if e.AvgDailyGain != nil {
avgDailyGain = *e.AvgDailyGain
}
if e.CumIntake != nil {
cumIntake = *e.CumIntake
}
@@ -119,6 +119,21 @@ func ToRecordingRelationDTO(e entity.Recording) RecordingRelationDTO {
if e.TotalChickQty != nil {
totalChickQty = *e.TotalChickQty
}
if e.HandDay != nil {
handDay = *e.HandDay
}
if e.HandHouse != nil {
handHouse = *e.HandHouse
}
if e.FeedIntake != nil {
feedIntake = *e.FeedIntake
}
if e.EggMesh != nil {
eggMesh = *e.EggMesh
}
if e.EggWeight != nil {
eggWeight = *e.EggWeight
}
if e.ProjectFlockKandang != nil && e.ProjectFlockKandang.ProjectFlock.Id != 0 {
category := e.ProjectFlockKandang.ProjectFlock.Category
@@ -139,11 +154,21 @@ func ToRecordingRelationDTO(e entity.Recording) RecordingRelationDTO {
ProjectFlockCategory: projectFlockCategory,
TotalDepletionQty: totalDepletionQty,
CumDepletionRate: cumDepletionRate,
DailyGain: dailyGain,
AvgDailyGain: avgDailyGain,
CumIntake: cumIntake,
FcrValue: fcrValue,
TotalChickQty: totalChickQty,
HandDay: handDay,
HandHouse: handHouse,
FeedIntake: feedIntake,
EggMesh: eggMesh,
EggWeight: eggWeight,
StandardHandDay: e.StandardHandDay,
StandardHandHouse: e.StandardHandHouse,
StandardFeedIntake: e.StandardFeedIntake,
StandardMaxDepletion: e.StandardMaxDepletion,
StandardEggMesh: e.StandardEggMesh,
StandardEggWeight: e.StandardEggWeight,
StandardFcr: e.StandardFcr,
Approval: latestApproval,
}
}
@@ -183,25 +208,12 @@ func ToRecordingDetailDTO(e entity.Recording) RecordingDetailDTO {
return RecordingDetailDTO{
RecordingListDTO: listDTO,
BodyWeights: ToRecordingBodyWeightDTOs(e.BodyWeights),
Depletions: ToRecordingDepletionDTOs(e.Depletions),
Stocks: ToRecordingStockDTOs(e.Stocks),
Eggs: eggs,
}
}
func ToRecordingBodyWeightDTOs(bodyWeights []entity.RecordingBW) []RecordingBodyWeightDTO {
result := make([]RecordingBodyWeightDTO, len(bodyWeights))
for i, bw := range bodyWeights {
result[i] = RecordingBodyWeightDTO{
AvgWeight: bw.AvgWeight,
Qty: bw.Qty,
TotalWeight: bw.TotalWeight,
}
}
return result
}
func ToRecordingDepletionDTOs(depletions []entity.RecordingDepletion) []RecordingDepletionDTO {
result := make([]RecordingDepletionDTO, len(depletions))
for i, d := range depletions {
@@ -17,11 +17,9 @@ type RecordingRepository interface {
repository.BaseRepository[entity.Recording]
WithRelations(db *gorm.DB) *gorm.DB
GetLatestByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (*entity.Recording, error)
GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error)
CreateBodyWeights(tx *gorm.DB, bodyWeights []entity.RecordingBW) error
DeleteBodyWeights(tx *gorm.DB, recordingID uint) error
CreateStocks(tx *gorm.DB, stocks []entity.RecordingStock) error
DeleteStocks(tx *gorm.DB, recordingID uint) error
ListStocks(tx *gorm.DB, recordingID uint) ([]entity.RecordingStock, error)
@@ -41,10 +39,11 @@ type RecordingRepository interface {
SumRecordingDepletions(tx *gorm.DB, recordingID uint) (float64, error)
FindPreviousRecording(tx *gorm.DB, projectFlockKandangId uint, currentDay int) (*entity.Recording, error)
GetTotalChick(tx *gorm.DB, projectFlockKandangId uint) (int64, error)
GetAverageBodyWeight(tx *gorm.DB, recordingID uint) (float64, error)
GetTotalChickinByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint) (float64, error)
GetFeedUsageInGrams(tx *gorm.DB, recordingID uint) (float64, error)
GetFcrID(tx *gorm.DB, projectFlockKandangId uint) (uint, error)
GetFcrStandardWeightKg(tx *gorm.DB, fcrId uint, currentWeightKg float64) (float64, bool, error)
GetEggSummaryByRecording(tx *gorm.DB, recordingID uint) (totalQty float64, totalWeightGrams float64, err error)
GetCumulativeEggQtyByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint, recordTime time.Time) (float64, error)
GetFcrStandardNumber(tx *gorm.DB, fcrId uint, currentWeightKg float64) (float64, bool, error)
GetProductionWeightAndQtyByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeight float64, totalQty float64, err error)
GetTotalDepletionByProjectFlockID(ctx context.Context, projectFlockID uint) (totalDepletion float64, err error)
GetLatestAvgWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (avgWeight float64, err error)
@@ -66,7 +65,6 @@ func (r *RecordingRepositoryImpl) WithRelations(db *gorm.DB) *gorm.DB {
Preload("CreatedUser").
Preload("ProjectFlockKandang").
Preload("ProjectFlockKandang.ProjectFlock").
Preload("BodyWeights").
Preload("Depletions").
Preload("Depletions.ProductWarehouse").
Preload("Depletions.ProductWarehouse.Product").
@@ -81,6 +79,27 @@ func (r *RecordingRepositoryImpl) WithRelations(db *gorm.DB) *gorm.DB {
Preload("Eggs.ProductWarehouse.Warehouse")
}
func (r *RecordingRepositoryImpl) GetLatestByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (*entity.Recording, error) {
if projectFlockKandangId == 0 {
return nil, errors.New("project_flock_kandang_id is required")
}
var record entity.Recording
err := r.DB().WithContext(ctx).
Where("project_flock_kandangs_id = ?", projectFlockKandangId).
Order("record_datetime DESC").
Order("created_at DESC").
Limit(1).
Find(&record).Error
if errors.Is(err, gorm.ErrRecordNotFound) || record.Id == 0 {
return nil, nil
}
if err != nil {
return nil, err
}
return &record, nil
}
func (r *RecordingRepositoryImpl) GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error) {
var days []int
if err := tx.Model(&entity.Recording{}).
@@ -92,17 +111,6 @@ func (r *RecordingRepositoryImpl) GenerateNextDay(tx *gorm.DB, projectFlockKanda
return nextRecordingDay(days), nil
}
func (r *RecordingRepositoryImpl) CreateBodyWeights(tx *gorm.DB, bodyWeights []entity.RecordingBW) error {
if len(bodyWeights) == 0 {
return nil
}
return tx.Create(&bodyWeights).Error
}
func (r *RecordingRepositoryImpl) DeleteBodyWeights(tx *gorm.DB, recordingID uint) error {
return tx.Where("recording_id = ?", recordingID).Delete(&entity.RecordingBW{}).Error
}
func (r *RecordingRepositoryImpl) CreateStocks(tx *gorm.DB, stocks []entity.RecordingStock) error {
if len(stocks) == 0 {
return nil
@@ -271,21 +279,18 @@ func (r *RecordingRepositoryImpl) GetTotalChick(tx *gorm.DB, projectFlockKandang
return int64(math.Round(total)), nil
}
func (r *RecordingRepositoryImpl) GetAverageBodyWeight(tx *gorm.DB, recordingID uint) (float64, error) {
var result struct {
TotalWeight float64
TotalQty float64
}
if err := tx.Model(&entity.RecordingBW{}).
Select("COALESCE(SUM(total_weight), 0) AS total_weight, COALESCE(SUM(qty), 0) AS total_qty").
Where("recording_id = ?", recordingID).
Scan(&result).Error; err != nil {
return 0, err
}
if result.TotalQty == 0 {
func (r *RecordingRepositoryImpl) GetTotalChickinByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint) (float64, error) {
if projectFlockKandangId == 0 {
return 0, nil
}
return result.TotalWeight / result.TotalQty, nil
var result float64
err := tx.
Table("project_chickins").
Select("COALESCE(SUM(project_chickins.usage_qty), 0)").
Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangId).
Scan(&result).Error
return result, err
}
func (r *RecordingRepositoryImpl) GetFeedUsageInGrams(tx *gorm.DB, recordingID uint) (float64, error) {
@@ -322,22 +327,48 @@ func (r *RecordingRepositoryImpl) GetFeedUsageInGrams(tx *gorm.DB, recordingID u
return total, nil
}
func (r *RecordingRepositoryImpl) GetFcrID(tx *gorm.DB, projectFlockKandangId uint) (uint, error) {
func (r *RecordingRepositoryImpl) GetEggSummaryByRecording(tx *gorm.DB, recordingID uint) (totalQty float64, totalWeightGrams float64, err error) {
if recordingID == 0 {
return 0, 0, nil
}
var result struct {
FcrID uint
TotalQty float64
TotalWeightGrams float64
}
if err := tx.Table("project_flock_kandangs").
Select("project_flocks.fcr_id AS fcr_id").
Joins("JOIN project_flocks ON project_flocks.id = project_flock_kandangs.project_flock_id").
Where("project_flock_kandangs.id = ?", projectFlockKandangId).
Scan(&result).Error; err != nil {
return 0, err
err = tx.
Table("recording_eggs").
Select("COALESCE(SUM(recording_eggs.qty), 0) AS total_qty, COALESCE(SUM(recording_eggs.qty * COALESCE(recording_eggs.weight, 0)), 0) AS total_weight_grams").
Where("recording_eggs.recording_id = ?", recordingID).
Scan(&result).Error
if err != nil {
return 0, 0, err
}
return result.FcrID, nil
return result.TotalQty, result.TotalWeightGrams, nil
}
func (r *RecordingRepositoryImpl) GetFcrStandardWeightKg(tx *gorm.DB, fcrId uint, currentWeightKg float64) (float64, bool, error) {
if fcrId == 0 {
func (r *RecordingRepositoryImpl) GetCumulativeEggQtyByProjectFlockKandang(
tx *gorm.DB,
projectFlockKandangId uint,
recordTime time.Time,
) (float64, error) {
if projectFlockKandangId == 0 {
return 0, nil
}
var result float64
err := tx.
Table("recording_eggs").
Select("COALESCE(SUM(recording_eggs.qty), 0)").
Joins("JOIN recordings ON recordings.id = recording_eggs.recording_id").
Where("recordings.project_flock_kandangs_id = ?", projectFlockKandangId).
Where("recordings.record_datetime <= ?", recordTime).
Scan(&result).Error
return result, err
}
func (r *RecordingRepositoryImpl) GetFcrStandardNumber(tx *gorm.DB, fcrId uint, currentWeightKg float64) (float64, bool, error) {
if fcrId == 0 || currentWeightKg <= 0 {
return 0, false, nil
}
@@ -360,49 +391,12 @@ func (r *RecordingRepositoryImpl) GetFcrStandardWeightKg(tx *gorm.DB, fcrId uint
return 0, false, err
}
weight := standard.Weight
if weight > 10 {
return weight / 1000, true, nil
}
return weight, true, nil
return standard.FcrNumber, true, nil
}
func (r *RecordingRepositoryImpl) GetProductionWeightAndQtyByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeight float64, totalQty float64, err error) {
if projectFlockID == 0 {
return 0, 0, nil
}
totalChickinQty, err := r.getTotalChickinQtyByProjectFlockID(ctx, projectFlockID)
if err != nil {
return 0, 0, err
}
totalDepletion, err := r.GetTotalDepletionByProjectFlockID(ctx, projectFlockID)
if err != nil {
return 0, 0, err
}
actualQty := totalChickinQty - totalDepletion
avgWeight, err := r.GetLatestAvgWeightByProjectFlockID(ctx, projectFlockID)
if err != nil {
return 0, 0, err
}
totalWeight = actualQty * avgWeight
return totalWeight, actualQty, nil
}
func (r *RecordingRepositoryImpl) getTotalChickinQtyByProjectFlockID(ctx context.Context, projectFlockID uint) (float64, error) {
var result float64
err := r.DB().WithContext(ctx).
Table("project_chickins").
Select("COALESCE(SUM(project_chickins.usage_qty), 0)").
Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = project_chickins.project_flock_kandang_id").
Where("project_flock_kandangs.project_flock_id = ?", projectFlockID).
Scan(&result).Error
return result, err
// Body-weight tracking is removed; keep stub for report compatibility.
return 0, 0, nil
}
func (r *RecordingRepositoryImpl) GetTotalDepletionByProjectFlockID(ctx context.Context, projectFlockID uint) (float64, error) {
@@ -418,16 +412,8 @@ func (r *RecordingRepositoryImpl) GetTotalDepletionByProjectFlockID(ctx context.
}
func (r *RecordingRepositoryImpl) GetLatestAvgWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (float64, error) {
var result float64
err := r.DB().WithContext(ctx).
Table("recording_bws").
Select("COALESCE(AVG(recording_bws.avg_weight), 0)").
Joins("JOIN recordings ON recordings.id = recording_bws.recording_id").
Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = recordings.project_flock_kandangs_id").
Where("project_flock_kandangs.project_flock_id = ?", projectFlockID).
Where("recordings.record_datetime = (SELECT MAX(record_datetime) FROM recordings r2 WHERE r2.project_flock_kandangs_id IN (SELECT id FROM project_flock_kandangs WHERE project_flock_id = ?))", projectFlockID).
Scan(&result).Error
return result, err
// Body-weight tracking is removed; keep stub for report compatibility.
return 0, nil
}
func (r *RecordingRepositoryImpl) GetTotalEggProductionWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (float64, error) {
@@ -9,6 +9,7 @@ import (
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories"
rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations"
@@ -121,6 +122,9 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
if err := s.attachLatestApprovals(c.Context(), recordings); err != nil {
return nil, 0, err
}
if err := s.attachProductionStandards(c.Context(), recordings); err != nil {
return nil, 0, err
}
return recordings, total, nil
}
@@ -138,6 +142,9 @@ func (s recordingService) GetOne(c *fiber.Ctx, id uint) (*entity.Recording, erro
if err := s.attachLatestApproval(c.Context(), recording); err != nil {
return nil, err
}
if err := s.attachProductionStandard(c.Context(), recording); err != nil {
return nil, err
}
return recording, nil
}
@@ -233,12 +240,6 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
return err
}
mappedBodyWeights := recordingutil.MapBodyWeights(createdRecording.Id, req.BodyWeights)
if err := s.Repository.CreateBodyWeights(tx, mappedBodyWeights); err != nil {
s.Log.Errorf("Failed to persist body weights: %+v", err)
return err
}
mappedStocks := recordingutil.MapStocks(createdRecording.Id, req.Stocks)
if err := s.Repository.CreateStocks(tx, mappedStocks); err != nil {
s.Log.Errorf("Failed to persist stocks: %+v", err)
@@ -261,7 +262,7 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
return err
}
if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, mappedDepletions, nil, nil, nil, mappedEggs)); err != nil {
if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, mappedDepletions, nil, mappedEggs)); err != nil {
s.Log.Errorf("Failed to adjust product warehouses: %+v", err)
return err
}
@@ -291,7 +292,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
return nil, err
}
if req.BodyWeights == nil && req.Stocks == nil && req.Depletions == nil && req.Eggs == nil {
if req.Stocks == nil && req.Depletions == nil && req.Eggs == nil {
return s.GetOne(c, id)
}
@@ -311,12 +312,11 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
}
recordingEntity = recording
hasBodyChanges := req.BodyWeights != nil
hasStockChanges := req.Stocks != nil
hasDepletionChanges := req.Depletions != nil
hasEggChanges := req.Eggs != nil
if !hasBodyChanges && !hasStockChanges && !hasDepletionChanges && !hasEggChanges {
if !hasStockChanges && !hasDepletionChanges && !hasEggChanges {
return nil
}
@@ -346,17 +346,6 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
}
}
if hasBodyChanges {
if err := s.Repository.DeleteBodyWeights(tx, recordingEntity.Id); err != nil {
s.Log.Errorf("Failed to clear body weights: %+v", err)
return err
}
if err := s.Repository.CreateBodyWeights(tx, recordingutil.MapBodyWeights(recordingEntity.Id, req.BodyWeights)); err != nil {
s.Log.Errorf("Failed to update body weights: %+v", err)
return err
}
}
if hasStockChanges {
existingStocks, err := s.Repository.ListStocks(tx, recordingEntity.Id)
if err != nil {
@@ -402,7 +391,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
return err
}
if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(existingDepletions, mappedDepletions, nil, nil, nil, nil)); err != nil {
if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(existingDepletions, mappedDepletions, nil, nil)); err != nil {
s.Log.Errorf("Failed to adjust product warehouses for depletions: %+v", err)
return err
}
@@ -426,13 +415,13 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
return err
}
if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, nil, nil, nil, existingEggs, mappedEggs)); err != nil {
if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, nil, existingEggs, mappedEggs)); err != nil {
s.Log.Errorf("Failed to adjust product warehouses for eggs: %+v", err)
return err
}
}
if hasBodyChanges || hasStockChanges || hasDepletionChanges {
if hasStockChanges || hasDepletionChanges {
if err := s.computeAndUpdateMetrics(ctx, tx, recordingEntity); err != nil {
s.Log.Errorf("Failed to recompute recording metrics: %+v", err)
return err
@@ -596,7 +585,7 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error {
return err
}
if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(oldDepletions, nil, nil, nil, oldEggs, nil)); err != nil {
if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(oldDepletions, nil, oldEggs, nil)); err != nil {
return err
}
@@ -724,7 +713,6 @@ func (s *recordingService) ReleaseRecordingStocks(ctx context.Context, tx *gorm.
func buildWarehouseDeltas(
oldDepletions, newDepletions []entity.RecordingDepletion,
oldStocks, newStocks []entity.RecordingStock,
oldEggs, newEggs []entity.RecordingEgg,
) map[uint]float64 {
deltas := make(map[uint]float64)
@@ -775,7 +763,6 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm
var prevCumDepletionQty float64
var prevCumIntake float64
var prevAvgWeight float64
if prevRecording != nil {
if prevRecording.TotalDepletionQty != nil {
prevCumDepletionQty = *prevRecording.TotalDepletionQty
@@ -783,10 +770,6 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm
if prevRecording.CumIntake != nil {
prevCumIntake = float64(*prevRecording.CumIntake)
}
prevAvgWeight, err = s.Repository.GetAverageBodyWeight(tx, prevRecording.Id)
if err != nil {
return fmt.Errorf("getAverageBodyWeight(prev): %w", err)
}
}
totalChick, err := s.Repository.GetTotalChick(tx, recording.ProjectFlockKandangId)
@@ -794,20 +777,25 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm
return fmt.Errorf("getTotalChick: %w", err)
}
currentAvgWeight, err := s.Repository.GetAverageBodyWeight(tx, recording.Id)
if err != nil {
return fmt.Errorf("getAverageBodyWeight(current): %w", err)
}
usageInGrams, err := s.Repository.GetFeedUsageInGrams(tx, recording.Id)
if err != nil {
return fmt.Errorf("getFeedUsageInGrams: %w", err)
}
currentAvgGrams := recordingutil.ToGrams(currentAvgWeight)
currentAvgKg := recordingutil.GramsToKg(currentAvgGrams)
prevAvgGrams := recordingutil.ToGrams(prevAvgWeight)
prevAvgKg := recordingutil.GramsToKg(prevAvgGrams)
totalEggQty, totalEggWeightGrams, err := s.Repository.GetEggSummaryByRecording(tx, recording.Id)
if err != nil {
return fmt.Errorf("getEggSummaryByRecording: %w", err)
}
cumulativeEggQty, err := s.Repository.GetCumulativeEggQtyByProjectFlockKandang(tx, recording.ProjectFlockKandangId, recording.RecordDatetime)
if err != nil {
return fmt.Errorf("getCumulativeEggQtyByProjectFlockKandang: %w", err)
}
initialChickin, err := s.Repository.GetTotalChickinByProjectFlockKandang(tx, recording.ProjectFlockKandangId)
if err != nil {
return fmt.Errorf("getTotalChickinByProjectFlockKandang: %w", err)
}
currentDepletion := float64(totalDepletionQty)
cumDepletionQty := prevCumDepletionQty + currentDepletion
@@ -840,24 +828,64 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm
recording.CumDepletionRate = nil
}
if currentAvgGrams > 0 && prevAvgGrams > 0 {
dailyGainKg := (currentAvgGrams - prevAvgGrams) / 1000
updates["daily_gain"] = dailyGainKg
recording.DailyGain = &dailyGainKg
var feedIntake float64
if remainingChick > 0 && usageInGrams > 0 {
feedIntake = (usageInGrams / remainingChick) * 1000
updates["feed_intake"] = feedIntake
recording.FeedIntake = &feedIntake
} else {
dailyGainKg := 0.0
updates["daily_gain"] = dailyGainKg
recording.DailyGain = &dailyGainKg
updates["feed_intake"] = gorm.Expr("NULL")
recording.FeedIntake = nil
}
if currentAvgKg > 0 && remainingChick > 0 {
avgDailyGain := (currentAvgKg - prevAvgKg) / remainingChick
updates["avg_daily_gain"] = avgDailyGain
recording.AvgDailyGain = &avgDailyGain
var handDay float64
if remainingChick > 0 && totalEggQty >= 0 {
handDay = (totalEggQty / remainingChick) * 100
updates["hand_day"] = handDay
recording.HandDay = &handDay
} else {
avgDailyGain := 0.0
updates["avg_daily_gain"] = avgDailyGain
recording.AvgDailyGain = &avgDailyGain
updates["hand_day"] = gorm.Expr("NULL")
recording.HandDay = nil
}
var handHouse float64
if initialChickin > 0 && cumulativeEggQty >= 0 {
handHouse = cumulativeEggQty / initialChickin
updates["hand_house"] = handHouse
recording.HandHouse = &handHouse
} else {
updates["hand_house"] = gorm.Expr("NULL")
recording.HandHouse = nil
}
var eggMesh float64
if remainingChick > 0 && totalEggWeightGrams > 0 {
eggMesh = (totalEggWeightGrams / remainingChick) * 1000
updates["egg_mesh"] = eggMesh
recording.EggMesh = &eggMesh
} else {
updates["egg_mesh"] = gorm.Expr("NULL")
recording.EggMesh = nil
}
var eggWeight float64
if totalEggQty > 0 && totalEggWeightGrams > 0 {
eggWeight = (totalEggWeightGrams / totalEggQty) * 1000
updates["egg_weight"] = eggWeight
recording.EggWeight = &eggWeight
} else {
updates["egg_weight"] = gorm.Expr("NULL")
recording.EggWeight = nil
}
var fcrValue float64
if usageInGrams > 0 && totalEggWeightGrams > 0 {
fcrValue = totalEggWeightGrams / usageInGrams
updates["fcr_value"] = fcrValue
recording.FcrValue = &fcrValue
} else {
updates["fcr_value"] = gorm.Expr("NULL")
recording.FcrValue = nil
}
if usageInGrams > 0 && totalChick > 0 {
@@ -882,16 +910,6 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm
recording.CumIntake = nil
}
if usageInGrams > 0 && currentAvgKg > 0 {
feedUsageKg := usageInGrams / 1000
fcrValue := feedUsageKg / currentAvgKg
updates["fcr_value"] = fcrValue
recording.FcrValue = &fcrValue
} else {
updates["fcr_value"] = gorm.Expr("NULL")
recording.FcrValue = nil
}
if err := s.Repository.WithTx(tx).PatchOne(ctx, recording.Id, updates, nil); err != nil {
return err
}
@@ -997,6 +1015,104 @@ func (s *recordingService) attachLatestApproval(ctx context.Context, item *entit
return nil
}
type productionStandardValues struct {
HandDay *float64
HandHouse *float64
FeedIntake *float64
MaxDepletion *float64
EggMesh *float64
EggWeight *float64
}
func (s *recordingService) attachProductionStandards(ctx context.Context, items []entity.Recording) error {
if len(items) == 0 {
return nil
}
for i := range items {
if err := s.attachProductionStandard(ctx, &items[i]); err != nil {
s.Log.Warnf("Unable to load production standard for recording %d: %+v", items[i].Id, err)
}
}
return nil
}
func (s *recordingService) attachProductionStandard(ctx context.Context, item *entity.Recording) error {
if item == nil || item.Id == 0 {
return nil
}
if item.Day == nil || *item.Day <= 0 {
return nil
}
if item.ProjectFlockKandang == nil || item.ProjectFlockKandang.ProjectFlock.Id == 0 {
return nil
}
standardID := item.ProjectFlockKandang.ProjectFlock.ProductionStandardId
if standardID == 0 {
return nil
}
week := ((int(*item.Day) - 1) / 7) + 1
if week <= 0 {
return nil
}
category := strings.ToUpper(item.ProjectFlockKandang.ProjectFlock.Category)
db := s.Repository.DB()
standardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db)
growthDetailRepo := rProductionStandard.NewStandardGrowthDetailRepository(db)
var standard productionStandardValues
var standardFcr *float64
if category == string(utils.ProjectFlockCategoryLaying) {
detail, err := standardDetailRepo.GetByStandardIDAndWeek(ctx, standardID, week)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if detail != nil {
standard.HandDay = detail.TargetHenDayProduction
standard.HandHouse = detail.TargetHenHouseProduction
standard.EggWeight = detail.TargetEggWeight
standard.EggMesh = detail.TargetEggMass
}
}
growthDetail, err := growthDetailRepo.GetByStandardIDAndWeek(ctx, standardID, week)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if growthDetail != nil {
standard.FeedIntake = growthDetail.FeedIntake
standard.MaxDepletion = growthDetail.MaxDepletion
if category == string(utils.ProjectFlockCategoryLaying) && growthDetail.TargetMeanBw != nil && item.ProjectFlockKandang.ProjectFlock.FcrId > 0 {
targetWeight := *growthDetail.TargetMeanBw
if targetWeight > 10 {
targetWeight = targetWeight / 1000
}
if targetWeight > 0 {
fcrStd, ok, err := s.Repository.GetFcrStandardNumber(db, item.ProjectFlockKandang.ProjectFlock.FcrId, targetWeight)
if err != nil {
return err
}
if ok {
standardFcr = &fcrStd
}
}
}
}
item.StandardHandDay = standard.HandDay
item.StandardHandHouse = standard.HandHouse
item.StandardFeedIntake = standard.FeedIntake
item.StandardMaxDepletion = standard.MaxDepletion
item.StandardEggMesh = standard.EggMesh
item.StandardEggWeight = standard.EggWeight
item.StandardFcr = standardFcr
return nil
}
func uniqueUintSlice(values []uint) []uint {
if len(values) == 0 {
return nil
@@ -1,12 +1,6 @@
package validation
type (
BodyWeight struct {
AvgWeight float64 `json:"avg_weight" validate:"required"`
Qty float64 `json:"qty" validate:"required,gt=0"`
TotalWeight *float64 `json:"total_weight,omitempty" validate:"omitempty,gte=0"`
}
Stock struct {
ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"`
Qty float64 `json:"qty" validate:"required,gte=0"`
@@ -27,14 +21,12 @@ type (
type Create struct {
ProjectFlockKandangId uint `json:"project_flock_kandang_id" validate:"required,number,min=1"`
BodyWeights []BodyWeight `json:"body_weights" validate:"dive"`
Stocks []Stock `json:"stocks" validate:"dive"`
Depletions []Depletion `json:"depletions" validate:"dive"`
Eggs []Egg `json:"eggs" validate:"omitempty,dive"`
}
type Update struct {
BodyWeights []BodyWeight `json:"body_weights,omitempty" validate:"omitempty,dive"`
Stocks []Stock `json:"stocks,omitempty" validate:"omitempty,dive"`
Depletions []Depletion `json:"depletions,omitempty" validate:"omitempty,dive"`
Eggs []Egg `json:"eggs,omitempty" validate:"omitempty,dive"`
+4 -2
View File
@@ -8,10 +8,11 @@ import (
"gorm.io/gorm"
chickins "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins"
projectFlockKandangs "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project-flock-kandangs"
projectflocks "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks"
recordings "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings"
transferLayings "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings"
projectFlockKandangs "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project-flock-kandangs"
uniformitys "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities"
// MODULE IMPORTS
)
@@ -24,8 +25,9 @@ func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Valida
chickins.ChickinModule{},
transferLayings.TransferLayingModule{},
projectFlockKandangs.ProjectFlockKandangModule{},
uniformitys.UniformityModule{},
// MODULE REGISTRY
}
}
for _, m := range allModules {
m.RegisterRoutes(group, db, validate)
@@ -0,0 +1,292 @@
package controller
import (
"math"
"gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/validations"
"gitlab.com/mbugroup/lti-api.git/internal/response"
"github.com/gofiber/fiber/v2"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
)
type UniformityController struct {
UniformityService service.UniformityService
}
func NewUniformityController(uniformityService service.UniformityService) *UniformityController {
return &UniformityController{
UniformityService: uniformityService,
}
}
func (u *UniformityController) GetAll(c *fiber.Ctx) error {
query, err := validation.ParseQuery(c)
if err != nil {
return err
}
result, totalResults, err := u.UniformityService.GetAll(c, query)
if err != nil {
return err
}
standards, err := u.UniformityService.MapStandards(c, result)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.SuccessWithPaginate[dto.UniformityListDTO]{
Code: fiber.StatusOK,
Status: "success",
Message: "Get all production uniformities successfully",
Meta: response.Meta{
Page: query.Page,
Limit: query.Limit,
TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))),
TotalResults: totalResults,
Filters: fiber.Map{
"location_id": "",
"project_flock_id": "",
"status": "Pengajuan",
},
},
Data: dto.ToUniformityListDTOsWithStandard(result, standards),
})
}
func (u *UniformityController) GetOne(c *fiber.Ctx) error {
id, err := validation.ParseIDParam(c, "id")
if err != nil {
return err
}
result, err := u.UniformityService.GetOne(c, id)
if err != nil {
return err
}
withDetails := c.QueryBool("with_details", false)
calculation := service.UniformityCalculation{}
var document *entity.Document
var meanWeight float64
if result.MeanUp > 0 {
meanWeight = math.Round(result.MeanUp / 1.10)
}
if withDetails {
var err error
calculation, document, err = u.UniformityService.CalculateUniformityFromDocument(c, id)
if err != nil {
return err
}
} else {
calculation = service.UniformityCalculation{
ChickQtyOfWeight: result.ChickQtyOfWeight,
MeanWeight: meanWeight,
MeanDown: result.MeanDown,
MeanUp: result.MeanUp,
UniformQty: result.UniformQty,
OutsideQty: result.NotUniformQty,
Uniformity: result.Uniformity,
Cv: result.Cv,
}
}
standard, err := u.UniformityService.GetStandard(c, result)
if err != nil {
return err
}
var standardDTO *dto.UniformityStandardDTO
if standard != nil {
standardDTO = &dto.UniformityStandardDTO{
MeanWeight: standard.MeanWeight,
Uniformity: standard.Uniformity,
}
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Get production uniformity successfully",
Data: dto.ToUniformityDetailDTO(*result, calculation, document, standardDTO),
})
}
func (u *UniformityController) CreateOne(c *fiber.Ctx) error {
req, file, err := validation.ParseCreate(c)
if err != nil {
return err
}
rows, err := u.UniformityService.ParseBodyWeightExcel(c, file)
if err != nil {
return err
}
calculation, err := u.UniformityService.ComputeUniformity(rows)
if err != nil {
return err
}
result, err := u.UniformityService.CreateOne(c, req, file, rows)
if err != nil {
return err
}
document := dto.NewDocumentForResponse(file.Filename)
standard, err := u.UniformityService.GetStandard(c, result)
if err != nil {
return err
}
var standardDTO *dto.UniformityStandardDTO
if standard != nil {
standardDTO = &dto.UniformityStandardDTO{
MeanWeight: standard.MeanWeight,
Uniformity: standard.Uniformity,
}
}
return c.Status(fiber.StatusCreated).
JSON(response.Success{
Code: fiber.StatusCreated,
Status: "success",
Message: "Create uniformity successfully",
Data: dto.ToUniformityDetailDTO(*result, calculation, document, standardDTO),
})
}
func (u *UniformityController) UploadBodyWeightExcel(c *fiber.Ctx) error {
files, err := validation.ParseUploadFiles(c)
if err != nil {
return err
}
rows, err := u.UniformityService.ParseBodyWeightExcel(c, files[0])
if err != nil {
return err
}
calculation, err := u.UniformityService.ComputeUniformity(rows)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Uniformity verified successfully",
Data: dto.ToUniformityVerificationDTO(calculation),
})
}
func (u *UniformityController) UpdateOne(c *fiber.Ctx) error {
id, err := validation.ParseIDParam(c, "id")
if err != nil {
return err
}
req, file, err := validation.ParseUpdate(c)
if err != nil {
return err
}
var rows []service.BodyWeightExcelRow
if file != nil {
parsed, err := u.UniformityService.ParseBodyWeightExcel(c, file)
if err != nil {
return err
}
rows = parsed
}
result, err := u.UniformityService.UpdateOne(c, req, id, file, rows)
if err != nil {
return err
}
standard, err := u.UniformityService.GetStandard(c, result)
if err != nil {
return err
}
var standardDTO *dto.UniformityStandardDTO
if standard != nil {
standardDTO = &dto.UniformityStandardDTO{
MeanWeight: standard.MeanWeight,
Uniformity: standard.Uniformity,
}
}
calculation := service.UniformityCalculation{
ChickQtyOfWeight: result.ChickQtyOfWeight,
MeanWeight: math.Round(result.MeanUp / 1.10),
MeanDown: result.MeanDown,
MeanUp: result.MeanUp,
UniformQty: result.UniformQty,
OutsideQty: result.NotUniformQty,
Uniformity: result.Uniformity,
Cv: result.Cv,
}
var document *entity.Document
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Update uniformity successfully",
Data: dto.ToUniformityDetailDTO(*result, calculation, document, standardDTO),
})
}
func (u *UniformityController) DeleteOne(c *fiber.Ctx) error {
id, err := validation.ParseIDParam(c, "id")
if err != nil {
return err
}
if err := u.UniformityService.DeleteOne(c, id); err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Common{
Code: fiber.StatusOK,
Status: "success",
Message: "Delete uniformity successfully",
})
}
func (u *UniformityController) Approve(c *fiber.Ctx) error {
req, err := validation.ParseApprove(c)
if err != nil {
return err
}
results, err := u.UniformityService.Approval(c, req)
if err != nil {
return err
}
var (
data interface{}
message = "Submit uniformity approvals successfully"
)
if len(results) == 1 {
message = "Submit uniformity approval successfully"
data = dto.ToUniformityListDTOs(results)[0]
} else {
data = dto.ToUniformityListDTOs(results)
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: message,
Data: data,
})
}
@@ -0,0 +1,236 @@
package dto
import (
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/services"
)
type UniformitySamplingDTO struct {
ChickQtyOfWeight float64 `json:"chick_qty_of_weight"`
MeanWeight float64 `json:"mean_weight"`
MeanDown float64 `json:"mean_down"`
MeanUp float64 `json:"mean_up"`
}
type UniformityResultDTO struct {
UniformQty float64 `json:"uniform_qty"`
OutsideQty float64 `json:"outside_qty"`
Uniformity float64 `json:"uniformity"`
Cv float64 `json:"cv"`
}
type UniformityStandardDTO struct {
MeanWeight *float64 `json:"mean_weight"`
Uniformity *float64 `json:"uniformity"`
}
type UniformityDetailItemDTO struct {
Id int `json:"id"`
Weight float64 `json:"weight"`
Range string `json:"range"`
}
type UniformityVerificationDTO struct {
Sampling UniformitySamplingDTO `json:"sampling"`
Result UniformityResultDTO `json:"result"`
UniformityDetails []UniformityDetailItemDTO `json:"uniformity_details"`
}
type UniformityInfoDTO struct {
Tanggal string `json:"tanggal"`
LokasiFarm string `json:"lokasi_farm"`
ProjectFlock string `json:"project_flock"`
Kandang string `json:"kandang"`
FileName string `json:"file_name"`
}
type UniformityDetailDTO struct {
Id uint `json:"id"`
InfoUmum UniformityInfoDTO `json:"info_umum"`
Sampling UniformitySamplingDTO `json:"sampling"`
Result UniformityResultDTO `json:"result"`
Standard *UniformityStandardDTO `json:"standard"`
UniformityDetails []UniformityDetailItemDTO `json:"uniformity_details"`
}
type UniformityListDTO struct {
Id uint `json:"id"`
ProjectFlockKandangId uint `json:"project_flock_kandang_id"`
LocationName string `json:"location_name"`
FlockName string `json:"flock_name"`
KandangName string `json:"kandang_name"`
AppliedAt *time.Time `json:"applied_at"`
Week int `json:"week"`
Status string `json:"status"`
Uniformity float64 `json:"uniformity"`
Cv float64 `json:"cv"`
ChickQtyOfWeight float64 `json:"chick_qty_of_weight"`
UniformQty float64 `json:"uniform_qty"`
MeanUp float64 `json:"mean_up"`
MeanDown float64 `json:"mean_down"`
StandardMeanWeight *float64 `json:"standard_mean_weight"`
StandardUniformity *float64 `json:"standard_uniformity"`
CreatedAt time.Time `json:"created_at"`
CreatedBy uint `json:"created_by"`
LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"`
}
func NewDocumentForResponse(name string) *entity.Document {
if name == "" {
return nil
}
return &entity.Document{Name: name}
}
func ToUniformityVerificationDTO(calc service.UniformityCalculation) UniformityVerificationDTO {
return UniformityVerificationDTO{
Sampling: toUniformitySamplingDTO(calc),
Result: toUniformityResultDTO(calc),
UniformityDetails: toUniformityDetailItemsDTO(calc),
}
}
func ToUniformityDetailDTO(
entityData entity.ProjectFlockKandangUniformity,
calc service.UniformityCalculation,
document *entity.Document,
standard *UniformityStandardDTO,
) UniformityDetailDTO {
info := UniformityInfoDTO{
Tanggal: formatUniformityDate(entityData.UniformDate),
LokasiFarm: resolveLocationName(entityData.ProjectFlockKandang),
ProjectFlock: resolveProjectFlockName(entityData.ProjectFlockKandang),
Kandang: resolveKandangName(entityData.ProjectFlockKandang),
FileName: "",
}
if document != nil {
info.FileName = document.Name
}
return UniformityDetailDTO{
Id: entityData.Id,
InfoUmum: info,
Sampling: toUniformitySamplingDTO(calc),
Result: toUniformityResultDTO(calc),
Standard: standard,
UniformityDetails: toUniformityDetailItemsDTO(calc),
}
}
func ToUniformityListDTOs(items []entity.ProjectFlockKandangUniformity) []UniformityListDTO {
result := make([]UniformityListDTO, len(items))
for i, item := range items {
var latestApproval *approvalDTO.ApprovalRelationDTO
status := "Pengajuan"
if item.LatestApproval != nil {
mapped := approvalDTO.ToApprovalDTO(*item.LatestApproval)
latestApproval = &mapped
if mapped.StepName != "" {
status = mapped.StepName
}
}
result[i] = UniformityListDTO{
Id: item.Id,
ProjectFlockKandangId: item.ProjectFlockKandangId,
LocationName: resolveLocationName(item.ProjectFlockKandang),
FlockName: resolveProjectFlockName(item.ProjectFlockKandang),
KandangName: resolveKandangName(item.ProjectFlockKandang),
AppliedAt: item.UniformDate,
Week: item.Week,
Status: status,
Uniformity: item.Uniformity,
Cv: item.Cv,
ChickQtyOfWeight: item.ChickQtyOfWeight,
UniformQty: item.UniformQty,
MeanUp: item.MeanUp,
MeanDown: item.MeanDown,
CreatedAt: item.CreatedAt,
CreatedBy: item.CreatedBy,
LatestApproval: latestApproval,
}
}
return result
}
func ToUniformityListDTOsWithStandard(
items []entity.ProjectFlockKandangUniformity,
standards map[uint]service.UniformityStandard,
) []UniformityListDTO {
result := ToUniformityListDTOs(items)
if len(result) == 0 || len(standards) == 0 {
return result
}
for i := range result {
if std, ok := standards[result[i].Id]; ok {
result[i].StandardMeanWeight = std.MeanWeight
result[i].StandardUniformity = std.Uniformity
}
}
return result
}
func toUniformitySamplingDTO(calc service.UniformityCalculation) UniformitySamplingDTO {
return UniformitySamplingDTO{
ChickQtyOfWeight: calc.ChickQtyOfWeight,
MeanWeight: calc.MeanWeight,
MeanDown: calc.MeanDown,
MeanUp: calc.MeanUp,
}
}
func toUniformityResultDTO(calc service.UniformityCalculation) UniformityResultDTO {
return UniformityResultDTO{
UniformQty: calc.UniformQty,
OutsideQty: calc.OutsideQty,
Uniformity: calc.Uniformity,
Cv: calc.Cv,
}
}
func toUniformityDetailItemsDTO(calc service.UniformityCalculation) []UniformityDetailItemDTO {
result := make([]UniformityDetailItemDTO, len(calc.Details))
for i, item := range calc.Details {
result[i] = UniformityDetailItemDTO{
Id: item.Id,
Weight: item.Weight,
Range: item.Range,
}
}
return result
}
func resolveLocationName(pfk entity.ProjectFlockKandang) string {
if pfk.Kandang.Id != 0 && pfk.Kandang.Location.Id != 0 {
return pfk.Kandang.Location.Name
}
if pfk.ProjectFlock.Id != 0 && pfk.ProjectFlock.Location.Id != 0 {
return pfk.ProjectFlock.Location.Name
}
return ""
}
func resolveProjectFlockName(pfk entity.ProjectFlockKandang) string {
if pfk.ProjectFlock.Id != 0 {
return pfk.ProjectFlock.FlockName
}
return ""
}
func resolveKandangName(pfk entity.ProjectFlockKandang) string {
if pfk.Kandang.Id != 0 {
return pfk.Kandang.Name
}
return ""
}
func formatUniformityDate(date *time.Time) string {
if date == nil || date.IsZero() {
return ""
}
return date.Format("2006-01-02")
}
@@ -0,0 +1,57 @@
package uniformitys
import (
"context"
"fmt"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories"
rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
rUniformity "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/repositories"
sUniformity "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/services"
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
)
type UniformityModule struct{}
func (UniformityModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
uniformityRepo := rUniformity.NewUniformityRepository(db)
documentRepo := commonRepo.NewDocumentRepository(db)
approvalRepo := commonRepo.NewApprovalRepository(db)
projectFlockKandangRepo := rProjectFlock.NewProjectFlockKandangRepository(db)
productionStandardRepo := rProductionStandard.NewProductionStandardRepository(db)
standardGrowthDetailRepo := rProductionStandard.NewStandardGrowthDetailRepository(db)
userRepo := rUser.NewUserRepository(db)
documentSvc, err := commonSvc.NewDocumentServiceFromConfig(context.Background(), documentRepo)
if err != nil {
panic(fmt.Sprintf("failed to create document service: %v", err))
}
approvalSvc := commonSvc.NewApprovalService(approvalRepo)
if err := approvalSvc.RegisterWorkflowSteps(utils.ApprovalWorkflowUniformity, utils.UniformityApprovalSteps); err != nil {
panic(fmt.Sprintf("failed to register uniformity approval workflow: %v", err))
}
uniformityService := sUniformity.NewUniformityService(
uniformityRepo,
documentSvc,
approvalRepo,
approvalSvc,
projectFlockKandangRepo,
productionStandardRepo,
standardGrowthDetailRepo,
validate,
)
userService := sUser.NewUserService(userRepo, validate)
UniformityRoutes(router, userService, uniformityService)
}
@@ -0,0 +1,21 @@
package repository
import (
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
)
type UniformityRepository interface {
repository.BaseRepository[entity.ProjectFlockKandangUniformity]
}
type UniformityRepositoryImpl struct {
*repository.BaseRepositoryImpl[entity.ProjectFlockKandangUniformity]
}
func NewUniformityRepository(db *gorm.DB) UniformityRepository {
return &UniformityRepositoryImpl{
BaseRepositoryImpl: repository.NewBaseRepository[entity.ProjectFlockKandangUniformity](db),
}
}
@@ -0,0 +1,25 @@
package uniformitys
import (
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
controller "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/controllers"
uniformity "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/services"
user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
"github.com/gofiber/fiber/v2"
)
func UniformityRoutes(v1 fiber.Router, u user.UserService, s uniformity.UniformityService) {
ctrl := controller.NewUniformityController(s)
route := v1.Group("/uniformities")
route.Use(m.Auth(u))
route.Get("/", m.RequirePermissions(m.P_Uniformities_GetAll), ctrl.GetAll)
route.Post("/", m.RequirePermissions(m.P_Uniformities_CreateOne), ctrl.CreateOne)
route.Post("/verify", m.RequirePermissions(m.P_Uniformities_Verify), ctrl.UploadBodyWeightExcel)
route.Post("/approvals", m.RequirePermissions(m.P_Uniformities_Approval), ctrl.Approve)
route.Get("/:id", m.RequirePermissions(m.P_Uniformities_GetOne), ctrl.GetOne)
route.Patch("/:id", m.RequirePermissions(m.P_Uniformities_UpdateOne), ctrl.UpdateOne)
route.Delete("/:id", m.RequirePermissions(m.P_Uniformities_DeleteOne), ctrl.DeleteOne)
}
@@ -0,0 +1,200 @@
package service
import (
"io"
"mime/multipart"
"strconv"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2"
)
type BodyWeightExcelRow struct {
No int `json:"no"`
Weight float64 `json:"weight"`
Range string `json:"range,omitempty"`
}
func (s uniformityService) ParseBodyWeightExcel(_ *fiber.Ctx, file *multipart.FileHeader) ([]BodyWeightExcelRow, error) {
if file == nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "file is required")
}
reader, err := file.Open()
if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "failed to open file")
}
defer reader.Close()
rows, err := parseBodyWeightExcelReader(reader)
if err != nil {
return nil, err
}
return rows, nil
}
func parseBodyWeightExcelReader(reader io.Reader) ([]BodyWeightExcelRow, error) {
xlsx, err := excelize.OpenReader(reader)
if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "failed to read excel file")
}
defer func() {
_ = xlsx.Close()
}()
sheets := xlsx.GetSheetList()
if len(sheets) == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "no sheets found in file")
}
sheetName := sheets[0]
if len(sheets) > 1 {
sheetName = sheets[1]
}
rows, err := xlsx.GetRows(sheetName, excelize.Options{RawCellValue: true})
if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "failed to read sheet rows")
}
return parseBodyWeightRows(rows)
}
func parseBodyWeightRows(rows [][]string) ([]BodyWeightExcelRow, error) {
headerRowIdx, noCol, bwCol, rangeCol := findBodyWeightHeader(rows)
if headerRowIdx < 0 || bwCol < 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "header BW not found")
}
result := make([]BodyWeightExcelRow, 0)
lastNo := 0
for i := headerRowIdx + 1; i < len(rows); i++ {
row := rows[i]
weightStr := cellAt(row, bwCol)
weightVal, ok := parseNumber(weightStr)
if !ok {
continue
}
noVal := 0
if noCol >= 0 {
if parsed, ok := parseNumber(cellAt(row, noCol)); ok {
noVal = int(parsed)
}
}
if noVal <= 0 {
noVal = lastNo + 1
}
if noVal > lastNo {
lastNo = noVal
}
rangeVal := ""
if rangeCol >= 0 {
rangeVal = strings.TrimSpace(cellAt(row, rangeCol))
}
rowPayload := BodyWeightExcelRow{
No: noVal,
Weight: weightVal,
Range: rangeVal,
}
if rowPayload.No <= 0 || rowPayload.Weight <= 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid body weight row data")
}
result = append(result, rowPayload)
}
if len(result) == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "no body weight data found")
}
return result, nil
}
func findBodyWeightHeader(rows [][]string) (rowIdx int, noCol int, bwCol int, rangeCol int) {
rowIdx = -1
noCol = -1
bwCol = -1
rangeCol = -1
for i, row := range rows {
tempNo := -1
tempBW := -1
tempRange := -1
for j, cell := range row {
label := normalizeHeader(cell)
switch label {
case "no":
tempNo = j
case "bw":
tempBW = j
case "outsiderange":
tempRange = j
default:
if strings.HasPrefix(label, "bw") {
tempBW = j
} else if strings.HasPrefix(label, "no") {
tempNo = j
} else if strings.Contains(label, "range") {
tempRange = j
}
}
}
if tempBW >= 0 {
rowIdx = i
bwCol = tempBW
noCol = tempNo
rangeCol = tempRange
break
}
}
return rowIdx, noCol, bwCol, rangeCol
}
func cellAt(row []string, idx int) string {
if idx < 0 || idx >= len(row) {
return ""
}
return strings.TrimSpace(row[idx])
}
func normalizeHeader(value string) string {
trimmed := strings.ToLower(strings.TrimSpace(value))
if trimmed == "" {
return ""
}
var b strings.Builder
for _, r := range trimmed {
if r >= 'a' && r <= 'z' {
b.WriteRune(r)
}
}
return b.String()
}
func parseNumber(value string) (float64, bool) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return 0, false
}
if strings.Contains(trimmed, ",") {
if strings.Contains(trimmed, ".") {
trimmed = strings.ReplaceAll(trimmed, ",", "")
} else {
trimmed = strings.ReplaceAll(trimmed, ",", ".")
}
}
parsed, err := strconv.ParseFloat(trimmed, 64)
if err != nil {
return 0, false
}
return parsed, true
}
@@ -0,0 +1,959 @@
package service
import (
"context"
"errors"
"fmt"
"math"
"mime/multipart"
"net/http"
"strings"
"time"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
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"
rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/validations"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
)
type UniformityService interface {
GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlockKandangUniformity, int64, error)
GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectFlockKandangUniformity, error)
GetSummary(ctx *fiber.Ctx, id uint) (*entity.ProjectFlockKandangUniformity, error)
GetStandard(ctx *fiber.Ctx, uniformity *entity.ProjectFlockKandangUniformity) (*UniformityStandard, error)
MapStandards(ctx *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]UniformityStandard, error)
CreateOne(ctx *fiber.Ctx, req *validation.Create, file *multipart.FileHeader, rows []BodyWeightExcelRow) (*entity.ProjectFlockKandangUniformity, error)
UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint, file *multipart.FileHeader, rows []BodyWeightExcelRow) (*entity.ProjectFlockKandangUniformity, error)
DeleteOne(ctx *fiber.Ctx, id uint) error
Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlockKandangUniformity, error)
ParseBodyWeightExcel(ctx *fiber.Ctx, file *multipart.FileHeader) ([]BodyWeightExcelRow, error)
ComputeUniformity(rows []BodyWeightExcelRow) (UniformityCalculation, error)
CalculateUniformityFromDocument(ctx *fiber.Ctx, uniformityID uint) (UniformityCalculation, *entity.Document, error)
}
type uniformityService struct {
Log *logrus.Logger
Validate *validator.Validate
Repository repository.UniformityRepository
DocumentSvc commonSvc.DocumentService
ApprovalRepo commonRepo.ApprovalRepository
ApprovalSvc commonSvc.ApprovalService
ProjectFlockKandangRepo rProjectFlock.ProjectFlockKandangRepository
ProductionStandardRepo rProductionStandard.ProductionStandardRepository
StandardGrowthDetailRepo rProductionStandard.StandardGrowthDetailRepository
}
func NewUniformityService(
repo repository.UniformityRepository,
documentSvc commonSvc.DocumentService,
approvalRepo commonRepo.ApprovalRepository,
approvalSvc commonSvc.ApprovalService,
projectFlockKandangRepo rProjectFlock.ProjectFlockKandangRepository,
productionStandardRepo rProductionStandard.ProductionStandardRepository,
standardGrowthDetailRepo rProductionStandard.StandardGrowthDetailRepository,
validate *validator.Validate,
) UniformityService {
return &uniformityService{
Log: utils.Log,
Validate: validate,
Repository: repo,
DocumentSvc: documentSvc,
ApprovalRepo: approvalRepo,
ApprovalSvc: approvalSvc,
ProjectFlockKandangRepo: projectFlockKandangRepo,
ProductionStandardRepo: productionStandardRepo,
StandardGrowthDetailRepo: standardGrowthDetailRepo,
}
}
func (s uniformityService) withRelations(db *gorm.DB) *gorm.DB {
return db.
Preload("ProjectFlockKandang.ProjectFlock.Location").
Preload("ProjectFlockKandang.Kandang.Location")
}
func (s uniformityService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlockKandangUniformity, int64, error) {
if err := s.Validate.Struct(params); err != nil {
return nil, 0, err
}
offset := (params.Page - 1) * params.Limit
uniformitys, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db)
if params.ProjectFlockKandangId != 0 {
db = db.Where("project_flock_kandang_id = ?", params.ProjectFlockKandangId)
}
if params.Week != 0 {
db = db.Where("week = ?", params.Week)
}
return db.Order("uniform_date DESC").Order("created_at DESC")
})
if err != nil {
s.Log.Errorf("Failed to get uniformitys: %+v", err)
return nil, 0, err
}
if err := s.attachLatestApprovals(c.Context(), uniformitys); err != nil {
return nil, 0, err
}
return uniformitys, total, nil
}
func (s uniformityService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlockKandangUniformity, error) {
uniformity, err := s.Repository.GetByID(c.Context(), id, s.withRelations)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Uniformity not found")
}
if err != nil {
s.Log.Errorf("Failed get uniformity by id: %+v", err)
return nil, err
}
if err := s.attachLatestApproval(c.Context(), uniformity); err != nil {
return nil, err
}
return uniformity, nil
}
func (s uniformityService) GetSummary(c *fiber.Ctx, id uint) (*entity.ProjectFlockKandangUniformity, error) {
return s.GetOne(c, id)
}
func (s uniformityService) GetStandard(c *fiber.Ctx, uniformity *entity.ProjectFlockKandangUniformity) (*UniformityStandard, error) {
if uniformity == nil {
return nil, nil
}
return s.resolveUniformityStandard(c.Context(), *uniformity)
}
func (s uniformityService) MapStandards(c *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]UniformityStandard, error) {
if len(items) == 0 {
return nil, nil
}
if s.ProductionStandardRepo == nil || s.StandardGrowthDetailRepo == nil {
return nil, nil
}
categoryStandard := make(map[string]*entity.ProductionStandard)
detailCache := make(map[uint]map[int]entity.StandardGrowthDetail)
result := make(map[uint]UniformityStandard, len(items))
for _, item := range items {
if item.Id == 0 {
continue
}
standard, err := s.resolveCategoryStandard(c.Context(), item.ProjectFlockKandang.ProjectFlock.Category, categoryStandard)
if err != nil {
return nil, err
}
if standard == nil {
continue
}
weekMap, ok := detailCache[standard.Id]
if !ok {
details, err := s.StandardGrowthDetailRepo.GetByProductionStandardID(c.Context(), standard.Id)
if err != nil {
return nil, err
}
weekMap = make(map[int]entity.StandardGrowthDetail, len(details))
for _, detail := range details {
weekMap[detail.Week] = detail
}
detailCache[standard.Id] = weekMap
}
detail, ok := weekMap[item.Week]
if !ok {
continue
}
standardDTO := UniformityStandard{
MeanWeight: cloneFloat64(detail.TargetMeanBw),
Uniformity: float64Ptr(detail.MinUniformity),
}
result[item.Id] = standardDTO
}
return result, nil
}
func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file *multipart.FileHeader, rows []BodyWeightExcelRow) (*entity.ProjectFlockKandangUniformity, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
if s.ProjectFlockKandangRepo == nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Project flock kandang repository not available")
}
if file == nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "document is required")
}
uniformDate, err := time.Parse("2006-01-02", req.Date)
if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "date must be in YYYY-MM-DD format")
}
if err := commonSvc.EnsureRelations(
c.Context(),
commonSvc.RelationCheck{Name: "Project Flock Kandang", ID: &req.ProjectFlockKandangId, Exists: s.ProjectFlockKandangRepo.IdExists},
); err != nil {
return nil, err
}
if err := s.ensureUniqueUniformity(c.Context(), 0, req.ProjectFlockKandangId, req.Week, &uniformDate); err != nil {
return nil, err
}
if len(rows) == 0 {
parsedRows, err := s.ParseBodyWeightExcel(c, file)
if err != nil {
return nil, err
}
rows = parsedRows
}
calculation, err := s.ComputeUniformity(rows)
if err != nil {
return nil, err
}
actorID, err := m.ActorIDFromContext(c)
if err != nil {
return nil, err
}
createBody := &entity.ProjectFlockKandangUniformity{
Uniformity: calculation.Uniformity,
Week: req.Week,
Cv: calculation.Cv,
ChickQtyOfWeight: calculation.ChickQtyOfWeight,
MeanUp: calculation.MeanUp,
MeanDown: calculation.MeanDown,
ProjectFlockKandangId: req.ProjectFlockKandangId,
UniformQty: calculation.UniformQty,
NotUniformQty: calculation.OutsideQty,
UniformDate: &uniformDate,
CreatedBy: actorID,
}
if err := s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
repoTx := s.Repository.WithTx(tx)
if err := repoTx.CreateOne(c.Context(), createBody, nil); err != nil {
return err
}
if err := s.createUniformityApproval(
c.Context(),
tx,
createBody.Id,
utils.UniformityStepPengajuan,
entity.ApprovalActionCreated,
actorID,
nil,
); err != nil {
return err
}
return nil
}); err != nil {
s.Log.Errorf("Failed to create uniformity: %+v", err)
return nil, err
}
if s.DocumentSvc != nil {
actorIDCopy := actorID
_, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{
DocumentableType: "UNIFORMITY",
DocumentableID: uint64(createBody.Id),
CreatedBy: &actorIDCopy,
Files: []commonSvc.DocumentFile{
{
File: file,
Type: "UNIFORMITY",
},
},
})
if err != nil {
s.rollbackUniformityCreate(c.Context(), createBody.Id)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to upload uniformity document")
}
}
return s.GetOne(c, createBody.Id)
}
func (s uniformityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint, file *multipart.FileHeader, rows []BodyWeightExcelRow) (*entity.ProjectFlockKandangUniformity, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
updateBody := make(map[string]any)
var uniformDate *time.Time
if req.Date != nil {
parsed, err := time.Parse("2006-01-02", *req.Date)
if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "date must be in YYYY-MM-DD format")
}
updateBody["uniform_date"] = parsed
uniformDate = &parsed
}
if req.ProjectFlockKandangId != nil {
if s.ProjectFlockKandangRepo == nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Project flock kandang repository not available")
}
if err := commonSvc.EnsureRelations(
c.Context(),
commonSvc.RelationCheck{Name: "Project Flock Kandang", ID: req.ProjectFlockKandangId, Exists: s.ProjectFlockKandangRepo.IdExists},
); err != nil {
return nil, err
}
updateBody["project_flock_kandang_id"] = *req.ProjectFlockKandangId
}
if req.Week != nil {
updateBody["week"] = *req.Week
}
if req.Date != nil || req.ProjectFlockKandangId != nil || req.Week != nil {
current, err := s.Repository.GetByID(c.Context(), id, nil)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Uniformity not found")
}
return nil, err
}
targetDate := uniformDate
if targetDate == nil {
targetDate = current.UniformDate
}
targetWeek := current.Week
if req.Week != nil {
targetWeek = *req.Week
}
targetPFKID := current.ProjectFlockKandangId
if req.ProjectFlockKandangId != nil {
targetPFKID = *req.ProjectFlockKandangId
}
if targetDate != nil {
if err := s.ensureUniqueUniformity(c.Context(), id, targetPFKID, targetWeek, targetDate); err != nil {
return nil, err
}
}
}
if file != nil {
if s.DocumentSvc == nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Document service not available")
}
if len(rows) == 0 {
parsedRows, err := s.ParseBodyWeightExcel(c, file)
if err != nil {
return nil, err
}
rows = parsedRows
}
calculation, err := s.ComputeUniformity(rows)
if err != nil {
return nil, err
}
updateBody["uniformity"] = calculation.Uniformity
updateBody["cv"] = calculation.Cv
updateBody["chick_qty_of_weight"] = calculation.ChickQtyOfWeight
updateBody["mean_up"] = calculation.MeanUp
updateBody["mean_down"] = calculation.MeanDown
updateBody["uniform_qty"] = calculation.UniformQty
updateBody["not_uniform_qty"] = calculation.OutsideQty
}
if len(updateBody) == 0 {
return s.GetOne(c, id)
}
if file == nil {
if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Uniformity not found")
}
s.Log.Errorf("Failed to update uniformity: %+v", err)
return nil, err
}
return s.GetOne(c, id)
}
existingDocs, err := s.DocumentSvc.ListByTarget(c.Context(), "UNIFORMITY", uint64(id))
if err != nil {
return nil, err
}
actorID, err := m.ActorIDFromContext(c)
if err != nil {
return nil, err
}
actorIDCopy := actorID
uploadResults, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{
DocumentableType: "UNIFORMITY",
DocumentableID: uint64(id),
CreatedBy: &actorIDCopy,
Files: []commonSvc.DocumentFile{
{
File: file,
Type: "UNIFORMITY",
},
},
})
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to upload uniformity document")
}
if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil {
if len(uploadResults) > 0 {
ids := make([]uint, 0, len(uploadResults))
for _, result := range uploadResults {
if result.Document.Id != 0 {
ids = append(ids, result.Document.Id)
}
}
if len(ids) > 0 {
_ = s.DocumentSvc.DeleteDocuments(c.Context(), ids, true)
}
}
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Uniformity not found")
}
s.Log.Errorf("Failed to update uniformity: %+v", err)
return nil, err
}
if len(existingDocs) > 0 {
oldIDs := make([]uint, 0, len(existingDocs))
for _, doc := range existingDocs {
if doc.Id != 0 {
oldIDs = append(oldIDs, doc.Id)
}
}
if len(oldIDs) > 0 {
_ = s.DocumentSvc.DeleteDocuments(c.Context(), oldIDs, true)
}
}
return s.GetOne(c, id)
}
func (s *uniformityService) ensureUniqueUniformity(ctx context.Context, id uint, projectFlockKandangID uint, week int, uniformDate *time.Time) error {
if projectFlockKandangID == 0 || week == 0 || uniformDate == nil || uniformDate.IsZero() {
return nil
}
query := s.Repository.DB().WithContext(ctx).
Model(&entity.ProjectFlockKandangUniformity{}).
Where("project_flock_kandang_id = ? AND week = ? AND uniform_date = ?", projectFlockKandangID, week, *uniformDate)
if id != 0 {
query = query.Where("id <> ?", id)
}
var count int64
if err := query.Count(&count).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate uniformity uniqueness")
}
if count > 0 {
return fiber.NewError(fiber.StatusConflict, "Uniformity already exists for the same project flock kandang, week, and date")
}
return nil
}
func (s uniformityService) DeleteOne(c *fiber.Ctx, id uint) error {
if err := s.Repository.DeleteOne(c.Context(), id); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "Uniformity not found")
}
s.Log.Errorf("Failed to delete uniformity: %+v", err)
return err
}
return nil
}
func (s uniformityService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlockKandangUniformity, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
actionValue := strings.ToUpper(strings.TrimSpace(req.Action))
var action entity.ApprovalAction
switch actionValue {
case string(entity.ApprovalActionApproved):
action = entity.ApprovalActionApproved
case string(entity.ApprovalActionRejected):
action = entity.ApprovalActionRejected
default:
return nil, fiber.NewError(fiber.StatusBadRequest, "action must be APPROVED or REJECTED")
}
ids := uniqueUintSlice(req.ApprovableIds)
if len(ids) == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "approvable_ids must contain at least one id")
}
step := utils.UniformityStepPengajuan
if action == entity.ApprovalActionApproved {
step = utils.UniformityStepDisetujui
}
actorID, err := m.ActorIDFromContext(c)
if err != nil {
return nil, err
}
ctx := c.Context()
transactionErr := s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
repoTx := s.Repository.WithTx(tx)
approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx))
for _, id := range ids {
if _, err := repoTx.GetByID(ctx, id, nil); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Uniformity %d not found", id))
}
return err
}
if _, err := approvalSvc.CreateApproval(
ctx,
utils.ApprovalWorkflowUniformity,
id,
step,
&action,
actorID,
req.Notes,
); err != nil {
return err
}
}
return nil
})
if transactionErr != nil {
if fiberErr, ok := transactionErr.(*fiber.Error); ok {
return nil, fiberErr
}
s.Log.Errorf("Failed to record approvals for uniformities %+v: %+v", ids, transactionErr)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to submit uniformity approval")
}
results := make([]entity.ProjectFlockKandangUniformity, 0, len(ids))
for _, id := range ids {
loaded, err := s.GetOne(c, id)
if err != nil {
return nil, err
}
results = append(results, *loaded)
}
return results, nil
}
type UniformityDetailItem struct {
Id int
Weight float64
Range string
}
type UniformityCalculation struct {
ChickQtyOfWeight float64
MeanWeight float64
MeanDown float64
MeanUp float64
UniformQty float64
OutsideQty float64
Uniformity float64
Cv float64
Details []UniformityDetailItem
}
type UniformityStandard struct {
MeanWeight *float64
Uniformity *float64
}
func (s uniformityService) ComputeUniformity(rows []BodyWeightExcelRow) (UniformityCalculation, error) {
return computeUniformity(rows)
}
func (s uniformityService) CalculateUniformityFromDocument(c *fiber.Ctx, uniformityID uint) (UniformityCalculation, *entity.Document, error) {
if s.DocumentSvc == nil {
return UniformityCalculation{}, nil, fiber.NewError(fiber.StatusInternalServerError, "Document service not available")
}
documents, err := s.DocumentSvc.ListByTarget(c.Context(), "UNIFORMITY", uint64(uniformityID))
if err != nil {
return UniformityCalculation{}, nil, err
}
if len(documents) == 0 {
return UniformityCalculation{}, nil, fiber.NewError(fiber.StatusNotFound, "Uniformity document not found")
}
document := documents[0]
url := s.DocumentSvc.PublicURL(document)
if url == "" {
return UniformityCalculation{}, nil, fiber.NewError(fiber.StatusBadRequest, "Uniformity document URL not available")
}
req, err := http.NewRequestWithContext(c.Context(), http.MethodGet, url, nil)
if err != nil {
return UniformityCalculation{}, nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return UniformityCalculation{}, nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return UniformityCalculation{}, nil, fiber.NewError(fiber.StatusBadRequest, "Failed to download uniformity document")
}
rows, err := parseBodyWeightExcelReader(resp.Body)
if err != nil {
return UniformityCalculation{}, nil, err
}
calculation, err := computeUniformity(rows)
if err != nil {
return UniformityCalculation{}, nil, err
}
return calculation, &document, nil
}
func (s *uniformityService) createUniformityApproval(
ctx context.Context,
db *gorm.DB,
uniformityID uint,
step approvalutils.ApprovalStep,
action entity.ApprovalAction,
actorID uint,
notes *string,
) error {
if uniformityID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Uniformity tidak valid untuk approval")
}
if actorID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Actor Id tidak valid untuk approval")
}
var svc commonSvc.ApprovalService
if db != nil {
svc = commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(db))
} else if s.ApprovalSvc != nil {
svc = s.ApprovalSvc
} else {
svc = commonSvc.NewApprovalService(s.ApprovalRepo)
}
_, err := svc.CreateApproval(ctx, utils.ApprovalWorkflowUniformity, uniformityID, step, &action, actorID, notes)
return err
}
func (s *uniformityService) attachLatestApprovals(ctx context.Context, items []entity.ProjectFlockKandangUniformity) error {
if len(items) == 0 || s.ApprovalSvc == nil {
return nil
}
ids := make([]uint, 0, len(items))
visited := make(map[uint]struct{}, len(items))
for _, item := range items {
if item.Id == 0 {
continue
}
if _, ok := visited[item.Id]; ok {
continue
}
visited[item.Id] = struct{}{}
ids = append(ids, item.Id)
}
if len(ids) == 0 {
return nil
}
latestMap, err := s.ApprovalSvc.LatestByTargets(ctx, utils.ApprovalWorkflowUniformity, ids, func(db *gorm.DB) *gorm.DB {
return db.Preload("ActionUser")
})
if err != nil {
s.Log.Warnf("Unable to load latest approvals for uniformities: %+v", err)
return nil
}
if len(latestMap) == 0 {
return nil
}
for i := range items {
if items[i].Id == 0 {
continue
}
if approval, ok := latestMap[items[i].Id]; ok {
items[i].LatestApproval = approval
}
}
return nil
}
func (s *uniformityService) attachLatestApproval(ctx context.Context, item *entity.ProjectFlockKandangUniformity) error {
if item == nil || item.Id == 0 || s.ApprovalSvc == nil {
return nil
}
approvals, err := s.ApprovalSvc.ListByTarget(ctx, utils.ApprovalWorkflowUniformity, item.Id, func(db *gorm.DB) *gorm.DB {
return db.Preload("ActionUser")
})
if err != nil {
s.Log.Warnf("Unable to load approvals for uniformity %d: %+v", item.Id, err)
return nil
}
if len(approvals) == 0 {
item.LatestApproval = nil
return nil
}
latest := approvals[len(approvals)-1]
item.LatestApproval = &latest
return nil
}
func (s *uniformityService) resolveUniformityStandard(ctx context.Context, item entity.ProjectFlockKandangUniformity) (*UniformityStandard, error) {
if s.ProductionStandardRepo == nil || s.StandardGrowthDetailRepo == nil {
return nil, nil
}
standard, err := s.resolveCategoryStandard(ctx, item.ProjectFlockKandang.ProjectFlock.Category, nil)
if err != nil || standard == nil {
return nil, err
}
detail, err := s.StandardGrowthDetailRepo.GetByStandardIDAndWeek(ctx, standard.Id, item.Week)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &UniformityStandard{
MeanWeight: cloneFloat64(detail.TargetMeanBw),
Uniformity: float64Ptr(detail.MinUniformity),
}, nil
}
func (s *uniformityService) resolveCategoryStandard(
ctx context.Context,
category string,
cache map[string]*entity.ProductionStandard,
) (*entity.ProductionStandard, error) {
category = strings.TrimSpace(category)
if category == "" {
return nil, nil
}
if cache != nil {
if cached, ok := cache[category]; ok {
return cached, nil
}
}
var standard entity.ProductionStandard
err := s.ProductionStandardRepo.DB().WithContext(ctx).
Where("project_category = ?", category).
Where("deleted_at IS NULL").
Order("created_at DESC").
First(&standard).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
if cache != nil {
cache[category] = nil
}
return nil, nil
}
return nil, err
}
standardCopy := standard
if cache != nil {
cache[category] = &standardCopy
}
return &standardCopy, nil
}
func cloneFloat64(value *float64) *float64 {
if value == nil {
return nil
}
copy := *value
return &copy
}
func float64Ptr(value float64) *float64 {
copy := value
return &copy
}
func (s *uniformityService) rollbackUniformityCreate(ctx context.Context, uniformityID uint) {
if uniformityID == 0 {
return
}
if s.ApprovalRepo != nil {
if err := s.ApprovalRepo.DeleteByTarget(ctx, utils.ApprovalWorkflowUniformity.String(), uniformityID); err != nil {
s.Log.WithError(err).Warnf("Failed to rollback uniformity approvals for %d", uniformityID)
}
}
if err := s.Repository.DeleteOne(ctx, uniformityID); err != nil {
s.Log.WithError(err).Warnf("Failed to rollback uniformity %d", uniformityID)
}
}
func uniqueUintSlice(values []uint) []uint {
if len(values) == 0 {
return nil
}
seen := make(map[uint]struct{}, len(values))
result := make([]uint, 0, len(values))
for _, v := range values {
if v == 0 {
continue
}
if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
result = append(result, v)
}
return result
}
func computeUniformity(rows []BodyWeightExcelRow) (UniformityCalculation, error) {
weights := make([]float64, 0, len(rows))
details := make([]UniformityDetailItem, 0, len(rows))
hasRangeLabels := false
for idx, row := range rows {
if row.Weight <= 0 {
continue
}
id := row.No
if id <= 0 {
id = idx + 1
}
weights = append(weights, row.Weight)
rangeLabel := strings.TrimSpace(row.Range)
if rangeLabel != "" {
upper := strings.ToUpper(rangeLabel)
if upper == "HIGH" || upper == "LOW" {
hasRangeLabels = true
}
rangeLabel = upper
}
details = append(details, UniformityDetailItem{
Id: id,
Weight: row.Weight,
Range: rangeLabel,
})
}
total := float64(len(weights))
if total == 0 {
return UniformityCalculation{}, fiber.NewError(fiber.StatusBadRequest, "no body weight data found")
}
var sum float64
for _, w := range weights {
sum += w
}
mean := sum / total
meanUpThreshold := roundToPrecision(mean*1.10, 3)
meanDownThreshold := roundToPrecision(mean*0.90, 3)
var uniformCount float64
for i := range details {
if hasRangeLabels {
if details[i].Range == "HIGH" || details[i].Range == "LOW" {
details[i].Range = "Outside"
continue
}
details[i].Range = "Ideal"
uniformCount++
continue
}
w := details[i].Weight
if w > meanUpThreshold || w < meanDownThreshold {
details[i].Range = "Outside"
continue
}
details[i].Range = "Ideal"
uniformCount++
}
outsideCount := total - uniformCount
var cv float64
if mean > 0 && total > 1 {
stddevWeights := weights
stddevCount := float64(len(stddevWeights))
if stddevCount > 1 {
var stddevSum float64
for _, w := range stddevWeights {
stddevSum += w
}
stddevMean := stddevSum / stddevCount
var sumSquares float64
for _, w := range stddevWeights {
diff := w - stddevMean
sumSquares += diff * diff
}
stddev := math.Sqrt(sumSquares / (stddevCount - 1))
cv = (stddev / mean) * 100
}
}
uniformity := (uniformCount / total) * 100
return UniformityCalculation{
ChickQtyOfWeight: total,
MeanWeight: roundToPrecision(mean, 0),
MeanDown: roundToPrecision(mean*0.90, 0),
MeanUp: roundToPrecision(mean*1.10, 0),
UniformQty: uniformCount,
OutsideQty: outsideCount,
Uniformity: roundToPrecision(uniformity, 0),
Cv: roundToPrecision(cv, 1),
Details: details,
}, nil
}
func roundToPrecision(value float64, precision int) float64 {
if precision < 0 {
return value
}
scale := math.Pow10(precision)
scaled := value * scale
fraction := scaled - math.Floor(scaled)
if fraction >= 0.5 {
return math.Ceil(scaled) / scale
}
return math.Floor(scaled) / scale
}
@@ -0,0 +1,164 @@
package validation
import (
"mime/multipart"
"strconv"
"strings"
"github.com/gofiber/fiber/v2"
)
type Create struct {
Date string `form:"date" validate:"required"`
ProjectFlockKandangId uint `form:"project_flock_kandang_id" validate:"required,number,min=1"`
Week int `form:"week" validate:"required,min=1"`
}
type Update struct {
Date *string `json:"date,omitempty" form:"date" validate:"omitempty"`
ProjectFlockKandangId *uint `json:"project_flock_kandang_id,omitempty" form:"project_flock_kandang_id" validate:"omitempty,number,min=1"`
Week *int `json:"week,omitempty" form:"week" validate:"omitempty,min=1"`
}
type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"`
Week int `query:"week" validate:"omitempty,min=1"`
}
type UploadExcelRequest struct {
Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"`
}
type Approve struct {
Action string `json:"action" validate:"required_strict"`
ApprovableIds []uint `json:"approvable_ids" validate:"required_strict,min=1,dive,gt=0"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
}
func ParseIDParam(c *fiber.Ctx, name string) (uint, error) {
raw := strings.TrimSpace(c.Params(name))
if raw == "" {
return 0, fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
id, err := strconv.Atoi(raw)
if err != nil || id <= 0 {
return 0, fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
return uint(id), nil
}
func ParseQuery(c *fiber.Ctx) (*Query, error) {
query := &Query{
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
ProjectFlockKandangId: uint(c.QueryInt("project_flock_kandang_id", 0)),
Week: c.QueryInt("week", 0),
}
if query.Page < 1 || query.Limit < 1 {
return nil, fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
}
return query, nil
}
func ParseCreate(c *fiber.Ctx) (*Create, *multipart.FileHeader, error) {
date := strings.TrimSpace(c.FormValue("date"))
if date == "" {
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "date is required")
}
projectFlockKandangIDStr := strings.TrimSpace(c.FormValue("project_flock_kandang_id"))
if projectFlockKandangIDStr == "" {
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required")
}
projectFlockKandangID, err := strconv.Atoi(projectFlockKandangIDStr)
if err != nil || projectFlockKandangID <= 0 {
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required")
}
weekStr := strings.TrimSpace(c.FormValue("week"))
if weekStr == "" {
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "week is required")
}
week, err := strconv.Atoi(weekStr)
if err != nil || week <= 0 {
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "week is required")
}
file, err := c.FormFile("document")
if err != nil {
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "document is required")
}
return &Create{
Date: date,
ProjectFlockKandangId: uint(projectFlockKandangID),
Week: week,
}, file, nil
}
func ParseUpdate(c *fiber.Ctx) (*Update, *multipart.FileHeader, error) {
contentType := strings.ToLower(c.Get("Content-Type"))
if strings.Contains(contentType, "multipart/form-data") {
req := &Update{}
date := strings.TrimSpace(c.FormValue("date"))
if date != "" {
req.Date = &date
}
projectFlockKandangIDStr := strings.TrimSpace(c.FormValue("project_flock_kandang_id"))
if projectFlockKandangIDStr != "" {
projectFlockKandangID, err := strconv.Atoi(projectFlockKandangIDStr)
if err != nil || projectFlockKandangID <= 0 {
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is invalid")
}
idCopy := uint(projectFlockKandangID)
req.ProjectFlockKandangId = &idCopy
}
weekStr := strings.TrimSpace(c.FormValue("week"))
if weekStr != "" {
week, err := strconv.Atoi(weekStr)
if err != nil || week <= 0 {
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "week is invalid")
}
req.Week = &week
}
file, err := c.FormFile("document")
if err != nil {
file = nil
}
return req, file, nil
}
req := new(Update)
if err := c.BodyParser(req); err != nil {
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
return req, nil, nil
}
func ParseUploadFiles(c *fiber.Ctx) ([]*multipart.FileHeader, error) {
file, err := c.FormFile("document")
if err != nil || file == nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "document is required")
}
return []*multipart.FileHeader{file}, nil
}
func ParseApprove(c *fiber.Ctx) (*Approve, error) {
req := new(Approve)
if err := c.BodyParser(req); err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
return req, nil
}
@@ -1,6 +1,7 @@
package controller
import (
"encoding/json"
"fmt"
"math"
"strconv"
@@ -24,13 +25,13 @@ func NewPurchaseController(s service.PurchaseService) *PurchaseController {
func (ctrl *PurchaseController) GetAll(c *fiber.Ctx) error {
query := &validation.Query{
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
CreatedFrom: strings.TrimSpace(c.Query("created_from")),
CreatedTo: strings.TrimSpace(c.Query("created_to")),
SupplierID: uint(c.QueryInt("supplier_id", 0)),
AreaID: uint(c.QueryInt("area_id", 0)),
LocationID: uint(c.QueryInt("location_id", 0)),
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
CreatedFrom: strings.TrimSpace(c.Query("created_from")),
CreatedTo: strings.TrimSpace(c.Query("created_to")),
SupplierID: uint(c.QueryInt("supplier_id", 0)),
AreaID: uint(c.QueryInt("area_id", 0)),
LocationID: uint(c.QueryInt("location_id", 0)),
ProductCategoryID: uint(c.QueryInt("product_category_id", 0)),
}
@@ -86,7 +87,6 @@ func (ctrl *PurchaseController) CreateOne(c *fiber.Ctx) error {
if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
result, err := ctrl.service.CreateOne(c, req)
if err != nil {
return err
@@ -161,10 +161,26 @@ func (ctrl *PurchaseController) ReceiveProducts(c *fiber.Ctx) error {
}
req := new(validation.ReceivePurchaseRequest)
if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
form, err := c.MultipartForm()
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid multipart form")
}
req.Action = c.FormValue("action")
if notes := strings.TrimSpace(c.FormValue("notes")); notes != "" {
req.Notes = &notes
}
itemsJSON := c.FormValue("items")
if strings.TrimSpace(itemsJSON) != "" {
if err := json.Unmarshal([]byte(itemsJSON), &req.Items); err != nil {
var singleItem validation.ReceivePurchaseItemRequest
if err := json.Unmarshal([]byte(itemsJSON), &singleItem); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid items JSON")
}
req.Items = []validation.ReceivePurchaseItemRequest{singleItem}
}
}
req.TravelDocuments = form.File["documents"]
result, err := ctrl.service.ReceiveProducts(c, uint(id), req)
if err != nil {
return err
+1
View File
@@ -98,6 +98,7 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
approvalService,
expenseBridge,
fifoService,
documentSvc,
)
userRepo := rUser.NewUserRepository(db)
@@ -310,9 +310,6 @@ func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates [
return err
}
if cnt == 1 {
if item.Warehouse == nil || item.Warehouse.KandangId == nil || *item.Warehouse.KandangId == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Warehouse not connect to kandangs")
}
newNonstockID, err := b.findExpeditionNonstockID(ctx, supplierID)
if err != nil {
return err
@@ -332,7 +329,9 @@ func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates [
"price": pricePerItem,
"notes": note,
"nonstock_id": newNonstockID,
"kandang_id": uint64(*item.Warehouse.KandangId),
}
if item.Warehouse != nil && item.Warehouse.KandangId != nil && *item.Warehouse.KandangId != 0 {
updateBody["kandang_id"] = uint64(*item.Warehouse.KandangId)
}
if err := b.db.WithContext(ctx).
Model(&entity.ExpenseNonstock{}).
@@ -395,9 +394,13 @@ func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates [
}
if kandangID != nil {
updateBody["kandang_id"] = uint64(*kandangID)
} else {
updateBody["kandang_id"] = nil
}
if projectFK != nil {
updateBody["project_flock_kandang_id"] = uint64(*projectFK)
} else {
updateBody["project_flock_kandang_id"] = nil
}
if err := b.db.WithContext(ctx).
@@ -550,18 +553,27 @@ func (b *expenseBridge) createExpenseViaService(
}
kandangID := items[0].kandangID
if kandangID == nil || *kandangID == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Warehouse not connect to kandangs")
}
kandang, err := b.kandangRepo.GetByID(ctx, *kandangID, func(db *gorm.DB) *gorm.DB {
return db.Select("id, location_id")
})
if err != nil {
return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Kandang not found: %d", *kandangID))
}
if kandang == nil {
return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Kandang not found: %d", *kandangID))
var locationID uint64
var expenseKandangID *uint64
if kandangID != nil && *kandangID != 0 {
kandang, err := b.kandangRepo.GetByID(ctx, *kandangID, func(db *gorm.DB) *gorm.DB {
return db.Select("id, location_id")
})
if err != nil {
return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Kandang not found: %d", *kandangID))
}
if kandang == nil {
return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Kandang not found: %d", *kandangID))
}
locationID = uint64(kandang.LocationId)
id := uint64(*kandangID)
expenseKandangID = &id
} else {
warehouse := items[0].item.Warehouse
if warehouse == nil || warehouse.LocationId == nil || *warehouse.LocationId == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Warehouse location is required for expense")
}
locationID = uint64(*warehouse.LocationId)
}
costItems := make([]expenseValidation.CostItem, 0, len(items))
@@ -584,9 +596,9 @@ func (b *expenseBridge) createExpenseViaService(
TransactionDate: utils.FormatDate(expenseDate),
Category: "BOP",
SupplierID: uint64(supplierID),
LocationID: uint64(kandang.LocationId),
LocationID: locationID,
ExpenseNonstocks: []expenseValidation.ExpenseNonstock{{
KandangID: func() *uint64 { id := uint64(*kandangID); return &id }(),
KandangID: expenseKandangID,
CostItems: costItems,
}},
}
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"math"
"mime/multipart"
"strings"
"time"
@@ -57,6 +58,7 @@ type purchaseService struct {
ApprovalSvc commonSvc.ApprovalService
ExpenseBridge PurchaseExpenseBridge
FifoSvc commonSvc.FifoService
DocumentSvc commonSvc.DocumentService
approvalWorkflow approvalutils.ApprovalWorkflowKey
}
@@ -76,6 +78,7 @@ func NewPurchaseService(
approvalSvc commonSvc.ApprovalService,
expenseBridge PurchaseExpenseBridge,
fifoSvc commonSvc.FifoService,
documentSvc commonSvc.DocumentService,
) PurchaseService {
return &purchaseService{
Log: utils.Log,
@@ -89,6 +92,7 @@ func NewPurchaseService(
ApprovalSvc: approvalSvc,
ExpenseBridge: expenseBridge,
FifoSvc: fifoSvc,
DocumentSvc: documentSvc,
approvalWorkflow: utils.ApprovalWorkflowPurchase,
}
}
@@ -246,22 +250,25 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase
s.Log.Errorf("Failed to get warehouse %d: %+v", id, err)
return nil, nil, utils.Internal("Failed to get warehouse")
}
if warehouse.KandangId == nil || *warehouse.KandangId == 0 {
return nil, nil, utils.BadRequest(fmt.Sprintf("%s is not linked to a kandang", warehouse.Name))
}
var pfkID *uint
if s.ProjectFlockKandangRepo != nil {
if pfk, err := s.ProjectFlockKandangRepo.GetActiveByKandangID(c.Context(), uint(*warehouse.KandangId)); err == nil && pfk != nil {
if pfk.ClosedAt != nil {
return nil, nil, utils.BadRequest("Project sudah closing")
isKandang := strings.EqualFold(strings.TrimSpace(warehouse.Type), "KANDANG")
if isKandang {
if warehouse.KandangId == nil || *warehouse.KandangId == 0 {
return nil, nil, utils.BadRequest(fmt.Sprintf("%s is not linked to a kandang", warehouse.Name))
}
if s.ProjectFlockKandangRepo != nil {
if pfk, err := s.ProjectFlockKandangRepo.GetActiveByKandangID(c.Context(), uint(*warehouse.KandangId)); err == nil && pfk != nil {
if pfk.ClosedAt != nil {
return nil, nil, utils.BadRequest("Project sudah closing")
}
idCopy := uint(pfk.Id)
pfkID = &idCopy
} else if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, utils.BadRequest(fmt.Sprintf("%s has no active project flock", warehouse.Name))
} else if err != nil {
s.Log.Errorf("Failed to validate project flock for warehouse %d: %+v", id, err)
return nil, nil, utils.Internal("Failed to validate project flock")
}
idCopy := uint(pfk.Id)
pfkID = &idCopy
} else if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, utils.BadRequest(fmt.Sprintf("%s has no active project flock", warehouse.Name))
} else if err != nil {
s.Log.Errorf("Failed to validate project flock for warehouse %d: %+v", id, err)
return nil, nil, utils.Internal("Failed to validate project flock")
}
}
@@ -612,9 +619,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
ctx := c.Context()
action, err := parseApprovalActionInput(req.Action)
if err != nil {
return nil, err
@@ -661,6 +666,30 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
return updated, nil
}
if action == entity.ApprovalActionApproved && len(req.TravelDocuments) > 0 {
if len(req.TravelDocuments) > len(req.Items) {
return nil, utils.BadRequest("Travel documents exceed total receiving items")
}
for idx, file := range req.TravelDocuments {
if file == nil {
continue
}
if idx >= len(req.Items) {
break
}
itemID := req.Items[idx].PurchaseItemID
if itemID == 0 {
return nil, utils.BadRequest("Purchase item id is required for travel document upload")
}
uploadedURL, err := s.uploadTravelDocument(ctx, actorID, itemID, file)
if err != nil {
s.Log.Errorf("Failed to upload travel document for item %d: %+v", itemID, err)
return nil, utils.Internal("Failed to upload travel document")
}
req.Items[idx].TravelDocumentPath = &uploadedURL
}
}
itemMap := make(map[uint]*entity.PurchaseItem, len(purchase.Items))
for i := range purchase.Items {
itemMap[purchase.Items[i].Id] = &purchase.Items[i]
@@ -804,32 +833,20 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
for _, prep := range prepared {
item := prep.item
var oldPWID *uint
if item.ProductWarehouseId != nil {
idCopy := uint(*item.ProductWarehouseId)
oldPWID = &idCopy
}
var newPWID *uint
clearPW := false
// Always ensure PW when qty > 0 so stockable has target.
if prep.receivedQty > 0 {
pwID, err := pwRepoTx.EnsureProductWarehouse(
c.Context(),
uint(item.ProductId),
prep.warehouseID,
item.ProjectFlockKandangId,
purchase.CreatedBy,
)
if err != nil {
return err
}
newPWID = &pwID
} else if oldPWID != nil {
newPWID = oldPWID
clearPW = true
// Always ensure PW after receiving so linkage stays stable.
pwID, err := pwRepoTx.EnsureProductWarehouse(
c.Context(),
uint(item.ProductId),
prep.warehouseID,
item.ProjectFlockKandangId,
purchase.CreatedBy,
)
if err != nil {
return err
}
newPWID = &pwID
deltaQty := prep.receivedQty - item.TotalQty
switch {
@@ -854,7 +871,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
VehicleNumber: prep.payload.VehicleNumber,
ReceivedQty: &qtyCopy,
ProductWarehouseID: newPWID,
ClearProductWarehouse: clearPW,
ClearProductWarehouse: false,
}
if prep.overrideWarehouse || uint(item.WarehouseId) != prep.warehouseID {
@@ -969,6 +986,38 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
return updated, nil
}
func (s *purchaseService) uploadTravelDocument(
ctx context.Context,
actorID uint,
itemID uint,
file *multipart.FileHeader,
) (string, error) {
if file == nil {
return "", errors.New("travel document file is required")
}
if s.DocumentSvc == nil {
return "", errors.New("document service not available")
}
documentFiles := []commonSvc.DocumentFile{{
File: file,
Type: string(utils.DocumentTypePurchaseTravel),
}}
results, err := s.DocumentSvc.UploadDocuments(ctx, commonSvc.DocumentUploadRequest{
DocumentableType: string(utils.DocumentableTypePurchaseItem),
DocumentableID: uint64(itemID),
CreatedBy: &actorID,
Files: documentFiles,
})
if err != nil {
return "", err
}
if len(results) == 0 {
return "", errors.New("upload result is empty")
}
return results[0].URL, nil
}
func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint, req *validation.DeletePurchaseItemsRequest) (*entity.Purchase, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
@@ -1048,6 +1097,10 @@ func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint, req *validation.Del
return nil, utils.Internal("Failed to delete purchase items")
}
if err := s.deletePurchaseItemDocuments(ctx, itemsToDelete); err != nil {
return nil, utils.Internal("Failed to delete purchase documents")
}
if len(itemsToDelete) > 0 {
if err := s.notifyExpenseItemsDeleted(ctx, purchase.Id, itemsToDelete); err != nil {
s.Log.Errorf("Failed to sync expense deletion for purchase %d: %+v", purchase.Id, err)
@@ -1107,6 +1160,10 @@ func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint) error {
return utils.Internal("Failed to delete purchase")
}
if err := s.deletePurchaseItemDocuments(ctx, itemsToDelete); err != nil {
return utils.Internal("Failed to delete purchase documents")
}
if len(itemsToDelete) > 0 {
if err := s.notifyExpenseItemsDeleted(ctx, uint(id), itemsToDelete); err != nil {
s.Log.Errorf("Failed to sync expense deletion for purchase %d: %+v", id, err)
@@ -1190,6 +1247,21 @@ func (s *purchaseService) notifyExpenseItemsDeleted(ctx context.Context, purchas
}
func (s *purchaseService) deletePurchaseItemDocuments(ctx context.Context, items []entity.PurchaseItem) error {
if s.DocumentSvc == nil || len(items) == 0 {
return nil
}
for _, item := range items {
if item.Id == 0 {
continue
}
if err := s.DocumentSvc.DeleteByTarget(ctx, string(utils.DocumentableTypePurchaseItem), uint64(item.Id), true); err != nil {
return err
}
}
return nil
}
func (s *purchaseService) buildStaffAdjustmentPayload(
ctx context.Context,
purchase *entity.Purchase,
@@ -1462,5 +1534,5 @@ func (s *purchaseService) ensureProjectFlockNotClosedForPurchase(
return utils.Internal("DB not available for project flock validation")
}
return commonSvc.EnsureProjectFlockNotClosedForProductWarehouses(ctx, db, pfkIDs)
return commonSvc.EnsureProjectFlockNotClosedByProjectFlockKandangID(ctx, db, pfkIDs)
}
@@ -1,5 +1,7 @@
package validation
import "mime/multipart"
type PurchaseItemPayload struct {
WarehouseID uint `json:"warehouse_id" validate:"required,gt=0"`
ProductID uint `json:"product_id" validate:"required,gt=0"`
@@ -26,7 +28,7 @@ type StaffPurchaseApprovalItem struct {
type ApproveStaffPurchaseRequest struct {
Action string `json:"action" validate:"required,oneof=APPROVED REJECTED"`
Items []StaffPurchaseApprovalItem `json:"items,omitempty" validate:"omitempty,min=1,dive"`
Items []StaffPurchaseApprovalItem `json:"items" validate:"omitempty,min=1,dive"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
}
@@ -36,21 +38,22 @@ type ApproveManagerPurchaseRequest struct {
}
type ReceivePurchaseItemRequest struct {
PurchaseItemID uint `json:"purchase_item_id" validate:"required,gt=0"`
WarehouseID *uint `json:"warehouse_id" validate:"omitempty,gt=0"`
ReceivedDate string `json:"received_date" validate:"required,datetime=2006-01-02"`
ExpeditionVendorID *uint `json:"expedition_vendor_id,omitempty" validate:"omitempty,gt=0"`
TransportPerItem *float64 `json:"transport_per_item,omitempty" validate:"omitempty,gte=0"`
TravelNumber *string `json:"travel_number" validate:"omitempty,max=100"`
TravelDocumentPath *string `json:"travel_document_path" validate:"omitempty,max=255"`
VehicleNumber *string `json:"vehicle_number" validate:"omitempty,max=100"`
ReceivedQty *float64 `json:"received_qty" validate:"omitempty,gte=0"`
PurchaseItemID uint `form:"purchase_item_id" json:"purchase_item_id" validate:"required,gt=0"`
WarehouseID *uint `form:"warehouse_id" json:"warehouse_id" validate:"omitempty,gt=0"`
ReceivedDate string `form:"received_date" json:"received_date" validate:"required,datetime=2006-01-02"`
ExpeditionVendorID *uint `form:"expedition_vendor_id" json:"expedition_vendor_id,omitempty" validate:"omitempty,gt=0"`
TransportPerItem *float64 `form:"transport_per_item" json:"transport_per_item,omitempty" validate:"omitempty,gte=0"`
TravelNumber *string `form:"travel_number" json:"travel_number" validate:"omitempty,max=100"`
TravelDocumentPath *string `form:"travel_document_path" json:"travel_document_path" validate:"omitempty,max=1024"`
VehicleNumber *string `form:"vehicle_number" json:"vehicle_number" validate:"omitempty,max=100"`
ReceivedQty *float64 `form:"received_qty" json:"received_qty" validate:"omitempty,gte=0"`
}
type ReceivePurchaseRequest struct {
Action string `json:"action" validate:"required,oneof=APPROVED REJECTED"`
Items []ReceivePurchaseItemRequest `json:"items,omitempty" validate:"omitempty,min=1,dive"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
Action string `form:"action" json:"action" validate:"required,oneof=APPROVED REJECTED"`
Items []ReceivePurchaseItemRequest `form:"items" json:"items" validate:"min=1,dive"`
TravelDocuments []*multipart.FileHeader `form:"travel_documents" json:"-" validate:"omitempty,dive"`
Notes *string `form:"notes" json:"notes,omitempty" validate:"omitempty,max=500"`
}
type DeletePurchaseItemsRequest struct {
+1 -1
View File
@@ -18,6 +18,6 @@ func RepportRoutes(v1 fiber.Router, u user.UserService, s repport.RepportService
route.Get("/expense", m.RequirePermissions(m.P_ReportExpenseGetAll), ctrl.GetExpense)
route.Get("/marketing", m.RequirePermissions(m.P_ReportDeliveryGetAll), ctrl.GetMarketing)
route.Get("/purchase-supplier", m.RequirePermissions(m.P_ReportPurchaseSupplierGetAll), ctrl.GetPurchaseSupplier)
route.Get("/hpp-per-kandang", ctrl.GetHppPerKandang)
route.Get("/hpp-per-kandang", m.RequirePermissions(m.P_ReportHppPerKandangGetAll),ctrl.GetHppPerKandang)
}