Merge branch 'development' into 'dev/gio'

# Conflicts:
#   internal/modules/repports/route.go
This commit is contained in:
Hafizh A. Y.
2026-01-06 10:09:45 +00:00
98 changed files with 4892 additions and 544 deletions
@@ -134,7 +134,14 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
report = &SapronakReportDTO{}
}
filter := strings.ToUpper(strings.TrimSpace(flag))
normalizeFlag := func(raw string) string {
normalized := strings.ToUpper(strings.TrimSpace(raw))
if normalized == "PULLET" {
return "DOC"
}
return normalized
}
filter := normalizeFlag(flag)
byFlag := map[string]**SapronakCategoryDTO{}
if filter == "" || filter == "DOC" {
@@ -149,10 +156,6 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
result.Pakan = &SapronakCategoryDTO{Rows: make([]SapronakCategoryRowDTO, 0)}
byFlag["PAKAN"] = &result.Pakan
}
if filter == "" || filter == "PULLET" {
result.Pullet = &SapronakCategoryDTO{Rows: make([]SapronakCategoryRowDTO, 0)}
byFlag["PULLET"] = &result.Pullet
}
formatDate := func(t *time.Time) string {
if t == nil {
@@ -162,7 +165,7 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
}
for _, group := range report.Groups {
flagKey := strings.ToUpper(group.Flag)
flagKey := normalizeFlag(group.Flag)
ptr := byFlag[flagKey]
if ptr == nil || *ptr == nil {
continue
@@ -182,7 +185,7 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
}
for idx, item := range group.Items {
productKey := strings.ToUpper(group.Flag + "|" + item.ProductName)
productKey := strings.ToUpper(flagKey + "|" + item.ProductName)
baseRow := SapronakCategoryRowDTO{
ID: idx + 1,
Date: formatDate(item.Tanggal),
@@ -246,7 +249,5 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
buildTotals(result.Doc, "TOTAL DOC")
buildTotals(result.Ovk, "TOTAL OVK")
buildTotals(result.Pakan, "TOTAL PAKAN")
buildTotals(result.Pullet, "TOTAL PULLET")
return result
}
@@ -407,7 +407,7 @@ SELECT
COALESCE(fw.name, '') AS source_warehouse,
COALESCE(tw.name, '') AS destination_warehouse,
'' AS destination,
std.quantity AS quantity,
std.usage_qty AS quantity,
u.name AS unit,
'Stock Refill' AS notes
FROM stock_transfer_details std
@@ -456,7 +456,7 @@ SELECT
COALESCE(fw.name, '') AS source_warehouse,
'' AS destination_warehouse,
COALESCE(tw.name, '') AS destination,
std.quantity AS quantity,
std.usage_qty AS quantity,
u.name AS unit,
'Transfer to other unit' AS notes
FROM stock_transfer_details std
@@ -927,34 +927,34 @@ func (r *ClosingRepositoryImpl) GetActualUsageCostByProjectFlockID(ctx context.C
COALESCE(SUM(
CASE
WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0)
WHEN sa.stockable_type = ? THEN COALESCE(std.quantity, 0)
WHEN sa.stockable_type = ? THEN COALESCE(std.usage_qty, 0)
ELSE 0
END
), 0) AS total_qty,
COALESCE(SUM(
CASE
WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0)
WHEN sa.stockable_type = ? THEN COALESCE(std.quantity, 0) * COALESCE(tpi.price, 0)
WHEN sa.stockable_type = ? THEN COALESCE(std.usage_qty, 0) * COALESCE(tpi.price, 0)
ELSE 0
END
), 0) AS total_price,
COALESCE(SUM(
CASE
WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0)
WHEN sa.stockable_type = ? THEN COALESCE(std.quantity, 0)
WHEN sa.stockable_type = ? THEN COALESCE(std.usage_qty, 0)
ELSE 0
END
), 0) AS qty_divisor,
COALESCE(SUM(
CASE
WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0)
WHEN sa.stockable_type = ? THEN COALESCE(std.quantity, 0) * COALESCE(tpi.price, 0)
WHEN sa.stockable_type = ? THEN COALESCE(std.usage_qty, 0) * COALESCE(tpi.price, 0)
ELSE 0
END
), 0) / NULLIF(COALESCE(SUM(
CASE
WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0)
WHEN sa.stockable_type = ? THEN COALESCE(std.quantity, 0)
WHEN sa.stockable_type = ? THEN COALESCE(std.usage_qty, 0)
ELSE 0
END
), 0), 0) AS average_price`,
+1 -1
View File
@@ -30,6 +30,6 @@ func ClosingRoutes(v1 fiber.Router, u user.UserService, s closing.ClosingService
route.Get("/:projectFlockId/sapronak", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingSapronak)
route.Get("/:project_flock_id/expedition-hpp", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetExpeditionHPP)
route.Get("/:project_flock_id/:project_flock_kandang_id/expedition-hpp", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetExpeditionHPPByKandang)
route.Get("/:projectFlockId/data-produksi", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingDataProduksi)
route.Get("/:projectFlockId/production-data", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingDataProduksi)
route.Get("/:projectFlockId/keuangan", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingKeuangan)
}
@@ -359,7 +359,11 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
if filterFlag == "" {
return true
}
return strings.ToUpper(f) == filterFlag
candidate := strings.ToUpper(f)
if filterFlag == "DOC" || filterFlag == "PULLET" {
return candidate == "DOC" || candidate == "PULLET"
}
return candidate == filterFlag
}
// For project flocks with category GROWING, pullet usage from chickin
@@ -0,0 +1,243 @@
package controller
import (
"math"
"strconv"
"gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/validations"
"gitlab.com/mbugroup/lti-api.git/internal/response"
"github.com/gofiber/fiber/v2"
)
type DailyChecklistController struct {
DailyChecklistService service.DailyChecklistService
}
func NewDailyChecklistController(dailyChecklistService service.DailyChecklistService) *DailyChecklistController {
return &DailyChecklistController{
DailyChecklistService: dailyChecklistService,
}
}
func (u *DailyChecklistController) GetAll(c *fiber.Ctx) error {
query := &validation.Query{
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""),
}
if query.Page < 1 || query.Limit < 1 {
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
}
result, totalResults, err := u.DailyChecklistService.GetAll(c, query)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.SuccessWithPaginate[dto.DailyChecklistListDTO]{
Code: fiber.StatusOK,
Status: "success",
Message: "Get all dailyChecklists successfully",
Meta: response.Meta{
Page: query.Page,
Limit: query.Limit,
TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))),
TotalResults: totalResults,
},
Data: dto.ToDailyChecklistListDTOs(result),
})
}
func (u *DailyChecklistController) GetOne(c *fiber.Ctx) error {
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
result, err := u.DailyChecklistService.GetOne(c, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Get dailyChecklist successfully",
Data: dto.ToDailyChecklistListDTO(*result),
})
}
func (u *DailyChecklistController) CreateOne(c *fiber.Ctx) error {
req := new(validation.Create)
if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
result, err := u.DailyChecklistService.CreateOne(c, req)
if err != nil {
return err
}
return c.Status(fiber.StatusCreated).
JSON(response.Success{
Code: fiber.StatusCreated,
Status: "success",
Message: "Create dailyChecklist successfully",
Data: dto.ToDailyChecklistListDTO(*result),
})
}
func (u *DailyChecklistController) UpdateOne(c *fiber.Ctx) error {
req := new(validation.Update)
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
result, err := u.DailyChecklistService.UpdateOne(c, req, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Update dailyChecklist successfully",
Data: dto.ToDailyChecklistListDTO(*result),
})
}
func (u *DailyChecklistController) DeleteOne(c *fiber.Ctx) error {
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
if err := u.DailyChecklistService.DeleteOne(c, uint(id)); err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Common{
Code: fiber.StatusOK,
Status: "success",
Message: "Delete dailyChecklist successfully",
})
}
func (u *DailyChecklistController) CreateDailyChecklistPhase(c *fiber.Ctx) error {
param := c.Params("idDailyChecklist")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid daily checklist id")
}
req := new(validation.AssignPhases)
if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
if err := u.DailyChecklistService.AssignPhases(c, uint(id), req); err != nil {
return err
}
return c.Status(fiber.StatusCreated).
JSON(response.Success{
Code: fiber.StatusCreated,
Status: "success",
Message: "Daily checklist phases saved successfully",
})
}
func (u *DailyChecklistController) CreateAssignment(c *fiber.Ctx) error {
param := c.Params("idDailyChecklist")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid daily checklist id")
}
req := new(validation.AssignTask)
if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
if err := u.DailyChecklistService.AssignTasks(c, uint(id), req); err != nil {
return err
}
return c.Status(fiber.StatusCreated).
JSON(response.Success{
Code: fiber.StatusCreated,
Status: "success",
Message: "Daily checklist assignments saved successfully",
})
}
func (u *DailyChecklistController) RemoveAssignment(c *fiber.Ctx) error {
dailyChecklistParam := c.Params("idDailyChecklist")
employeeParam := c.Params("idEmployee")
dailyChecklistID, err := strconv.Atoi(dailyChecklistParam)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid daily checklist id")
}
employeeID, err := strconv.Atoi(employeeParam)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid employee id")
}
if err := u.DailyChecklistService.RemoveAssignment(c, uint(dailyChecklistID), uint(employeeID)); err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Common{
Code: fiber.StatusOK,
Status: "success",
Message: "Assignment removed successfully",
})
}
func (u *DailyChecklistController) GetAllTasks(c *fiber.Ctx) error {
checklistParam := c.Query("checklist_id", "")
if checklistParam == "" {
return fiber.NewError(fiber.StatusBadRequest, "checklist_id is required")
}
checklistID, err := strconv.Atoi(checklistParam)
if err != nil || checklistID <= 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid checklist_id")
}
result, err := u.DailyChecklistService.GetTasks(c, uint(checklistID))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Get daily checklist tasks successfully",
Data: result,
})
}
@@ -0,0 +1,76 @@
package dto
import (
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
)
// === DTO Structs ===
type DailyChecklistRelationDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
}
type DailyChecklistListDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
CreatedUser *userDTO.UserRelationDTO `json:"created_user"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type DailyChecklistDetailDTO struct {
DailyChecklistListDTO
}
// === Mapper Functions ===
func ToDailyChecklistRelationDTO(e entity.DailyChecklist) DailyChecklistRelationDTO {
var name string
if e.Name != nil {
name = *e.Name
}
return DailyChecklistRelationDTO{
Id: e.Id,
Name: name,
}
}
func ToDailyChecklistListDTO(e entity.DailyChecklist) DailyChecklistListDTO {
var createdUser *userDTO.UserRelationDTO
// if e.CreatedUser.Id != 0 {
// mapped := userDTO.ToUserRelationDTO(e.CreatedUser)
// createdUser = &mapped
// }
var name string
if e.Name != nil {
name = *e.Name
}
return DailyChecklistListDTO{
Id: e.Id,
Name: name,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
CreatedUser: createdUser,
}
}
func ToDailyChecklistListDTOs(e []entity.DailyChecklist) []DailyChecklistListDTO {
result := make([]DailyChecklistListDTO, len(e))
for i, r := range e {
result[i] = ToDailyChecklistListDTO(r)
}
return result
}
func ToDailyChecklistDetailDTO(e entity.DailyChecklist) DailyChecklistDetailDTO {
return DailyChecklistDetailDTO{
DailyChecklistListDTO: ToDailyChecklistListDTO(e),
}
}
@@ -0,0 +1,27 @@
package dailyChecklists
import (
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
rDailyChecklist "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/repositories"
sDailyChecklist "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/services"
rPhases "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/repositories"
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
)
type DailyChecklistModule struct{}
func (DailyChecklistModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
dailyChecklistRepo := rDailyChecklist.NewDailyChecklistRepository(db)
phasesRepo := rPhases.NewPhasesRepository(db)
userRepo := rUser.NewUserRepository(db)
dailyChecklistService := sDailyChecklist.NewDailyChecklistService(dailyChecklistRepo, phasesRepo, validate)
userService := sUser.NewUserService(userRepo, validate)
DailyChecklistRoutes(router, userService, dailyChecklistService)
}
@@ -0,0 +1,21 @@
package repository
import (
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
"gorm.io/gorm"
)
type DailyChecklistRepository interface {
repository.BaseRepository[entity.DailyChecklist]
}
type DailyChecklistRepositoryImpl struct {
*repository.BaseRepositoryImpl[entity.DailyChecklist]
}
func NewDailyChecklistRepository(db *gorm.DB) DailyChecklistRepository {
return &DailyChecklistRepositoryImpl{
BaseRepositoryImpl: repository.NewBaseRepository[entity.DailyChecklist](db),
}
}
@@ -0,0 +1,35 @@
package dailyChecklists
import (
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
controller "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/controllers"
dailyChecklist "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/services"
user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
"github.com/gofiber/fiber/v2"
)
func DailyChecklistRoutes(v1 fiber.Router, u user.UserService, s dailyChecklist.DailyChecklistService) {
ctrl := controller.NewDailyChecklistController(s)
route := v1.Group("/daily-checklists")
route.Use(m.Auth(u))
route.Get("/", ctrl.GetAll)
route.Post("/", ctrl.CreateOne)
// create task
route.Post("/phase/:idDailyChecklist", ctrl.CreateDailyChecklistPhase)
// create assigment
route.Post("/assignment/:idDailyChecklist", ctrl.CreateAssignment)
// remove assignment
route.Delete("/:idDailyChecklist/assignments/:idEmployee", ctrl.RemoveAssignment)
//get all tasks
route.Get("/tasks", ctrl.GetAllTasks)
route.Get("/:id", ctrl.GetOne)
route.Patch("/:id", ctrl.UpdateOne)
route.Delete("/:id", ctrl.DeleteOne)
}
@@ -0,0 +1,410 @@
package service
import (
"errors"
"strconv"
"strings"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/validations"
phaseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type DailyChecklistService interface {
GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.DailyChecklist, int64, error)
GetOne(ctx *fiber.Ctx, id uint) (*entity.DailyChecklist, error)
CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.DailyChecklist, error)
UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.DailyChecklist, error)
DeleteOne(ctx *fiber.Ctx, id uint) error
AssignPhases(ctx *fiber.Ctx, id uint, req *validation.AssignPhases) error
AssignTasks(ctx *fiber.Ctx, id uint, req *validation.AssignTask) error
RemoveAssignment(ctx *fiber.Ctx, id uint, employeeID uint) error
GetTasks(ctx *fiber.Ctx, checklistID uint) ([]entity.DailyChecklistActivityTask, error)
}
type dailyChecklistService struct {
Log *logrus.Logger
Validate *validator.Validate
Repository repository.DailyChecklistRepository
PhaseRepo phaseRepo.PhasesRepository
}
func NewDailyChecklistService(repo repository.DailyChecklistRepository, phaseRepo phaseRepo.PhasesRepository, validate *validator.Validate) DailyChecklistService {
return &dailyChecklistService{
Log: utils.Log,
Validate: validate,
Repository: repo,
PhaseRepo: phaseRepo,
}
}
func (s dailyChecklistService) withRelations(db *gorm.DB) *gorm.DB {
return db
}
func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.DailyChecklist, int64, error) {
if err := s.Validate.Struct(params); err != nil {
return nil, 0, err
}
offset := (params.Page - 1) * params.Limit
dailyChecklists, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db)
if params.Search != "" {
return db.Where("name LIKE ?", "%"+params.Search+"%")
}
return db.Order("created_at DESC").Order("updated_at DESC")
})
if err != nil {
s.Log.Errorf("Failed to get dailyChecklists: %+v", err)
return nil, 0, err
}
return dailyChecklists, total, nil
}
func (s dailyChecklistService) GetOne(c *fiber.Ctx, id uint) (*entity.DailyChecklist, error) {
dailyChecklist, err := s.Repository.GetByID(c.Context(), id, s.withRelations)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found")
}
if err != nil {
s.Log.Errorf("Failed get dailyChecklist by id: %+v", err)
return nil, err
}
return dailyChecklist, nil
}
func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.DailyChecklist, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
date, err := time.Parse("2006-01-02", req.Date)
if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid date format, use YYYY-MM-DD")
}
status := req.Status
category := req.Category
createBody := &entity.DailyChecklist{
KandangId: req.KandangId,
Date: date,
Category: category,
Status: &status,
}
err = s.Repository.DB().WithContext(c.Context()).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "date"}, {Name: "kandang_id"}, {Name: "category"}},
DoUpdates: clause.Assignments(map[string]any{"status": status, "updated_at": time.Now()}),
}).Create(createBody).Error
if err != nil {
s.Log.Errorf("Failed to upsert dailyChecklist: %+v", err)
return nil, err
}
return s.GetOne(c, createBody.Id)
}
func (s dailyChecklistService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.DailyChecklist, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
updateBody := make(map[string]any)
if req.Name != nil {
updateBody["name"] = *req.Name
}
if len(updateBody) == 0 {
return s.GetOne(c, id)
}
if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found")
}
s.Log.Errorf("Failed to update dailyChecklist: %+v", err)
return nil, err
}
return s.GetOne(c, id)
}
func (s dailyChecklistService) 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, "DailyChecklist not found")
}
s.Log.Errorf("Failed to delete dailyChecklist: %+v", err)
return err
}
return nil
}
func (s dailyChecklistService) AssignPhases(c *fiber.Ctx, id uint, req *validation.AssignPhases) error {
if err := s.Validate.Struct(req); err != nil {
return err
}
if _, err := s.Repository.GetByID(c.Context(), id, nil); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found")
}
return err
}
phaseIDs, err := parsePhaseIDs(req.PhaseIDs)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
if len(phaseIDs) > 0 {
phases, err := s.PhaseRepo.GetByIDs(c.Context(), phaseIDs, nil)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusBadRequest, "Phase not found")
}
return err
}
if len(phases) != len(phaseIDs) {
return fiber.NewError(fiber.StatusBadRequest, "Phase not found")
}
}
db := s.Repository.DB()
if err := db.WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("checklist_id = ?", id).Delete(&entity.DailyChecklistPhase{}).Error; err != nil {
return err
}
if len(phaseIDs) == 0 {
return nil
}
records := make([]entity.DailyChecklistPhase, 0, len(phaseIDs))
for _, pid := range phaseIDs {
records = append(records, entity.DailyChecklistPhase{
ChecklistId: id,
PhaseId: pid,
})
}
if err := tx.Create(&records).Error; err != nil {
return err
}
if err := tx.Where("checklist_id = ?", id).Delete(&entity.DailyChecklistActivityTask{}).Error; err != nil {
return err
}
var activities []entity.PhaseActivity
if err := tx.Where("phase_id IN ?", phaseIDs).Find(&activities).Error; err != nil {
return err
}
activityRecords := make([]entity.DailyChecklistActivityTask, 0, len(activities))
for _, activity := range activities {
activityRecords = append(activityRecords, entity.DailyChecklistActivityTask{
ChecklistId: id,
PhaseId: activity.PhaseId,
PhaseActivityId: activity.Id,
TimeType: activity.TimeType,
})
}
if len(activityRecords) == 0 {
return nil
}
return tx.Create(&activityRecords).Error
}); err != nil {
s.Log.Errorf("Failed to assign phases to daily checklist: %+v", err)
return err
}
return nil
}
func (s dailyChecklistService) RemoveAssignment(c *fiber.Ctx, id uint, employeeID uint) error {
if _, err := s.Repository.GetByID(c.Context(), id, nil); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found")
}
return err
}
if employeeID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid employee id")
}
db := s.Repository.DB()
if err := db.WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
var tasks []entity.DailyChecklistActivityTask
if err := tx.Where("checklist_id = ?", id).Find(&tasks).Error; err != nil {
return err
}
if len(tasks) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "No activity tasks found for this checklist")
}
taskIDs := collectTaskIDs(tasks)
return tx.Where("task_id IN ? AND employee_id = ?", taskIDs, employeeID).
Delete(&entity.DailyChecklistActivityTaskAssignment{}).Error
}); err != nil {
s.Log.Errorf("Failed to remove assignment: %+v", err)
return err
}
return nil
}
func (s dailyChecklistService) GetTasks(c *fiber.Ctx, checklistID uint) ([]entity.DailyChecklistActivityTask, error) {
if checklistID == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "checklist_id is required")
}
if _, err := s.Repository.GetByID(c.Context(), checklistID, nil); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found")
}
return nil, err
}
var tasks []entity.DailyChecklistActivityTask
if err := s.Repository.DB().WithContext(c.Context()).
Where("checklist_id = ?", checklistID).
Order("created_at ASC").
Find(&tasks).Error; err != nil {
s.Log.Errorf("Failed to get daily checklist tasks: %+v", err)
return nil, err
}
return tasks, nil
}
func parsePhaseIDs(raw string) ([]uint, error) {
parts := strings.Split(raw, ",")
result := make([]uint, 0, len(parts))
seen := make(map[uint]struct{})
for _, part := range parts {
value := strings.TrimSpace(part)
if value == "" {
continue
}
num, err := strconv.ParseUint(value, 10, 64)
if err != nil {
return nil, errors.New("invalid phase id: " + value)
}
u := uint(num)
if _, ok := seen[u]; ok {
continue
}
seen[u] = struct{}{}
result = append(result, u)
}
return result, nil
}
func parseIDs(raw string) ([]uint, error) {
parts := strings.Split(raw, ",")
result := make([]uint, 0, len(parts))
seen := make(map[uint]struct{})
for _, part := range parts {
value := strings.TrimSpace(part)
if value == "" {
continue
}
num, err := strconv.ParseUint(value, 10, 64)
if err != nil {
return nil, errors.New("invalid employee id: " + value)
}
u := uint(num)
if _, ok := seen[u]; ok {
continue
}
seen[u] = struct{}{}
result = append(result, u)
}
return result, nil
}
func collectTaskIDs(tasks []entity.DailyChecklistActivityTask) []uint {
result := make([]uint, len(tasks))
for i, task := range tasks {
result[i] = task.Id
}
return result
}
func (s dailyChecklistService) AssignTasks(c *fiber.Ctx, id uint, req *validation.AssignTask) error {
if err := s.Validate.Struct(req); err != nil {
return err
}
employeeIDs, err := parseIDs(req.EmployeeIDs)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
if len(employeeIDs) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "employee_ids cannot be empty")
}
if _, err := s.Repository.GetByID(c.Context(), id, nil); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found")
}
return err
}
db := s.Repository.DB()
if err := db.WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
var tasks []entity.DailyChecklistActivityTask
if err := tx.Where("checklist_id = ?", id).Find(&tasks).Error; err != nil {
return err
}
if len(tasks) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "No activity tasks found for this checklist")
}
assignments := make([]entity.DailyChecklistActivityTaskAssignment, 0, len(tasks)*len(employeeIDs))
for _, task := range tasks {
for _, empID := range employeeIDs {
assignments = append(assignments, entity.DailyChecklistActivityTaskAssignment{
TaskId: task.Id,
EmployeeId: empID,
})
}
}
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "task_id"}, {Name: "employee_id"}},
DoUpdates: clause.Assignments(map[string]any{"updated_at": time.Now()}),
}).Create(&assignments).Error
}); err != nil {
s.Log.Errorf("Failed to assign tasks to daily checklist: %+v", err)
return err
}
return nil
}
@@ -0,0 +1,26 @@
package validation
type Create struct {
Date string `json:"date" validate:"required"`
KandangId uint `json:"kandang_id" validate:"required"`
Category string `json:"category" validate:"required"`
Status string `json:"status" validate:"required"`
}
type Update struct {
Name *string `json:"name,omitempty" validate:"omitempty"`
}
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"`
Search string `query:"search" validate:"omitempty,max=50"`
}
type AssignPhases struct {
PhaseIDs string `json:"phase_ids" validate:"required"`
}
type AssignTask struct {
EmployeeIDs string `json:"employee_ids" validate:"required"`
}
@@ -203,12 +203,12 @@ func (u *ExpenseController) UpdateOne(c *fiber.Ctx) error {
func (u *ExpenseController) DeleteOne(c *fiber.Ctx) error {
param := c.Params("id")
id, err := strconv.Atoi(param)
id64, err := strconv.ParseUint(param, 10, 64)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
if err := u.ExpenseService.DeleteOne(c, uint(id)); err != nil {
if err := u.ExpenseService.DeleteOne(c, id64); err != nil {
return err
}
+3 -5
View File
@@ -105,11 +105,9 @@ func ToExpenseBaseDTO(e *entity.Expense) ExpenseBaseDTO {
realizationDate = &e.RealizationDate
}
if len(e.Nonstocks) > 0 && e.Nonstocks[0].Kandang != nil {
if e.Nonstocks[0].Kandang.Location.Id != 0 {
mapped := locationDTO.ToLocationRelationDTO(e.Nonstocks[0].Kandang.Location)
location = &mapped
}
if e.Location != nil && e.Location.Id != 0 {
mapped := locationDTO.ToLocationRelationDTO(*e.Location)
location = &mapped
}
if e.Supplier != nil && e.Supplier.Id != 0 {
@@ -3,6 +3,8 @@ package repository
import (
"context"
"errors"
"fmt"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
@@ -17,6 +19,7 @@ type ExpenseRepository interface {
GetWithSupplier(ctx context.Context, id uint64) (*entity.Expense, error)
WithProjectFlockKandangFilter(pfkID, kandangID uint) func(*gorm.DB) *gorm.DB
CountUnfinishedByProjectFlockKandang(ctx context.Context, pfkID, kandangID uint, isFinished func(*entity.Approval) bool) (int64, error)
DeleteOne(ctx context.Context, id uint) error
}
type ExpenseRepositoryImpl struct {
@@ -107,3 +110,23 @@ func (r *ExpenseRepositoryImpl) CountUnfinishedByProjectFlockKandang(ctx context
}
return unfinished, nil
}
func (r *ExpenseRepositoryImpl) DeleteOne(ctx context.Context, id uint) error {
// Cast to uint64 to match entity.Id type
id64 := uint64(id)
deletedAt := time.Now()
// Use raw SQL with interpolated integer to avoid type issues
// Interpolate id directly as integer literal (safe because it's uint64)
result := r.DB().WithContext(ctx).
Exec(`UPDATE "expenses" SET "deleted_at" = $1 WHERE "id" = `+fmt.Sprintf("%d", id64)+` AND "deleted_at" IS NULL`,
deletedAt)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
+12 -12
View File
@@ -22,16 +22,16 @@ func ExpenseRoutes(v1 fiber.Router, u user.UserService, s expense.ExpenseService
// route.Patch("/:id", m.Auth(u), ctrl.UpdateOne)
// route.Delete("/:id", m.Auth(u), ctrl.DeleteOne)
route.Get("/",m.RequirePermissions(m.P_ExpenseGetAll), ctrl.GetAll)
route.Post("/",m.RequirePermissions(m.P_ExpenseCreateOne), ctrl.CreateOne)
route.Get("/:id",m.RequirePermissions(m.P_ExpenseGetOne), ctrl.GetOne)
route.Patch("/:id",m.RequirePermissions(m.P_ExpenseUpdateOne), ctrl.UpdateOne)
route.Delete("/:id",m.RequirePermissions(m.P_ExpenseDeleteOne), ctrl.DeleteOne)
route.Post("/approvals/manager",m.RequirePermissions(m.P_ExpenseApprovalManager), ctrl.Approval)
route.Post("/approvals/finance",m.RequirePermissions(m.P_ExpenseApprovalFinance), ctrl.Approval)
route.Post("/:id/realizations",m.RequirePermissions(m.P_ExpenseCreateRealizations), ctrl.CreateRealization)
route.Patch("/:id/realizations",m.RequirePermissions(m.P_ExpenseUpdateRealizations), ctrl.UpdateRealization)
route.Post("/:id/complete",m.RequirePermissions(m.P_ExpenseCompleteExpense), ctrl.CompleteExpense)
route.Delete("/:id/documents/:documentId",m.RequirePermissions(m.P_ExpenseDocument), ctrl.DeleteDocument)
route.Delete("/:id/realization-documents/:documentId",m.RequirePermissions(m.P_ExpenseDocumentRealizations), ctrl.DeleteRealizationDocument)
route.Get("/", m.RequirePermissions(m.P_ExpenseGetAll), ctrl.GetAll)
route.Post("/", m.RequirePermissions(m.P_ExpenseCreateOne), ctrl.CreateOne)
route.Get("/:id", m.RequirePermissions(m.P_ExpenseGetOne), ctrl.GetOne)
route.Patch("/:id", m.RequirePermissions(m.P_ExpenseUpdateOne), ctrl.UpdateOne)
route.Delete("/:id", m.RequirePermissions(m.P_ExpenseDeleteOne), ctrl.DeleteOne)
route.Post("/approvals/manager", m.RequirePermissions(m.P_ExpenseApprovalManager), ctrl.Approval)
route.Post("/approvals/finance", m.RequirePermissions(m.P_ExpenseApprovalFinance), ctrl.Approval)
route.Post("/:id/realizations", m.RequirePermissions(m.P_ExpenseCreateRealizations), ctrl.CreateRealization)
route.Patch("/:id/realizations", m.RequirePermissions(m.P_ExpenseUpdateRealizations), ctrl.UpdateRealization)
route.Post("/:id/complete", m.RequirePermissions(m.P_ExpenseCompleteExpense), ctrl.CompleteExpense)
route.Delete("/:id/documents/:documentId", m.RequirePermissions(m.P_ExpenseDocument), ctrl.DeleteDocument)
route.Delete("/:id/realization-documents/:documentId", m.RequirePermissions(m.P_ExpenseDocumentRealizations), ctrl.DeleteRealizationDocument)
}
@@ -30,7 +30,7 @@ type ExpenseService interface {
GetOne(ctx *fiber.Ctx, id uint) (*expenseDto.ExpenseDetailDTO, error)
CreateOne(ctx *fiber.Ctx, req *validation.Create) (*expenseDto.ExpenseDetailDTO, error)
UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*expenseDto.ExpenseDetailDTO, error)
DeleteOne(ctx *fiber.Ctx, id uint) error
DeleteOne(ctx *fiber.Ctx, id uint64) error
CreateRealization(ctx *fiber.Ctx, expenseID uint, req *validation.CreateRealization) (*expenseDto.ExpenseDetailDTO, error)
CompleteExpense(ctx *fiber.Ctx, id uint, notes *string) (*expenseDto.ExpenseDetailDTO, error)
UpdateRealization(ctx *fiber.Ctx, expenseID uint, req *validation.UpdateRealization) (*expenseDto.ExpenseDetailDTO, error)
@@ -68,6 +68,7 @@ func (s expenseService) withRelations(db *gorm.DB) *gorm.DB {
return db.
Preload("CreatedUser").
Preload("Supplier").
Preload("Location").
Preload("Nonstocks.Nonstock").
Preload("Nonstocks.Realization").
Preload("Nonstocks.ProjectFlockKandang.Kandang.Location").
@@ -214,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{
@@ -621,14 +620,15 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
return responseDTO, nil
}
func (s expenseService) DeleteOne(c *fiber.Ctx, id uint) error {
func (s expenseService) DeleteOne(c *fiber.Ctx, id uint64) error {
idUint := uint(id)
if err := commonSvc.EnsureRelations(c.Context(),
commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: s.Repository.IdExists},
commonSvc.RelationCheck{Name: "Expense", ID: &idUint, Exists: s.Repository.IdExists},
); err != nil {
return err
}
expense, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
expense, err := s.Repository.GetByID(c.Context(), idUint, func(db *gorm.DB) *gorm.DB {
return db.Preload("Nonstocks")
})
if err != nil {
@@ -643,7 +643,7 @@ func (s expenseService) DeleteOne(c *fiber.Ctx, id uint) error {
if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), expense); err != nil {
return err
}
if err := s.Repository.DeleteOne(c.Context(), id); err != nil {
if err := s.Repository.DeleteOne(c.Context(), idUint); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Expense not found for ID %d: %+v", id, err)
return fiber.NewError(fiber.StatusNotFound, "Expense not found")
@@ -118,10 +118,9 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
var createdLogId uint
var projectFlockKandangID *uint
pfk, err := s.ProjectFlockKandangRepo.GetActiveByKandangID(ctx, uint(req.WarehouseID))
if err == nil && pfk != nil {
idCopy := uint(pfk.Id)
projectFlockKandangID = &idCopy
pfkID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.WarehouseID))
if err == nil && pfkID > 0 {
projectFlockKandangID = &pfkID
}
pw, err := s.ProductWarehouseRepo.FindByProductWarehouseAndPfk(
@@ -195,13 +194,17 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
StockLogId: newLog.Id,
ProductWarehouseId: productWarehouse.Id,
}
if err := s.AdjustmentStockRepository.WithTx(tx).CreateOne(ctx, adjustmentStock, nil); err != nil {
s.Log.Errorf("Failed to create adjustment stock: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create adjustment stock record")
}
if transactionType == string(utils.StockLogTransactionTypeIncrease) {
// Adjustment INCREASE → Replenish stock (Stockable)
note := fmt.Sprintf("Stock Adjustment IN #%d", newLog.Id)
replenishResult, err := s.FifoSvc.Replenish(ctx, common.StockReplenishRequest{
_, err := s.FifoSvc.Replenish(ctx, common.StockReplenishRequest{
StockableKey: "ADJUSTMENT_IN",
StockableID: newLog.Id,
StockableID: adjustmentStock.Id,
ProductWarehouseID: uint(productWarehouse.Id),
Quantity: req.Quantity,
Note: &note,
@@ -211,15 +214,11 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to replenish stock via FIFO: %v", err))
}
// Update stockable tracking fields
adjustmentStock.TotalQty = replenishResult.AddedQuantity
adjustmentStock.TotalUsed = 0
} else {
// Adjustment DECREASE → Consume stock (Usable)
consumeResult, err := s.FifoSvc.Consume(ctx, common.StockConsumeRequest{
_, err := s.FifoSvc.Consume(ctx, common.StockConsumeRequest{
UsableKey: "ADJUSTMENT_OUT",
UsableID: newLog.Id,
UsableID: adjustmentStock.Id,
ProductWarehouseID: uint(productWarehouse.Id),
Quantity: req.Quantity,
AllowPending: false, // Don't allow pending for adjustment
@@ -228,16 +227,6 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to consume stock via FIFO: %v", err))
}
// Update usable tracking fields
adjustmentStock.UsageQty = consumeResult.UsageQuantity
adjustmentStock.PendingQty = consumeResult.PendingQuantity
}
// Save AdjustmentStock record
if err := s.AdjustmentStockRepository.WithTx(tx).CreateOne(ctx, adjustmentStock, nil); err != nil {
s.Log.Errorf("Failed to create adjustment stock: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create adjustment stock record")
}
// Update ProductWarehouse quantity (for backward compatibility/reporting)
@@ -216,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).
@@ -0,0 +1,161 @@
package controller
import (
"math"
"strconv"
"gitlab.com/mbugroup/lti-api.git/internal/modules/master/employees/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/master/employees/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/employees/validations"
"gitlab.com/mbugroup/lti-api.git/internal/response"
"github.com/gofiber/fiber/v2"
)
type EmployeesController struct {
EmployeesService service.EmployeesService
}
func NewEmployeesController(employeesService service.EmployeesService) *EmployeesController {
return &EmployeesController{
EmployeesService: employeesService,
}
}
func (u *EmployeesController) GetAll(c *fiber.Ctx) error {
query := &validation.Query{
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""),
}
if query.Page < 1 || query.Limit < 1 {
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
}
if kandangParam := c.Query("kandang_id", ""); kandangParam != "" {
id, err := strconv.Atoi(kandangParam)
if err != nil || id <= 0 {
return fiber.NewError(fiber.StatusBadRequest, "invalid kandang_id")
}
temp := uint(id)
query.KandangId = &temp
}
if activeParam := c.Query("is_active", ""); activeParam != "" {
value, err := strconv.ParseBool(activeParam)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid is_active value")
}
query.IsActive = &value
}
result, totalResults, err := u.EmployeesService.GetAll(c, query)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.SuccessWithPaginate[dto.EmployeesListDTO]{
Code: fiber.StatusOK,
Status: "success",
Message: "Get all employeess successfully",
Meta: response.Meta{
Page: query.Page,
Limit: query.Limit,
TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))),
TotalResults: totalResults,
},
Data: dto.ToEmployeesListDTOs(result),
})
}
func (u *EmployeesController) GetOne(c *fiber.Ctx) error {
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
result, err := u.EmployeesService.GetOne(c, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Get employees successfully",
Data: dto.ToEmployeesListDTO(*result),
})
}
func (u *EmployeesController) CreateOne(c *fiber.Ctx) error {
req := new(validation.Create)
if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
result, err := u.EmployeesService.CreateOne(c, req)
if err != nil {
return err
}
return c.Status(fiber.StatusCreated).
JSON(response.Success{
Code: fiber.StatusCreated,
Status: "success",
Message: "Create employees successfully",
Data: dto.ToEmployeesListDTO(*result),
})
}
func (u *EmployeesController) UpdateOne(c *fiber.Ctx) error {
req := new(validation.Update)
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
result, err := u.EmployeesService.UpdateOne(c, req, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Update employees successfully",
Data: dto.ToEmployeesListDTO(*result),
})
}
func (u *EmployeesController) DeleteOne(c *fiber.Ctx) error {
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
if err := u.EmployeesService.DeleteOne(c, uint(id)); err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Common{
Code: fiber.StatusOK,
Status: "success",
Message: "Delete employees successfully",
})
}
@@ -0,0 +1,70 @@
package dto
import (
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto"
)
// === DTO Structs ===
type EmployeesRelationDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
}
type EmployeesListDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
IsActive bool `json:"is_active"`
Kandangs []kandangDTO.KandangRelationDTO `json:"kandangs"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type EmployeesDetailDTO struct {
EmployeesListDTO
}
// === Mapper Functions ===
func ToEmployeesRelationDTO(e entity.Employees) EmployeesRelationDTO {
return EmployeesRelationDTO{
Id: e.Id,
Name: e.Name,
}
}
func ToEmployeesListDTO(e entity.Employees) EmployeesListDTO {
kandangs := make([]kandangDTO.KandangRelationDTO, 0, len(e.EmployeeKandangs))
for _, rel := range e.EmployeeKandangs {
if rel.Kandang.Id == 0 {
continue
}
kandangs = append(kandangs, kandangDTO.ToKandangRelationDTO(rel.Kandang))
}
return EmployeesListDTO{
Id: e.Id,
Name: e.Name,
IsActive: e.IsActive,
Kandangs: kandangs,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
}
}
func ToEmployeesListDTOs(e []entity.Employees) []EmployeesListDTO {
result := make([]EmployeesListDTO, len(e))
for i, r := range e {
result[i] = ToEmployeesListDTO(r)
}
return result
}
func ToEmployeesDetailDTO(e entity.Employees) EmployeesDetailDTO {
return EmployeesDetailDTO{
EmployeesListDTO: ToEmployeesListDTO(e),
}
}
@@ -0,0 +1,25 @@
package employeess
import (
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
rEmployees "gitlab.com/mbugroup/lti-api.git/internal/modules/master/employees/repositories"
sEmployees "gitlab.com/mbugroup/lti-api.git/internal/modules/master/employees/services"
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
)
type EmployeesModule struct{}
func (EmployeesModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
employeesRepo := rEmployees.NewEmployeesRepository(db)
userRepo := rUser.NewUserRepository(db)
employeesService := sEmployees.NewEmployeesService(employeesRepo, validate)
userService := sUser.NewUserService(userRepo, validate)
EmployeesRoutes(router, userService, employeesService)
}
@@ -0,0 +1,21 @@
package repository
import (
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
"gorm.io/gorm"
)
type EmployeesRepository interface {
repository.BaseRepository[entity.Employees]
}
type EmployeesRepositoryImpl struct {
*repository.BaseRepositoryImpl[entity.Employees]
}
func NewEmployeesRepository(db *gorm.DB) EmployeesRepository {
return &EmployeesRepositoryImpl{
BaseRepositoryImpl: repository.NewBaseRepository[entity.Employees](db),
}
}
@@ -0,0 +1,23 @@
package employeess
import (
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/employees/controllers"
employees "gitlab.com/mbugroup/lti-api.git/internal/modules/master/employees/services"
user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
"github.com/gofiber/fiber/v2"
)
func EmployeesRoutes(v1 fiber.Router, u user.UserService, s employees.EmployeesService) {
ctrl := controller.NewEmployeesController(s)
route := v1.Group("/employees")
route.Use(m.Auth(u))
route.Get("/", ctrl.GetAll)
route.Post("/", ctrl.CreateOne)
route.Get("/:id", ctrl.GetOne)
route.Patch("/:id", ctrl.UpdateOne)
route.Delete("/:id", ctrl.DeleteOne)
}
@@ -0,0 +1,265 @@
package service
import (
"errors"
"strings"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/employees/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/employees/validations"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
)
type EmployeesService interface {
GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Employees, int64, error)
GetOne(ctx *fiber.Ctx, id uint) (*entity.Employees, error)
CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Employees, error)
UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Employees, error)
DeleteOne(ctx *fiber.Ctx, id uint) error
}
type employeesService struct {
Log *logrus.Logger
Validate *validator.Validate
Repository repository.EmployeesRepository
}
func NewEmployeesService(repo repository.EmployeesRepository, validate *validator.Validate) EmployeesService {
return &employeesService{
Log: utils.Log,
Validate: validate,
Repository: repo,
}
}
func (s employeesService) withRelations(db *gorm.DB) *gorm.DB {
return db.
Preload("EmployeeKandangs.Kandang").
Where("employees.deleted_at IS NULL")
}
func (s employeesService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Employees, int64, error) {
if err := s.Validate.Struct(params); err != nil {
return nil, 0, err
}
offset := (params.Page - 1) * params.Limit
employeess, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db)
if params.Search != "" {
db = db.Where("employees.name LIKE ?", "%"+params.Search+"%")
}
if params.KandangId != nil {
db = db.Joins("JOIN employee_kandangs ek ON ek.employee_id = employees.id").
Where("ek.kandang_id = ?", *params.KandangId)
}
if params.IsActive != nil {
db = db.Where("employees.is_active = ?", *params.IsActive)
}
return db.Order("employees.created_at DESC").Order("employees.updated_at DESC")
})
if err != nil {
s.Log.Errorf("Failed to get employeess: %+v", err)
return nil, 0, err
}
return employeess, total, nil
}
func (s employeesService) GetOne(c *fiber.Ctx, id uint) (*entity.Employees, error) {
employees, err := s.Repository.GetByID(c.Context(), id, s.withRelations)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Employees not found")
}
if err != nil {
s.Log.Errorf("Failed get employees by id: %+v", err)
return nil, err
}
return employees, nil
}
func (s *employeesService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Employees, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
name := strings.TrimSpace(req.Name)
if name == "" {
return nil, fiber.NewError(fiber.StatusBadRequest, "name cannot be empty")
}
kandangIDs := normalizeKandangIDs(req.KandangIDs)
if len(kandangIDs) == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "kandang_ids must contain at least one valid id")
}
if _, err := s.Repository.First(c.Context(), func(db *gorm.DB) *gorm.DB {
return db.Where("LOWER(name) = ?", strings.ToLower(name))
}); err == nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "employee already exists")
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Failed checking employee uniqueness: %+v", err)
return nil, err
}
createBody := &entity.Employees{
Name: name,
IsActive: req.IsActive,
}
if err := s.Repository.DB().Transaction(func(tx *gorm.DB) error {
repoTx := s.Repository.WithTx(tx)
if err := repoTx.CreateOne(c.Context(), createBody, nil); err != nil {
return err
}
relations := make([]entity.EmployeeKandang, 0, len(kandangIDs))
for _, kandangID := range kandangIDs {
relations = append(relations, entity.EmployeeKandang{
EmployeeId: createBody.Id,
KandangId: kandangID,
})
}
if len(relations) > 0 {
if err := tx.WithContext(c.Context()).Create(&relations).Error; err != nil {
return err
}
}
return nil
}); err != nil {
s.Log.Errorf("Failed to create employees: %+v", err)
return nil, err
}
return s.GetOne(c, createBody.Id)
}
func (s employeesService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Employees, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
updateBody := make(map[string]any)
var (
kandangIDs []uint
needKandangUpdate bool
)
if req.Name != nil {
trimmed := strings.TrimSpace(*req.Name)
if trimmed == "" {
return nil, fiber.NewError(fiber.StatusBadRequest, "name cannot be empty")
}
if _, err := s.Repository.First(c.Context(), func(db *gorm.DB) *gorm.DB {
return db.Where("LOWER(name) = ? AND id <> ?", strings.ToLower(trimmed), id)
}); err == nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "employee already exists")
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Failed checking employee uniqueness: %+v", err)
return nil, err
}
updateBody["name"] = trimmed
}
if req.IsActive != nil {
updateBody["is_active"] = *req.IsActive
}
if req.KandangIDs != nil {
ids := normalizeKandangIDs(*req.KandangIDs)
if len(ids) == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "kandang_ids must contain at least one valid id")
}
kandangIDs = ids
needKandangUpdate = true
}
if len(updateBody) == 0 && !needKandangUpdate {
return s.GetOne(c, id)
}
if err := s.Repository.DB().Transaction(func(tx *gorm.DB) error {
repoTx := s.Repository.WithTx(tx)
if len(updateBody) > 0 {
if err := repoTx.PatchOne(c.Context(), id, updateBody, nil); err != nil {
return err
}
}
if needKandangUpdate {
if err := tx.WithContext(c.Context()).
Where("employee_id = ?", id).
Delete(&entity.EmployeeKandang{}).Error; err != nil {
return err
}
relations := make([]entity.EmployeeKandang, 0, len(kandangIDs))
for _, kandangID := range kandangIDs {
relations = append(relations, entity.EmployeeKandang{
EmployeeId: id,
KandangId: kandangID,
})
}
if len(relations) > 0 {
if err := tx.WithContext(c.Context()).Create(&relations).Error; err != nil {
return err
}
}
}
return nil
}); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Employees not found")
}
s.Log.Errorf("Failed to update employees: %+v", err)
return nil, err
}
return s.GetOne(c, id)
}
func (s employeesService) 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, "Employees not found")
}
s.Log.Errorf("Failed to delete employees: %+v", err)
return err
}
return nil
}
func normalizeKandangIDs(ids []uint) []uint {
result := make([]uint, 0, len(ids))
seen := make(map[uint]struct{})
for _, id := range ids {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
result = append(result, id)
}
return result
}
@@ -0,0 +1,21 @@
package validation
type Create struct {
Name string `json:"name" validate:"required_strict,min=3"`
KandangIDs []uint `json:"kandang_ids" validate:"required,min=1,dive,required"`
IsActive bool `json:"is_active"`
}
type Update struct {
Name *string `json:"name,omitempty" validate:"omitempty"`
KandangIDs *[]uint `json:"kandang_ids,omitempty" validate:"omitempty,min=1,dive,required"`
IsActive *bool `json:"is_active,omitempty"`
}
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"`
Search string `query:"search" validate:"omitempty,max=50"`
KandangId *uint `query:"kandang_id" validate:"omitempty"`
IsActive *bool `query:"is_active" validate:"omitempty"`
}
@@ -23,10 +23,12 @@ func NewNonstockController(nonstockService service.NonstockService) *NonstockCon
}
func (u *NonstockController) GetAll(c *fiber.Ctx) error {
query := &validation.Query{
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""),
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""),
SupplierID: uint(c.QueryInt("supplier_id", 0)),
}
if query.Page < 1 || query.Limit < 1 {
@@ -58,7 +58,15 @@ func (s nonstockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit
offset := (params.Page - 1) * params.Limit
nonstocks, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
if params.SupplierID > 0 {
db = db.Joins("INNER JOIN nonstock_suppliers ON nonstock_suppliers.nonstock_id = nonstocks.id").
Where("nonstock_suppliers.supplier_id = ?", params.SupplierID).
Distinct()
}
db = s.withRelations(db)
if params.Search != "" {
return db.Where("name LIKE ?", "%"+params.Search+"%")
}
@@ -15,7 +15,8 @@ type Update struct {
}
type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
Search string `query:"search" validate:"omitempty,max=50"`
Page int `query:"page" validate:"omitempty,number,min=1"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
Search string `query:"search" validate:"omitempty,max=50"`
SupplierID uint `query:"supplier_id" validate:"omitempty,gt=0"`
}
@@ -0,0 +1,153 @@
package controller
import (
"math"
"strconv"
"gitlab.com/mbugroup/lti-api.git/internal/modules/master/phase-activities/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phase-activities/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phase-activities/validations"
"gitlab.com/mbugroup/lti-api.git/internal/response"
"github.com/gofiber/fiber/v2"
)
type PhaseActivityController struct {
PhaseActivityService service.PhaseActivityService
}
func NewPhaseActivityController(phaseActivityService service.PhaseActivityService) *PhaseActivityController {
return &PhaseActivityController{
PhaseActivityService: phaseActivityService,
}
}
func (u *PhaseActivityController) GetAll(c *fiber.Ctx) error {
query := &validation.Query{
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""),
}
if query.Page < 1 || query.Limit < 1 {
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
}
if phaseParam := c.Query("phase_id", ""); phaseParam != "" {
id, err := strconv.Atoi(phaseParam)
if err != nil || id <= 0 {
return fiber.NewError(fiber.StatusBadRequest, "invalid phase_id")
}
temp := uint(id)
query.PhaseId = &temp
}
result, totalResults, err := u.PhaseActivityService.GetAll(c, query)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.SuccessWithPaginate[dto.PhaseActivityListDTO]{
Code: fiber.StatusOK,
Status: "success",
Message: "Get all phaseActivitys successfully",
Meta: response.Meta{
Page: query.Page,
Limit: query.Limit,
TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))),
TotalResults: totalResults,
},
Data: dto.ToPhaseActivityListDTOs(result),
})
}
func (u *PhaseActivityController) GetOne(c *fiber.Ctx) error {
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
result, err := u.PhaseActivityService.GetOne(c, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Get phaseActivity successfully",
Data: dto.ToPhaseActivityListDTO(*result),
})
}
func (u *PhaseActivityController) CreateOne(c *fiber.Ctx) error {
req := new(validation.Create)
if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
result, err := u.PhaseActivityService.CreateOne(c, req)
if err != nil {
return err
}
return c.Status(fiber.StatusCreated).
JSON(response.Success{
Code: fiber.StatusCreated,
Status: "success",
Message: "Create phaseActivity successfully",
Data: dto.ToPhaseActivityListDTO(*result),
})
}
func (u *PhaseActivityController) UpdateOne(c *fiber.Ctx) error {
req := new(validation.Update)
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
result, err := u.PhaseActivityService.UpdateOne(c, req, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Update phaseActivity successfully",
Data: dto.ToPhaseActivityListDTO(*result),
})
}
func (u *PhaseActivityController) DeleteOne(c *fiber.Ctx) error {
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
if err := u.PhaseActivityService.DeleteOne(c, uint(id)); err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Common{
Code: fiber.StatusOK,
Status: "success",
Message: "Delete phaseActivity successfully",
})
}
@@ -0,0 +1,72 @@
package dto
import (
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
)
// === DTO Structs ===
type PhaseActivityRelationDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
}
type PhaseActivityListDTO struct {
Id uint `json:"id"`
PhaseId uint `json:"phase_id"`
Name string `json:"name"`
Description *string `json:"description"`
TimeType *string `json:"time_type"`
CreatedUser *userDTO.UserRelationDTO `json:"created_user"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type PhaseActivityDetailDTO struct {
PhaseActivityListDTO
}
// === Mapper Functions ===
func ToPhaseActivityRelationDTO(e entity.PhaseActivity) PhaseActivityRelationDTO {
return PhaseActivityRelationDTO{
Id: e.Id,
Name: e.Name,
}
}
func ToPhaseActivityListDTO(e entity.PhaseActivity) PhaseActivityListDTO {
var createdUser *userDTO.UserRelationDTO
// if e.CreatedUser.Id != 0 {
// mapped := userDTO.ToUserRelationDTO(e.CreatedUser)
// createdUser = &mapped
// }
return PhaseActivityListDTO{
Id: e.Id,
PhaseId: e.PhaseId,
Name: e.Name,
Description: e.Description,
TimeType: e.TimeType,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
CreatedUser: createdUser,
}
}
func ToPhaseActivityListDTOs(e []entity.PhaseActivity) []PhaseActivityListDTO {
result := make([]PhaseActivityListDTO, len(e))
for i, r := range e {
result[i] = ToPhaseActivityListDTO(r)
}
return result
}
func ToPhaseActivityDetailDTO(e entity.PhaseActivity) PhaseActivityDetailDTO {
return PhaseActivityDetailDTO{
PhaseActivityListDTO: ToPhaseActivityListDTO(e),
}
}
@@ -0,0 +1,27 @@
package phaseActivity
import (
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
rPhaseActivity "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phase-activities/repositories"
sPhaseActivity "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phase-activities/services"
rPhases "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/repositories"
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
)
type PhaseActivityModule struct{}
func (PhaseActivityModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
phaseActivityRepo := rPhaseActivity.NewPhaseActivityRepository(db)
phasesRepo := rPhases.NewPhasesRepository(db)
userRepo := rUser.NewUserRepository(db)
phaseActivityService := sPhaseActivity.NewPhaseActivityService(phaseActivityRepo, phasesRepo, validate)
userService := sUser.NewUserService(userRepo, validate)
PhaseActivityRoutes(router, userService, phaseActivityService)
}
@@ -0,0 +1,21 @@
package repository
import (
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
"gorm.io/gorm"
)
type PhaseActivityRepository interface {
repository.BaseRepository[entity.PhaseActivity]
}
type PhaseActivityRepositoryImpl struct {
*repository.BaseRepositoryImpl[entity.PhaseActivity]
}
func NewPhaseActivityRepository(db *gorm.DB) PhaseActivityRepository {
return &PhaseActivityRepositoryImpl{
BaseRepositoryImpl: repository.NewBaseRepository[entity.PhaseActivity](db),
}
}
@@ -0,0 +1,23 @@
package phaseActivity
import (
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phase-activities/controllers"
phaseActivity "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phase-activities/services"
user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
"github.com/gofiber/fiber/v2"
)
func PhaseActivityRoutes(v1 fiber.Router, u user.UserService, s phaseActivity.PhaseActivityService) {
ctrl := controller.NewPhaseActivityController(s)
route := v1.Group("/phase-activities")
route.Use(m.Auth(u))
route.Get("/", ctrl.GetAll)
route.Post("/", ctrl.CreateOne)
route.Get("/:id", ctrl.GetOne)
route.Patch("/:id", ctrl.UpdateOne)
route.Delete("/:id", ctrl.DeleteOne)
}
@@ -0,0 +1,167 @@
package service
import (
"errors"
"strings"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phase-activities/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phase-activities/validations"
phaseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
)
type PhaseActivityService interface {
GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.PhaseActivity, int64, error)
GetOne(ctx *fiber.Ctx, id uint) (*entity.PhaseActivity, error)
CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.PhaseActivity, error)
UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.PhaseActivity, error)
DeleteOne(ctx *fiber.Ctx, id uint) error
}
type phaseActivityService struct {
Log *logrus.Logger
Validate *validator.Validate
Repository repository.PhaseActivityRepository
PhaseRepo phaseRepo.PhasesRepository
}
func NewPhaseActivityService(repo repository.PhaseActivityRepository, phaseRepo phaseRepo.PhasesRepository, validate *validator.Validate) PhaseActivityService {
return &phaseActivityService{
Log: utils.Log,
Validate: validate,
Repository: repo,
PhaseRepo: phaseRepo,
}
}
func (s phaseActivityService) withRelations(db *gorm.DB) *gorm.DB {
return db
}
func (s phaseActivityService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.PhaseActivity, int64, error) {
if err := s.Validate.Struct(params); err != nil {
return nil, 0, err
}
offset := (params.Page - 1) * params.Limit
phaseActivitys, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db)
if params.Search != "" {
db = db.Where("name LIKE ?", "%"+params.Search+"%")
}
if params.PhaseId != nil {
db = db.Where("phase_id = ?", *params.PhaseId)
}
return db.Order("created_at DESC").Order("updated_at DESC")
})
if err != nil {
s.Log.Errorf("Failed to get phaseActivitys: %+v", err)
return nil, 0, err
}
return phaseActivitys, total, nil
}
func (s phaseActivityService) GetOne(c *fiber.Ctx, id uint) (*entity.PhaseActivity, error) {
phaseActivity, err := s.Repository.GetByID(c.Context(), id, s.withRelations)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "PhaseActivity not found")
}
if err != nil {
s.Log.Errorf("Failed get phaseActivity by id: %+v", err)
return nil, err
}
return phaseActivity, nil
}
func (s *phaseActivityService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.PhaseActivity, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
phase, err := s.PhaseRepo.GetByID(c.Context(), req.PhaseId, nil)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusBadRequest, "phase not found")
}
if err != nil {
s.Log.Errorf("Failed to get phase: %+v", err)
return nil, err
}
name := strings.TrimSpace(req.Name)
if name == "" {
return nil, fiber.NewError(fiber.StatusBadRequest, "name cannot be empty")
}
timeType := strings.TrimSpace(req.TimeType)
if timeType == "" {
return nil, fiber.NewError(fiber.StatusBadRequest, "time_type cannot be empty")
}
createBody := &entity.PhaseActivity{
PhaseId: phase.Id,
Name: name,
Description: req.Description,
TimeType: &timeType,
}
if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil {
s.Log.Errorf("Failed to create phaseActivity: %+v", err)
return nil, err
}
return s.GetOne(c, createBody.Id)
}
func (s phaseActivityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.PhaseActivity, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
trimmedName := strings.TrimSpace(req.Name)
if trimmedName == "" {
return nil, fiber.NewError(fiber.StatusBadRequest, "name cannot be empty")
}
trimmedTimeType := strings.TrimSpace(req.TimeType)
if trimmedTimeType == "" {
return nil, fiber.NewError(fiber.StatusBadRequest, "time_type cannot be empty")
}
updateBody := map[string]any{
"name": trimmedName,
"time_type": trimmedTimeType,
}
if req.Description != nil {
updateBody["description"] = *req.Description
}
if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "PhaseActivity not found")
}
s.Log.Errorf("Failed to update phaseActivity: %+v", err)
return nil, err
}
return s.GetOne(c, id)
}
func (s phaseActivityService) 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, "PhaseActivity not found")
}
s.Log.Errorf("Failed to delete phaseActivity: %+v", err)
return err
}
return nil
}
@@ -0,0 +1,21 @@
package validation
type Create struct {
PhaseId uint `json:"phase_id" validate:"required"`
Name string `json:"name" validate:"required_strict,min=3"`
Description *string `json:"description,omitempty"`
TimeType string `json:"time_type" validate:"required"`
}
type Update struct {
Name string `json:"name" validate:"required_strict,min=3"`
Description *string `json:"description,omitempty"`
TimeType string `json:"time_type" validate:"required"`
}
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"`
Search string `query:"search" validate:"omitempty,max=50"`
PhaseId *uint `query:"phase_id" validate:"omitempty"`
}
@@ -0,0 +1,148 @@
package controller
import (
"math"
"strconv"
"gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/validations"
"gitlab.com/mbugroup/lti-api.git/internal/response"
"github.com/gofiber/fiber/v2"
)
type PhasesController struct {
PhasesService service.PhasesService
}
func NewPhasesController(phasesService service.PhasesService) *PhasesController {
return &PhasesController{
PhasesService: phasesService,
}
}
func (u *PhasesController) GetAll(c *fiber.Ctx) error {
query := &validation.Query{
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""),
}
if query.Page < 1 || query.Limit < 1 {
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
}
if category := c.Query("category", ""); category != "" {
query.Category = &category
}
result, totalResults, err := u.PhasesService.GetAll(c, query)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.SuccessWithPaginate[dto.PhasesListDTO]{
Code: fiber.StatusOK,
Status: "success",
Message: "Get all phasess successfully",
Meta: response.Meta{
Page: query.Page,
Limit: query.Limit,
TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))),
TotalResults: totalResults,
},
Data: dto.ToPhasesListDTOs(result),
})
}
func (u *PhasesController) GetOne(c *fiber.Ctx) error {
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
result, err := u.PhasesService.GetOne(c, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Get phases successfully",
Data: dto.ToPhasesListDTO(*result),
})
}
func (u *PhasesController) CreateOne(c *fiber.Ctx) error {
req := new(validation.Create)
if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
result, err := u.PhasesService.CreateOne(c, req)
if err != nil {
return err
}
return c.Status(fiber.StatusCreated).
JSON(response.Success{
Code: fiber.StatusCreated,
Status: "success",
Message: "Create phases successfully",
Data: dto.ToPhasesListDTO(*result),
})
}
func (u *PhasesController) UpdateOne(c *fiber.Ctx) error {
req := new(validation.Update)
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
result, err := u.PhasesService.UpdateOne(c, req, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Update phases successfully",
Data: dto.ToPhasesListDTO(*result),
})
}
func (u *PhasesController) DeleteOne(c *fiber.Ctx) error {
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
if err := u.PhasesService.DeleteOne(c, uint(id)); err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Common{
Code: fiber.StatusOK,
Status: "success",
Message: "Delete phases successfully",
})
}
@@ -0,0 +1,68 @@
package dto
import (
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
)
// === DTO Structs ===
type PhasesRelationDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
}
type PhasesListDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
Category string `json:"category"`
IsActive bool `json:"is_active"`
CreatedUser *userDTO.UserRelationDTO `json:"created_user"`
CreatedAt time.Time `json:"created_at"`
}
type PhasesDetailDTO struct {
PhasesListDTO
}
// === Mapper Functions ===
func ToPhasesRelationDTO(e entity.Phases) PhasesRelationDTO {
return PhasesRelationDTO{
Id: e.Id,
Name: e.Name,
}
}
func ToPhasesListDTO(e entity.Phases) PhasesListDTO {
var createdUser *userDTO.UserRelationDTO
// if e.CreatedUser.Id != 0 {
// mapped := userDTO.ToUserRelationDTO(e.CreatedUser)
// createdUser = &mapped
// }
return PhasesListDTO{
Id: e.Id,
Name: e.Name,
Category: e.Category,
IsActive: e.IsActive,
CreatedAt: e.CreatedAt,
CreatedUser: createdUser,
}
}
func ToPhasesListDTOs(e []entity.Phases) []PhasesListDTO {
result := make([]PhasesListDTO, len(e))
for i, r := range e {
result[i] = ToPhasesListDTO(r)
}
return result
}
func ToPhasesDetailDTO(e entity.Phases) PhasesDetailDTO {
return PhasesDetailDTO{
PhasesListDTO: ToPhasesListDTO(e),
}
}
+25
View File
@@ -0,0 +1,25 @@
package phases
import (
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
rPhases "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/repositories"
sPhases "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/services"
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
)
type PhasesModule struct{}
func (PhasesModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
phasesRepo := rPhases.NewPhasesRepository(db)
userRepo := rUser.NewUserRepository(db)
phasesService := sPhases.NewPhasesService(phasesRepo, validate)
userService := sUser.NewUserService(userRepo, validate)
PhasesRoutes(router, userService, phasesService)
}
@@ -0,0 +1,21 @@
package repository
import (
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
"gorm.io/gorm"
)
type PhasesRepository interface {
repository.BaseRepository[entity.Phases]
}
type PhasesRepositoryImpl struct {
*repository.BaseRepositoryImpl[entity.Phases]
}
func NewPhasesRepository(db *gorm.DB) PhasesRepository {
return &PhasesRepositoryImpl{
BaseRepositoryImpl: repository.NewBaseRepository[entity.Phases](db),
}
}
+23
View File
@@ -0,0 +1,23 @@
package phases
import (
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/controllers"
phases "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/services"
user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
"github.com/gofiber/fiber/v2"
)
func PhasesRoutes(v1 fiber.Router, u user.UserService, s phases.PhasesService) {
ctrl := controller.NewPhasesController(s)
route := v1.Group("/phases")
route.Use(m.Auth(u))
route.Get("/", ctrl.GetAll)
route.Post("/", ctrl.CreateOne)
route.Get("/:id", ctrl.GetOne)
route.Patch("/:id", ctrl.UpdateOne)
route.Delete("/:id", ctrl.DeleteOne)
}
@@ -0,0 +1,158 @@
package service
import (
"errors"
"strings"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/validations"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
)
type PhasesService interface {
GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Phases, int64, error)
GetOne(ctx *fiber.Ctx, id uint) (*entity.Phases, error)
CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Phases, error)
UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Phases, error)
DeleteOne(ctx *fiber.Ctx, id uint) error
}
type phasesService struct {
Log *logrus.Logger
Validate *validator.Validate
Repository repository.PhasesRepository
}
func NewPhasesService(repo repository.PhasesRepository, validate *validator.Validate) PhasesService {
return &phasesService{
Log: utils.Log,
Validate: validate,
Repository: repo,
}
}
func (s phasesService) withRelations(db *gorm.DB) *gorm.DB {
return db
}
func (s phasesService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Phases, int64, error) {
if err := s.Validate.Struct(params); err != nil {
return nil, 0, err
}
offset := (params.Page - 1) * params.Limit
phasess, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db)
if params.Search != "" {
return db.Where("name LIKE ?", "%"+params.Search+"%")
}
if params.Category != nil {
db = db.Where("category = ?", *params.Category)
}
return db.Order("created_at DESC")
})
if err != nil {
s.Log.Errorf("Failed to get phasess: %+v", err)
return nil, 0, err
}
return phasess, total, nil
}
func (s phasesService) GetOne(c *fiber.Ctx, id uint) (*entity.Phases, error) {
phases, err := s.Repository.GetByID(c.Context(), id, s.withRelations)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Phases not found")
}
if err != nil {
s.Log.Errorf("Failed get phases by id: %+v", err)
return nil, err
}
return phases, nil
}
func (s *phasesService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Phases, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
if _, err := s.Repository.First(c.Context(), func(db *gorm.DB) *gorm.DB {
return db.Where("LOWER(name) = ? AND category = ?", strings.ToLower(req.Name), req.Category)
}); err == nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "phase already exists")
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Failed checking phase uniqueness: %+v", err)
return nil, err
}
createBody := &entity.Phases{
Name: req.Name,
Category: req.Category,
IsActive: true,
}
if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil {
s.Log.Errorf("Failed to create phases: %+v", err)
return nil, err
}
return s.GetOne(c, createBody.Id)
}
func (s phasesService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Phases, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
existing, err := s.Repository.GetByID(c.Context(), id, nil)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Phases not found")
}
if err != nil {
s.Log.Errorf("Failed get phases by id: %+v", err)
return nil, err
}
updateBody := make(map[string]any)
if req.Name != nil {
if _, err := s.Repository.First(c.Context(), func(db *gorm.DB) *gorm.DB {
return db.Where("LOWER(name) = ? AND category = ? AND id <> ?", strings.ToLower(*req.Name), existing.Category, id)
}); err == nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "phase already exists")
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Failed checking phase uniqueness: %+v", err)
return nil, err
}
updateBody["name"] = strings.TrimSpace(*req.Name)
}
if len(updateBody) == 0 {
return s.GetOne(c, id)
}
if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil {
s.Log.Errorf("Failed to update phases: %+v", err)
return nil, err
}
return s.GetOne(c, id)
}
func (s phasesService) 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, "Phases not found")
}
s.Log.Errorf("Failed to delete phases: %+v", err)
return err
}
return nil
}
@@ -0,0 +1,17 @@
package validation
type Create struct {
Name string `json:"name" validate:"required_strict,min=3"`
Category string `json:"category" validate:"required"`
}
type Update struct {
Name *string `json:"name,omitempty" validate:"omitempty"`
}
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"`
Search string `query:"search" validate:"omitempty,max=50"`
Category *string `query:"category" validate:"omitempty"`
}
+7 -1
View File
@@ -10,17 +10,20 @@ import (
areas "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas"
banks "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks"
customers "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers"
employeess "gitlab.com/mbugroup/lti-api.git/internal/modules/master/employees"
fcrs "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs"
flocks "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks"
kandangs "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs"
locations "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations"
nonstocks "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks"
phaseActivitys "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phase-activities"
phasess "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess"
productcategories "gitlab.com/mbugroup/lti-api.git/internal/modules/master/product-categories"
productionStandards "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards"
products "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products"
suppliers "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers"
uoms "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms"
warehouses "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses"
productionStandards "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards"
// MODULE IMPORTS
)
@@ -42,6 +45,9 @@ func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Valida
banks.BankModule{},
flocks.FlockModule{},
productionStandards.ProductionStandardModule{},
employeess.EmployeesModule{},
phasess.PhasesModule{},
phaseActivitys.PhaseActivityModule{},
// MODULE REGISTRY
}
@@ -22,6 +22,7 @@ import (
pfutils "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/utils"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations"
recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
uniformityRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/repositories"
utils "gitlab.com/mbugroup/lti-api.git/internal/utils"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
@@ -516,27 +517,6 @@ func (s projectflockService) GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID u
return total, nil
}
// getProjectFlockClosingDate mengembalikan tanggal closing Project Flock jika sudah mencapai step SELESAI (Approved).
// func (s projectflockService) getProjectFlockClosingDate(ctx context.Context, projectFlockID uint) (*time.Time, error) {
// if projectFlockID == 0 || s.ApprovalSvc == nil {
// return nil, nil
// }
// latest, err := s.ApprovalSvc.LatestByTarget(ctx, utils.ApprovalWorkflowProjectFlock, projectFlockID, nil)
// if err != nil {
// return nil, err
// }
// if latest == nil || latest.Action == nil || *latest.Action != entity.ApprovalActionApproved {
// return nil, nil
// }
// if latest.StepNumber != uint16(utils.ProjectFlockStepSelesai) {
// return nil, nil
// }
// t := latest.ActionAt
// return &t, nil
// }
func (s projectflockService) GetProjectPeriods(c *fiber.Ctx, projectIDs []uint) (map[uint]int, error) {
if len(projectIDs) == 0 {
return map[uint]int{}, nil
@@ -866,6 +846,14 @@ func (s projectflockService) detachKandangs(ctx context.Context, dbTransaction *
}
if len(pfkIDs) > 0 {
uniformityRepo := uniformityRepository.NewUniformityRepository(s.Repository.DB())
if dbTransaction != nil {
uniformityRepo = uniformityRepository.NewUniformityRepository(dbTransaction)
}
if err := uniformityRepo.DeleteByProjectFlockKandangIDs(ctx, pfkIDs); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to remove uniformity data for project flock kandang")
}
pwRepo := s.ProductWarehouseRepo
if dbTransaction != nil {
pwRepo = productWarehouseRepository.NewProductWarehouseRepository(dbTransaction)
@@ -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 {
@@ -20,9 +20,6 @@ type RecordingRepository interface {
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)
@@ -42,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)
@@ -67,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").
@@ -114,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
@@ -293,35 +279,33 @@ 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) {
var rows []struct {
UsageQty float64
TotalQty float64
UomName string
}
if err := tx.
Table("recording_stocks").
Select("COALESCE(recording_stocks.usage_qty, 0) AS usage_qty, LOWER(uoms.name) AS uom_name").
Select("COALESCE(recording_stocks.usage_qty, 0) + COALESCE(recording_stocks.pending_qty, 0) AS total_qty, LOWER(uoms.name) AS uom_name").
Joins("JOIN product_warehouses ON product_warehouses.id = recording_stocks.product_warehouse_id").
Joins("JOIN products ON products.id = product_warehouses.product_id").
Joins("JOIN uoms ON uoms.id = products.uom_id").
Joins("JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = ? AND UPPER(flags.name) = ?", entity.FlagableTypeProduct, "PAKAN").
Where("recording_stocks.recording_id = ?", recordingID).
Scan(&rows).Error; err != nil {
return 0, err
@@ -329,37 +313,63 @@ func (r *RecordingRepositoryImpl) GetFeedUsageInGrams(tx *gorm.DB, recordingID u
var total float64
for _, row := range rows {
if row.UsageQty <= 0 {
if row.TotalQty <= 0 {
continue
}
switch strings.TrimSpace(row.UomName) {
case "kilogram", "kg", "kilograms", "kilo":
total += row.UsageQty * 1000
total += row.TotalQty * 1000
case "gram", "g", "grams":
total += row.UsageQty
total += row.TotalQty
default:
total += row.UsageQty
total += row.TotalQty
}
}
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
}
@@ -382,49 +392,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) {
@@ -440,16 +413,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
}
@@ -222,7 +229,13 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
CreatedBy: actorID,
}
if err := s.Repository.CreateOne(ctx, &createdRecording, func(*gorm.DB) *gorm.DB { return tx }); err != nil {
createTx := tx.WithContext(ctx).Select(
"ProjectFlockKandangId",
"RecordDatetime",
"Day",
"CreatedBy",
)
if err := createTx.Create(&createdRecording).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return fiber.NewError(
fiber.StatusBadRequest,
@@ -233,18 +246,14 @@ 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)
stockDesired := resetStockQuantitiesForFIFO(mappedStocks, s.FifoSvc != nil)
if err := s.Repository.CreateStocks(tx, mappedStocks); err != nil {
s.Log.Errorf("Failed to persist stocks: %+v", err)
return err
}
applyStockDesiredQuantities(mappedStocks, stockDesired, s.FifoSvc != nil)
if err := s.consumeRecordingStocks(ctx, tx, mappedStocks); err != nil {
return err
}
@@ -261,7 +270,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,16 +300,18 @@ 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)
}
ctx := c.Context()
var recordingEntity *entity.Recording
var updatedRecording *entity.Recording
transactionErr := s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
recording, err := s.Repository.GetByID(ctx, id, func(db *gorm.DB) *gorm.DB {
return s.Repository.WithRelations(tx)
repoTx := s.Repository.WithTx(tx)
recording, err := repoTx.GetByID(ctx, id, func(db *gorm.DB) *gorm.DB {
return s.Repository.WithRelations(db)
})
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -311,12 +322,47 @@ 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 {
var existingStocks []entity.RecordingStock
if hasStockChanges {
existingStocks, err = s.Repository.ListStocks(tx, recordingEntity.Id)
if err != nil {
s.Log.Errorf("Failed to list existing stocks: %+v", err)
return err
}
if stocksMatch(existingStocks, req.Stocks) {
hasStockChanges = false
}
}
var existingDepletions []entity.RecordingDepletion
if hasDepletionChanges {
existingDepletions, err = s.Repository.ListDepletions(tx, recordingEntity.Id)
if err != nil {
s.Log.Errorf("Failed to list existing depletions: %+v", err)
return err
}
if depletionsMatch(existingDepletions, req.Depletions) {
hasDepletionChanges = false
}
}
var existingEggs []entity.RecordingEgg
if hasEggChanges {
existingEggs, err = s.Repository.ListEggs(tx, recordingEntity.Id)
if err != nil {
s.Log.Errorf("Failed to list existing eggs: %+v", err)
return err
}
if eggsMatch(existingEggs, req.Eggs) {
hasEggChanges = false
}
}
if !hasStockChanges && !hasDepletionChanges && !hasEggChanges {
return nil
}
@@ -346,51 +392,13 @@ 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 {
s.Log.Errorf("Failed to list existing stocks: %+v", err)
return err
}
if err := s.releaseRecordingStocks(ctx, tx, existingStocks); err != nil {
return err
}
if err := s.Repository.DeleteStocks(tx, recordingEntity.Id); err != nil {
s.Log.Errorf("Failed to clear stocks: %+v", err)
return err
}
mappedStocks := recordingutil.MapStocks(recordingEntity.Id, req.Stocks)
if err := s.Repository.CreateStocks(tx, mappedStocks); err != nil {
s.Log.Errorf("Failed to update stocks: %+v", err)
return err
}
if err := s.consumeRecordingStocks(ctx, tx, mappedStocks); err != nil {
if err := s.syncRecordingStocks(ctx, tx, recordingEntity.Id, existingStocks, req.Stocks); err != nil {
return err
}
}
if hasDepletionChanges {
existingDepletions, err := s.Repository.ListDepletions(tx, recordingEntity.Id)
if err != nil {
s.Log.Errorf("Failed to list existing depletions: %+v", err)
return err
}
if err := s.Repository.DeleteDepletions(tx, recordingEntity.Id); err != nil {
s.Log.Errorf("Failed to clear depletions: %+v", err)
return err
@@ -402,19 +410,13 @@ 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
}
}
if hasEggChanges {
existingEggs, err := s.Repository.ListEggs(tx, recordingEntity.Id)
if err != nil {
s.Log.Errorf("Failed to list existing eggs: %+v", err)
return err
}
if err := s.Repository.DeleteEggs(tx, recordingEntity.Id); err != nil {
s.Log.Errorf("Failed to clear eggs: %+v", err)
return err
@@ -426,13 +428,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 || hasEggChanges {
if err := s.computeAndUpdateMetrics(ctx, tx, recordingEntity); err != nil {
s.Log.Errorf("Failed to recompute recording metrics: %+v", err)
return err
@@ -481,13 +483,31 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
}
}
updated, err := repoTx.GetByID(ctx, recordingEntity.Id, func(db *gorm.DB) *gorm.DB {
return s.Repository.WithRelations(db)
})
if err != nil {
s.Log.Errorf("Failed to reload recording %d after update: %+v", recordingEntity.Id, err)
return err
}
updatedRecording = updated
return nil
})
if transactionErr != nil {
return nil, transactionErr
}
return s.GetOne(c, id)
if updatedRecording == nil {
return s.GetOne(c, id)
}
if err := s.attachLatestApproval(ctx, updatedRecording); err != nil {
return nil, err
}
if err := s.attachProductionStandard(ctx, updatedRecording); err != nil {
return nil, err
}
return updatedRecording, nil
}
func (s recordingService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entity.Recording, error) {
@@ -596,7 +616,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
}
@@ -665,12 +685,17 @@ func (s *recordingService) consumeRecordingStocks(ctx context.Context, tx *gorm.
if stock.UsageQty != nil {
desired = *stock.UsageQty
}
var pending float64
if stock.PendingQty != nil {
pending = *stock.PendingQty
}
desiredTotal := desired + pending
result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{
UsableKey: recordingStockUsableKey,
UsableID: stock.Id,
ProductWarehouseID: stock.ProductWarehouseId,
Quantity: desired,
Quantity: desiredTotal,
AllowPending: true,
Tx: tx,
})
@@ -724,7 +749,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)
@@ -757,6 +781,288 @@ func (s *recordingService) adjustProductWarehouseQuantities(ctx context.Context,
return s.ProductWarehouseRepo.AdjustQuantities(ctx, deltas, func(*gorm.DB) *gorm.DB { return tx })
}
type desiredStock struct {
Usage float64
Pending float64
}
func resetStockQuantitiesForFIFO(stocks []entity.RecordingStock, enabled bool) []desiredStock {
desired := make([]desiredStock, len(stocks))
for i := range stocks {
if stocks[i].UsageQty != nil {
desired[i].Usage = *stocks[i].UsageQty
}
if stocks[i].PendingQty != nil {
desired[i].Pending = *stocks[i].PendingQty
}
if !enabled {
continue
}
zero := 0.0
stocks[i].UsageQty = &zero
stocks[i].PendingQty = &zero
}
return desired
}
func applyStockDesiredQuantities(stocks []entity.RecordingStock, desired []desiredStock, enabled bool) {
if !enabled {
return
}
for i := range stocks {
if i >= len(desired) {
break
}
usage := desired[i].Usage
pending := desired[i].Pending
stocks[i].UsageQty = &usage
stocks[i].PendingQty = &pending
}
}
func (s *recordingService) syncRecordingStocks(
ctx context.Context,
tx *gorm.DB,
recordingID uint,
existing []entity.RecordingStock,
incoming []validation.Stock,
) error {
if s.FifoSvc == nil {
if err := s.Repository.DeleteStocks(tx, recordingID); err != nil {
return err
}
mapped := recordingutil.MapStocks(recordingID, incoming)
return s.Repository.CreateStocks(tx, mapped)
}
existingByWarehouse := make(map[uint][]entity.RecordingStock)
for _, stock := range existing {
existingByWarehouse[stock.ProductWarehouseId] = append(existingByWarehouse[stock.ProductWarehouseId], stock)
}
stocksToConsume := make([]entity.RecordingStock, 0, len(incoming))
for _, item := range incoming {
list := existingByWarehouse[item.ProductWarehouseId]
var stock entity.RecordingStock
if len(list) > 0 {
stock = list[0]
existingByWarehouse[item.ProductWarehouseId] = list[1:]
} else {
zero := 0.0
stock = entity.RecordingStock{
RecordingId: recordingID,
ProductWarehouseId: item.ProductWarehouseId,
UsageQty: &zero,
PendingQty: &zero,
}
if err := tx.Create(&stock).Error; err != nil {
return err
}
}
desired := item.Qty
stock.UsageQty = &desired
if item.PendingQty != nil {
pending := *item.PendingQty
stock.PendingQty = &pending
}
stocksToConsume = append(stocksToConsume, stock)
}
var leftovers []entity.RecordingStock
for _, list := range existingByWarehouse {
leftovers = append(leftovers, list...)
}
if len(leftovers) > 0 {
if err := s.releaseRecordingStocks(ctx, tx, leftovers); err != nil {
return err
}
ids := make([]uint, 0, len(leftovers))
for _, stock := range leftovers {
if stock.Id != 0 {
ids = append(ids, stock.Id)
}
}
if len(ids) > 0 {
if err := tx.Where("id IN ?", ids).Delete(&entity.RecordingStock{}).Error; err != nil {
return err
}
}
}
if len(stocksToConsume) == 0 {
return nil
}
return s.consumeRecordingStocks(ctx, tx, stocksToConsume)
}
type eggTotals struct {
Qty int
Weight float64
}
type stockTotals struct {
Usage float64
Pending float64
Total float64
}
func summarizeExistingStocks(stocks []entity.RecordingStock) map[uint]stockTotals {
totals := make(map[uint]stockTotals)
for _, stock := range stocks {
var usage float64
var pending float64
if stock.UsageQty != nil {
usage = *stock.UsageQty
}
if stock.PendingQty != nil {
pending = *stock.PendingQty
}
current := totals[stock.ProductWarehouseId]
current.Usage += usage
current.Pending += pending
current.Total += usage + pending
totals[stock.ProductWarehouseId] = current
}
return totals
}
func summarizeIncomingStocks(stocks []validation.Stock) map[uint]stockTotals {
totals := make(map[uint]stockTotals)
for _, stock := range stocks {
var pending float64
if stock.PendingQty != nil {
pending = *stock.PendingQty
}
current := totals[stock.ProductWarehouseId]
current.Usage += stock.Qty
current.Pending += pending
current.Total += stock.Qty + pending
totals[stock.ProductWarehouseId] = current
}
return totals
}
func stocksMatch(existing []entity.RecordingStock, incoming []validation.Stock) bool {
hasPending := false
for _, item := range incoming {
if item.PendingQty != nil {
hasPending = true
break
}
}
existingUsage := make(map[uint]float64)
existingTotal := make(map[uint]float64)
for _, stock := range existing {
var usage float64
var pending float64
if stock.UsageQty != nil {
usage = *stock.UsageQty
}
if stock.PendingQty != nil {
pending = *stock.PendingQty
}
existingUsage[stock.ProductWarehouseId] += usage
existingTotal[stock.ProductWarehouseId] += usage + pending
}
incomingUsage := make(map[uint]float64)
incomingTotal := make(map[uint]float64)
for _, item := range incoming {
var pending float64
if item.PendingQty != nil {
pending = *item.PendingQty
}
incomingUsage[item.ProductWarehouseId] += item.Qty
incomingTotal[item.ProductWarehouseId] += item.Qty + pending
}
if hasPending {
return floatMapsMatch(existingTotal, incomingTotal)
}
return floatMapsMatch(existingUsage, incomingUsage)
}
func depletionsMatch(existing []entity.RecordingDepletion, incoming []validation.Depletion) bool {
existingTotals := make(map[uint]float64)
for _, dep := range existing {
existingTotals[dep.ProductWarehouseId] += dep.Qty
}
incomingTotals := make(map[uint]float64)
for _, dep := range incoming {
incomingTotals[dep.ProductWarehouseId] += dep.Qty
}
return floatMapsMatch(existingTotals, incomingTotals)
}
func eggsMatch(existing []entity.RecordingEgg, incoming []validation.Egg) bool {
existingTotals := make(map[uint]eggTotals)
for _, egg := range existing {
weight := 0.0
if egg.Weight != nil {
weight = *egg.Weight
}
current := existingTotals[egg.ProductWarehouseId]
current.Qty += egg.Qty
current.Weight += float64(egg.Qty) * weight
existingTotals[egg.ProductWarehouseId] = current
}
incomingTotals := make(map[uint]eggTotals)
for _, egg := range incoming {
weight := 0.0
if egg.Weight != nil {
weight = *egg.Weight
}
current := incomingTotals[egg.ProductWarehouseId]
current.Qty += egg.Qty
current.Weight += float64(egg.Qty) * weight
incomingTotals[egg.ProductWarehouseId] = current
}
if len(existingTotals) != len(incomingTotals) {
return false
}
for key, existingTotal := range existingTotals {
incomingTotal, ok := incomingTotals[key]
if !ok {
return false
}
if existingTotal.Qty != incomingTotal.Qty {
return false
}
if !floatNearlyEqual(existingTotal.Weight, incomingTotal.Weight) {
return false
}
}
return true
}
func floatMapsMatch(a, b map[uint]float64) bool {
if len(a) != len(b) {
return false
}
for key, value := range a {
other, ok := b[key]
if !ok {
return false
}
if !floatNearlyEqual(value, other) {
return false
}
}
return true
}
func floatNearlyEqual(a, b float64) bool {
return math.Abs(a-b) <= 0.000001
}
func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm.DB, recording *entity.Recording) error {
day := 0
if recording.Day != nil {
@@ -775,7 +1081,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 +1088,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 +1095,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 +1146,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 +1228,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 +1333,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"`
@@ -36,6 +36,10 @@ func (u *UniformityController) GetAll(c *fiber.Ctx) error {
if err != nil {
return err
}
documents, err := u.UniformityService.MapDocuments(c, result)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.SuccessWithPaginate[dto.UniformityListDTO]{
@@ -53,7 +57,7 @@ func (u *UniformityController) GetAll(c *fiber.Ctx) error {
"status": "Pengajuan",
},
},
Data: dto.ToUniformityListDTOsWithStandard(result, standards),
Data: dto.ToUniformityListDTOsWithStandard(result, standards, documents),
})
}
@@ -71,13 +75,14 @@ func (u *UniformityController) GetOne(c *fiber.Ctx) error {
withDetails := c.QueryBool("with_details", false)
calculation := service.UniformityCalculation{}
var document *entity.Document
var documentURL string
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)
calculation, document, documentURL, err = u.UniformityService.CalculateUniformityFromDocument(c, id)
if err != nil {
return err
}
@@ -92,6 +97,10 @@ func (u *UniformityController) GetOne(c *fiber.Ctx) error {
Uniformity: result.Uniformity,
Cv: result.Cv,
}
document, documentURL, err = u.UniformityService.GetDocumentInfo(c, id)
if err != nil {
return err
}
}
standard, err := u.UniformityService.GetStandard(c, result)
@@ -111,7 +120,7 @@ func (u *UniformityController) GetOne(c *fiber.Ctx) error {
Code: fiber.StatusOK,
Status: "success",
Message: "Get production uniformity successfully",
Data: dto.ToUniformityDetailDTO(*result, calculation, document, standardDTO),
Data: dto.ToUniformityDetailDTO(*result, calculation, document, documentURL, standardDTO),
})
}
@@ -154,7 +163,7 @@ func (u *UniformityController) CreateOne(c *fiber.Ctx) error {
Code: fiber.StatusCreated,
Status: "success",
Message: "Create uniformity successfully",
Data: dto.ToUniformityDetailDTO(*result, calculation, document, standardDTO),
Data: dto.ToUniformityDetailDTO(*result, calculation, document, "", standardDTO),
})
}
@@ -237,7 +246,7 @@ func (u *UniformityController) UpdateOne(c *fiber.Ctx) error {
Code: fiber.StatusOK,
Status: "success",
Message: "Update uniformity successfully",
Data: dto.ToUniformityDetailDTO(*result, calculation, document, standardDTO),
Data: dto.ToUniformityDetailDTO(*result, calculation, document, "", standardDTO),
})
}
@@ -45,6 +45,7 @@ type UniformityInfoDTO struct {
ProjectFlock string `json:"project_flock"`
Kandang string `json:"kandang"`
FileName string `json:"file_name"`
FileURL string `json:"file_url"`
}
type UniformityDetailDTO struct {
@@ -53,6 +54,7 @@ type UniformityDetailDTO struct {
Sampling UniformitySamplingDTO `json:"sampling"`
Result UniformityResultDTO `json:"result"`
Standard *UniformityStandardDTO `json:"standard"`
LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"`
UniformityDetails []UniformityDetailItemDTO `json:"uniformity_details"`
}
@@ -62,6 +64,7 @@ type UniformityListDTO struct {
LocationName string `json:"location_name"`
FlockName string `json:"flock_name"`
KandangName string `json:"kandang_name"`
FileName string `json:"file_name"`
AppliedAt *time.Time `json:"applied_at"`
Week int `json:"week"`
Status string `json:"status"`
@@ -73,7 +76,6 @@ type UniformityListDTO struct {
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"`
}
@@ -97,6 +99,7 @@ func ToUniformityDetailDTO(
entityData entity.ProjectFlockKandangUniformity,
calc service.UniformityCalculation,
document *entity.Document,
documentURL string,
standard *UniformityStandardDTO,
) UniformityDetailDTO {
info := UniformityInfoDTO{
@@ -105,10 +108,20 @@ func ToUniformityDetailDTO(
ProjectFlock: resolveProjectFlockName(entityData.ProjectFlockKandang),
Kandang: resolveKandangName(entityData.ProjectFlockKandang),
FileName: "",
FileURL: "",
}
if document != nil {
info.FileName = document.Name
}
if documentURL != "" {
info.FileURL = documentURL
}
var latestApproval *approvalDTO.ApprovalRelationDTO
if entityData.LatestApproval != nil {
mapped := approvalDTO.ToApprovalDTO(*entityData.LatestApproval)
latestApproval = &mapped
}
return UniformityDetailDTO{
Id: entityData.Id,
@@ -116,6 +129,7 @@ func ToUniformityDetailDTO(
Sampling: toUniformitySamplingDTO(calc),
Result: toUniformityResultDTO(calc),
Standard: standard,
LatestApproval: latestApproval,
UniformityDetails: toUniformityDetailItemsDTO(calc),
}
}
@@ -148,7 +162,6 @@ func ToUniformityListDTOs(items []entity.ProjectFlockKandangUniformity) []Unifor
UniformQty: item.UniformQty,
MeanUp: item.MeanUp,
MeanDown: item.MeanDown,
CreatedAt: item.CreatedAt,
CreatedBy: item.CreatedBy,
LatestApproval: latestApproval,
}
@@ -159,9 +172,15 @@ func ToUniformityListDTOs(items []entity.ProjectFlockKandangUniformity) []Unifor
func ToUniformityListDTOsWithStandard(
items []entity.ProjectFlockKandangUniformity,
standards map[uint]service.UniformityStandard,
documentNames map[uint]string,
) []UniformityListDTO {
result := ToUniformityListDTOs(items)
if len(result) == 0 || len(standards) == 0 {
for i := range result {
if name, ok := documentNames[result[i].Id]; ok {
result[i].FileName = name
}
}
return result
}
@@ -170,6 +189,9 @@ func ToUniformityListDTOsWithStandard(
result[i].StandardMeanWeight = std.MeanWeight
result[i].StandardUniformity = std.Uniformity
}
if name, ok := documentNames[result[i].Id]; ok {
result[i].FileName = name
}
}
return result
}
@@ -1,6 +1,8 @@
package repository
import (
"context"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
@@ -8,6 +10,7 @@ import (
type UniformityRepository interface {
repository.BaseRepository[entity.ProjectFlockKandangUniformity]
DeleteByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) error
}
type UniformityRepositoryImpl struct {
@@ -19,3 +22,13 @@ func NewUniformityRepository(db *gorm.DB) UniformityRepository {
BaseRepositoryImpl: repository.NewBaseRepository[entity.ProjectFlockKandangUniformity](db),
}
}
func (r *UniformityRepositoryImpl) DeleteByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) error {
if len(projectFlockKandangIDs) == 0 {
return nil
}
return r.DB().WithContext(ctx).
Unscoped().
Where("project_flock_kandang_id IN ?", projectFlockKandangIDs).
Delete(&entity.ProjectFlockKandangUniformity{}).Error
}
@@ -33,13 +33,15 @@ type UniformityService interface {
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)
MapDocuments(ctx *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]string, 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)
GetDocumentInfo(ctx *fiber.Ctx, uniformityID uint) (*entity.Document, string, error)
CalculateUniformityFromDocument(ctx *fiber.Ctx, uniformityID uint) (UniformityCalculation, *entity.Document, string, error)
}
type uniformityService struct {
@@ -98,7 +100,7 @@ func (s uniformityService) GetAll(c *fiber.Ctx, params *validation.Query) ([]ent
if params.Week != 0 {
db = db.Where("week = ?", params.Week)
}
return db.Order("uniform_date DESC").Order("created_at DESC")
return db.Order("uniform_date DESC").Order("id DESC")
})
if err != nil {
@@ -188,6 +190,29 @@ func (s uniformityService) MapStandards(c *fiber.Ctx, items []entity.ProjectFloc
return result, nil
}
func (s uniformityService) MapDocuments(c *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]string, error) {
if s.DocumentSvc == nil || len(items) == 0 {
return map[uint]string{}, nil
}
result := make(map[uint]string, len(items))
for _, item := range items {
if item.Id == 0 {
continue
}
documents, err := s.DocumentSvc.ListByTarget(c.Context(), "UNIFORMITY", uint64(item.Id))
if err != nil {
return nil, err
}
if len(documents) == 0 {
continue
}
result[item.Id] = documents[len(documents)-1].Name
}
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
@@ -592,50 +617,72 @@ func (s uniformityService) ComputeUniformity(rows []BodyWeightExcelRow) (Uniform
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")
}
func (s uniformityService) GetDocumentInfo(c *fiber.Ctx, uniformityID uint) (*entity.Document, string, error) {
return s.fetchUniformityDocument(c.Context(), uniformityID, true)
}
documents, err := s.DocumentSvc.ListByTarget(c.Context(), "UNIFORMITY", uint64(uniformityID))
func (s uniformityService) CalculateUniformityFromDocument(c *fiber.Ctx, uniformityID uint) (UniformityCalculation, *entity.Document, string, error) {
document, url, err := s.fetchUniformityDocument(c.Context(), uniformityID, false)
if err != nil {
return UniformityCalculation{}, nil, err
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")
if document == nil || url == "" {
return UniformityCalculation{}, nil, "", fiber.NewError(fiber.StatusNotFound, "Uniformity document not found")
}
req, err := http.NewRequestWithContext(c.Context(), http.MethodGet, url, nil)
if err != nil {
return UniformityCalculation{}, nil, err
return UniformityCalculation{}, nil, "", err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return UniformityCalculation{}, nil, err
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")
return UniformityCalculation{}, nil, "", fiber.NewError(fiber.StatusBadRequest, "Failed to download uniformity document")
}
rows, err := parseBodyWeightExcelReader(resp.Body)
if err != nil {
return UniformityCalculation{}, nil, err
return UniformityCalculation{}, nil, "", err
}
calculation, err := computeUniformity(rows)
if err != nil {
return UniformityCalculation{}, nil, err
return UniformityCalculation{}, nil, "", err
}
return calculation, &document, nil
return calculation, document, url, nil
}
func (s uniformityService) fetchUniformityDocument(ctx context.Context, uniformityID uint, allowMissing bool) (*entity.Document, string, error) {
if s.DocumentSvc == nil {
return nil, "", fiber.NewError(fiber.StatusInternalServerError, "Document service not available")
}
documents, err := s.DocumentSvc.ListByTarget(ctx, "UNIFORMITY", uint64(uniformityID))
if err != nil {
return nil, "", err
}
if len(documents) == 0 {
if allowMissing {
return nil, "", nil
}
return nil, "", fiber.NewError(fiber.StatusNotFound, "Uniformity document not found")
}
document := documents[len(documents)-1]
url, err := s.DocumentSvc.PresignURL(ctx, document, 15*time.Minute)
if err != nil {
return nil, "", err
}
if url == "" {
return nil, "", fiber.NewError(fiber.StatusBadRequest, "Uniformity document URL not available")
}
return &document, url, nil
}
func (s *uniformityService) createUniformityApproval(
@@ -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,29 @@ 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["travel_documents"]
if len(req.TravelDocuments) == 0 {
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,54 @@ 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")
}
documents, err := s.DocumentSvc.ListByTarget(ctx, string(utils.DocumentableTypePurchaseItem), uint64(itemID))
if err != nil {
return "", err
}
if len(documents) > 0 {
var ids []uint
for _, doc := range documents {
if doc.Type == string(utils.DocumentTypePurchaseTravel) {
ids = append(ids, doc.Id)
}
}
if err := s.DocumentSvc.DeleteDocuments(ctx, ids, true); err != nil {
return "", err
}
}
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].Document.Path, 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 +1113,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 +1176,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 +1263,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,
@@ -1427,10 +1515,56 @@ func (s *purchaseService) loadPurchase(
if err := s.attachLatestApproval(ctx, purchase); err != nil {
s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", id, err)
}
s.applyTravelDocumentURLs(ctx, purchase)
return purchase, nil
}
func (s *purchaseService) applyTravelDocumentURLs(ctx context.Context, purchase *entity.Purchase) {
if purchase == nil || s.DocumentSvc == nil {
return
}
for i := range purchase.Items {
item := &purchase.Items[i]
documents, err := s.DocumentSvc.ListByTarget(ctx, string(utils.DocumentableTypePurchaseItem), uint64(item.Id))
if err != nil {
s.Log.Warnf("Unable to load travel documents for purchase item %d: %+v", item.Id, err)
} else {
var targetDoc *entity.Document
for j := len(documents) - 1; j >= 0; j-- {
if documents[j].Type == string(utils.DocumentTypePurchaseTravel) {
targetDoc = &documents[j]
break
}
}
if targetDoc != nil {
url, err := s.DocumentSvc.PresignURL(ctx, *targetDoc, 15*time.Minute)
if err != nil {
s.Log.Warnf("Unable to presign travel document for purchase item %d: %+v", item.Id, err)
} else if url != "" {
item.TravelNumberDocs = &url
continue
}
}
}
path := item.TravelNumberDocs
if path == nil || strings.TrimSpace(*path) == "" {
continue
}
url, err := commonSvc.ResolveDocumentURL(ctx, s.DocumentSvc, *path, 15*time.Minute)
if err != nil {
s.Log.Warnf("Unable to presign travel document for purchase item %d: %+v", item.Id, err)
continue
}
if url == "" {
continue
}
item.TravelNumberDocs = &url
}
}
func collectPFKIDsFromPurchase(p *entity.Purchase) []uint {
seen := make(map[uint]struct{})
ids := make([]uint, 0)
@@ -1462,5 +1596,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 {
@@ -144,6 +144,9 @@ func (h *Controller) Refresh(c *fiber.Ctx) error {
refreshName := resolveSSOCookieName(config.SSORefreshCookieName, "refresh")
refreshToken := strings.TrimSpace(c.Cookies(refreshName))
if refreshToken == "" {
if target := buildStartRedirect(defaultSSOClientAlias()); target != "" {
return c.Redirect(target, fiber.StatusFound)
}
return fiber.NewError(fiber.StatusUnauthorized, "unauthenticated")
}
@@ -171,6 +174,12 @@ func (h *Controller) Refresh(c *fiber.Ctx) error {
if resp.StatusCode >= 400 {
utils.Log.Warnf("token refresh response status %d", resp.StatusCode)
if resp.StatusCode == fiber.StatusTooManyRequests {
return fiber.NewError(fiber.StatusTooManyRequests, "Too many attempts, please slow down")
}
if target := buildStartRedirect(defaultSSOClientAlias()); target != "" {
return c.Redirect(target, fiber.StatusFound)
}
return fiber.NewError(fiber.StatusUnauthorized, "unauthenticated")
}
@@ -422,6 +431,7 @@ func (h *Controller) Logout(c *fiber.Ctx) error {
refreshName := resolveSSOCookieName(config.SSORefreshCookieName, "refresh")
var accessToken, refreshToken string
var verification *sso.VerificationResult
if accessName != "" {
accessToken = strings.TrimSpace(c.Cookies(accessName))
}
@@ -443,9 +453,10 @@ func (h *Controller) Logout(c *fiber.Ctx) error {
}
if hadAccessCookie {
if verification, err := sso.VerifyAccessToken(accessToken); err != nil {
if v, err := sso.VerifyAccessToken(accessToken); err != nil {
utils.Log.WithError(err).Warn("failed to verify access token during logout")
} else {
verification = v
if revoker := session.GetRevocationStore(); revoker != nil {
if err := revoker.MarkUserLogout(c.Context(), verification.UserID, time.Now().UTC()); err != nil {
utils.Log.WithError(err).Warn("failed to mark user logout")
@@ -472,6 +483,28 @@ func (h *Controller) Logout(c *fiber.Ctx) error {
} else if rawReturn != "" {
utils.Log.WithError(err).Warn("invalid return_to during logout")
}
} else if rawReturn == "" && config.SSOPortalURL != "" {
if alias, singleCfg, ok := singleClientFromToken(verification); ok {
if normalized, err := normalizeReturnTarget(singleCfg.DefaultReturnURI, singleCfg); err == nil && normalized != "" {
redirectTarget = normalized
alias, cfg, hasClientInfo = alias, singleCfg, true
} else {
redirectTarget = config.SSOPortalURL
}
} else if accessToken != "" {
if alias, singleCfg, ok := h.singleClientFromSSO(c.Context(), accessToken); ok {
if normalized, err := normalizeReturnTarget(singleCfg.DefaultReturnURI, singleCfg); err == nil && normalized != "" {
redirectTarget = normalized
alias, cfg, hasClientInfo = alias, singleCfg, true
} else {
redirectTarget = config.SSOPortalURL
}
} else {
redirectTarget = config.SSOPortalURL
}
} else {
redirectTarget = config.SSOPortalURL
}
} else if rawReturn != "" {
if strings.HasPrefix(rawReturn, "/") && !strings.HasPrefix(rawReturn, "//") {
redirectTarget = rawReturn
@@ -491,6 +524,177 @@ func (h *Controller) Logout(c *fiber.Ctx) error {
return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "signed out"})
}
func singleSSOClient() (string, config.SSOClientConfig, bool) {
if len(config.SSOClients) != 1 {
return "", config.SSOClientConfig{}, false
}
for alias, cfg := range config.SSOClients {
if strings.TrimSpace(alias) == "" || strings.TrimSpace(cfg.PublicID) == "" {
return "", config.SSOClientConfig{}, false
}
return alias, cfg, true
}
return "", config.SSOClientConfig{}, false
}
func singleClientFromToken(verification *sso.VerificationResult) (string, config.SSOClientConfig, bool) {
if verification == nil || verification.Claims == nil {
return "", config.SSOClientConfig{}, false
}
return singleClientFromScopes(verification.Claims.Scopes())
}
func (h *Controller) singleClientFromSSO(ctx context.Context, accessToken string) (string, config.SSOClientConfig, bool) {
accessToken = strings.TrimSpace(accessToken)
if accessToken == "" {
return "", config.SSOClientConfig{}, false
}
meURL := strings.TrimSpace(config.SSOGetMeURL)
if meURL == "" {
return "", config.SSOClientConfig{}, false
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, meURL, nil)
if err != nil {
utils.Log.WithError(err).Warn("failed to build SSO getme request")
return "", config.SSOClientConfig{}, false
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := h.httpClient.Do(req)
if err != nil {
utils.Log.WithError(err).Warn("SSO getme request failed")
return "", config.SSOClientConfig{}, false
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
utils.Log.WithField("status", resp.StatusCode).Warn("SSO getme responded with error")
return "", config.SSOClientConfig{}, false
}
var payload struct {
Data struct {
Roles []struct {
Client *struct {
Alias string `json:"alias"`
} `json:"client"`
} `json:"roles"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
utils.Log.WithError(err).Warn("failed to decode SSO getme response")
return "", config.SSOClientConfig{}, false
}
aliases := make(map[string]struct{})
for _, role := range payload.Data.Roles {
if role.Client == nil {
continue
}
alias := strings.ToLower(strings.TrimSpace(role.Client.Alias))
if alias != "" {
aliases[alias] = struct{}{}
}
}
if len(aliases) != 1 {
return "", config.SSOClientConfig{}, false
}
for alias := range aliases {
if normalized, cfg, ok := findClientAlias(alias); ok {
return normalized, cfg, true
}
return "", config.SSOClientConfig{}, false
}
return "", config.SSOClientConfig{}, false
}
func singleClientFromScopes(scopes []string) (string, config.SSOClientConfig, bool) {
if len(scopes) == 0 {
return "", config.SSOClientConfig{}, false
}
seen := make(map[string]struct{})
for _, scope := range scopes {
if alias, ok := matchClientAliasFromScope(scope); ok {
seen[alias] = struct{}{}
}
if len(seen) > 1 {
return "", config.SSOClientConfig{}, false
}
}
if len(seen) != 1 {
return "", config.SSOClientConfig{}, false
}
for alias := range seen {
if normalized, cfg, ok := findClientAlias(alias); ok {
return normalized, cfg, true
}
}
return "", config.SSOClientConfig{}, false
}
func matchClientAliasFromScope(scope string) (string, bool) {
scope = strings.ToLower(strings.TrimSpace(scope))
if scope == "" {
return "", false
}
prefix := scope
if idx := strings.IndexAny(prefix, ".:"); idx > 0 {
prefix = prefix[:idx]
}
if prefix == "" {
return "", false
}
if alias, _, ok := findClientAlias(prefix); ok {
return alias, true
}
if prefix == "user-management" {
if alias, _, ok := findClientAlias("umgmt"); ok {
return alias, true
}
}
if prefix == "umgmt" {
if alias, _, ok := findClientAlias("user-management"); ok {
return alias, true
}
}
return "", false
}
func findClientAlias(alias string) (string, config.SSOClientConfig, bool) {
alias = strings.TrimSpace(alias)
if alias == "" {
return "", config.SSOClientConfig{}, false
}
if cfg, ok := config.SSOClients[alias]; ok && strings.TrimSpace(cfg.PublicID) != "" {
return alias, cfg, true
}
for key, cfg := range config.SSOClients {
if strings.EqualFold(key, alias) && strings.TrimSpace(cfg.PublicID) != "" {
return key, cfg, true
}
}
return "", config.SSOClientConfig{}, false
}
func defaultSSOClientAlias() string {
for alias := range config.SSOClients {
if strings.TrimSpace(alias) == "" {
continue
}
return alias
}
return ""
}
func buildStartRedirect(alias string) string {
alias = strings.TrimSpace(alias)
if alias == "" {
return ""
}
return "/api/sso/start?client=" + url.QueryEscape(alias)
}
func (h *Controller) revokeToken(ctx context.Context, token string, verification *sso.VerificationResult) {
if h.revoker == nil || verification == nil || verification.Claims == nil {
return
@@ -42,7 +42,7 @@ func (r *UserRepositoryImpl) GetByIdUser(
modifier func(*gorm.DB) *gorm.DB,
) (*entity.User, error) {
return r.BaseRepositoryImpl.First(ctx, func(db *gorm.DB) *gorm.DB {
return db.Where("id_user = ?", idUser)
return db.Where("id_user::bigint = ?::bigint", idUser)
})
}
@@ -93,7 +93,7 @@ func (r *UserRepositoryImpl) UpsertByIdUser(ctx context.Context, user *entity.Us
}
func (r *UserRepositoryImpl) SoftDeleteByIdUser(ctx context.Context, idUser int64) error {
query := r.DB().WithContext(ctx).Where("id_user = ?", idUser)
query := r.DB().WithContext(ctx).Where("id_user::bigint = ?::bigint", idUser)
result := query.Delete(&entity.User{})
if result.Error != nil {
return result.Error