From dff9e73ab104e9adf4ca3ab3fefec80769e55c63 Mon Sep 17 00:00:00 2001 From: ragilap Date: Wed, 14 Jan 2026 13:27:52 +0700 Subject: [PATCH 01/37] [FIX/BE-US]add feature restrict by location and areas in roles --- cmd/api/main.go | 2 +- internal/middleware/auth.go | 2 +- internal/middleware/role_scope.go | 280 +++++++++++++++ .../services/daily-checklist.service.go | 11 +- .../controllers/dashboard.controller.go | 32 ++ .../dashboards/services/dashboard.service.go | 6 + .../expense_realization.repository.go | 21 +- .../expenses/services/expense.service.go | 17 +- .../services/product-stock.service.go | 56 ++- .../services/product_warehouse.service.go | 32 +- .../transfers/services/transfer.service.go | 36 ++ .../master/areas/services/area.service.go | 27 +- .../kandangs/services/kandang.service.go | 17 +- .../locations/services/location.service.go | 27 +- .../warehouses/services/warehouse.service.go | 17 +- .../services/project_flock_kandang.service.go | 29 +- .../repositories/projectflock.repository.go | 14 + .../projectflock_kandang.repository.go | 99 ++++++ .../services/projectflock.service.go | 18 +- .../purchases/services/purchase.service.go | 57 ++- .../controllers/repport.controller.go | 35 ++ .../purchase_supplier.repository.go | 32 +- .../repports/services/repport.service.go | 64 ++++ .../validations/repport.validation.go | 3 + .../sso/controllers/master_data.controller.go | 331 ++++++++++++++++++ .../modules/sso/controllers/sso.controller.go | 2 +- .../sso/controllers/user_sync.controller.go | 13 +- internal/modules/sso/route.go | 3 + .../{sso => modules/sso/verifier}/profile.go | 12 + .../{sso => modules/sso/verifier}/verifier.go | 0 30 files changed, 1258 insertions(+), 37 deletions(-) create mode 100644 internal/middleware/role_scope.go create mode 100644 internal/modules/sso/controllers/master_data.controller.go rename internal/{sso => modules/sso/verifier}/profile.go (95%) rename internal/{sso => modules/sso/verifier}/verifier.go (100%) diff --git a/cmd/api/main.go b/cmd/api/main.go index a7c278d7..76de7729 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -14,8 +14,8 @@ import ( "gitlab.com/mbugroup/lti-api.git/internal/database" "gitlab.com/mbugroup/lti-api.git/internal/middleware" "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session" + sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier" "gitlab.com/mbugroup/lti-api.git/internal/route" - "gitlab.com/mbugroup/lti-api.git/internal/sso" "gitlab.com/mbugroup/lti-api.git/internal/utils" "github.com/gofiber/fiber/v2" diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index a831c25b..1b670c14 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -7,8 +7,8 @@ import ( "gitlab.com/mbugroup/lti-api.git/internal/config" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session" + sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier" service "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" - "gitlab.com/mbugroup/lti-api.git/internal/sso" "gitlab.com/mbugroup/lti-api.git/internal/utils" ) diff --git a/internal/middleware/role_scope.go b/internal/middleware/role_scope.go new file mode 100644 index 00000000..f00e12d9 --- /dev/null +++ b/internal/middleware/role_scope.go @@ -0,0 +1,280 @@ +package middleware + +import ( + "errors" + "strings" + + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + "gitlab.com/mbugroup/lti-api.git/internal/config" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" +) + +type ScopeFilter struct { + IDs []uint + Restrict bool +} + +type roleScope struct { + allArea bool + allLocation bool + areaIDs []uint + locationIDs []uint + hasAnyScopes bool +} + +func ResolveAreaScope(c *fiber.Ctx, db *gorm.DB) (ScopeFilter, error) { + scope, err := collectRoleScope(c) + if err != nil || !scope.hasAnyScopes { + return ScopeFilter{}, err + } + + if scope.allArea || scope.allLocation { + return ScopeFilter{}, nil + } + + allowed := uniqueUint(scope.areaIDs) + if len(scope.locationIDs) > 0 { + derived, err := areaIDsByLocationIDs(db, scope.locationIDs) + if err != nil { + return ScopeFilter{}, err + } + allowed = uniqueUint(append(allowed, derived...)) + } + + if len(allowed) == 0 { + return ScopeFilter{Restrict: true}, nil + } + return ScopeFilter{IDs: allowed, Restrict: true}, nil +} + +func ResolveLocationScope(c *fiber.Ctx, db *gorm.DB) (ScopeFilter, error) { + scope, err := collectRoleScope(c) + if err != nil || !scope.hasAnyScopes { + return ScopeFilter{}, err + } + + if scope.allLocation || scope.allArea { + return ScopeFilter{}, nil + } + + areaIDs := uniqueUint(scope.areaIDs) + locationIDs := uniqueUint(scope.locationIDs) + + switch { + case len(locationIDs) > 0 && len(areaIDs) > 0: + filtered, err := filterLocationIDsByAreaIDs(db, locationIDs, areaIDs) + if err != nil { + return ScopeFilter{}, err + } + locationIDs = filtered + case len(locationIDs) == 0 && len(areaIDs) > 0: + derived, err := locationIDsByAreaIDs(db, areaIDs) + if err != nil { + return ScopeFilter{}, err + } + locationIDs = derived + } + + locationIDs = uniqueUint(locationIDs) + if len(locationIDs) == 0 { + return ScopeFilter{Restrict: true}, nil + } + return ScopeFilter{IDs: locationIDs, Restrict: true}, nil +} + +func collectRoleScope(c *fiber.Ctx) (roleScope, error) { + ctx, ok := AuthDetails(c) + if !ok || ctx == nil || len(ctx.Roles) == 0 { + return roleScope{}, nil + } + + clientAlias := resolveClientAlias(ctx) + + scope := roleScope{} + areaSet := make(map[uint]struct{}) + locationSet := make(map[uint]struct{}) + + for _, role := range ctx.Roles { + if clientAlias != "" && !strings.EqualFold(strings.TrimSpace(role.ClientAlias), clientAlias) { + continue + } + scope.hasAnyScopes = true + if role.AllArea { + scope.allArea = true + } + if role.AllLocation { + scope.allLocation = true + } + for _, id := range role.AreaIDs { + if id == 0 { + continue + } + areaSet[id] = struct{}{} + } + for _, id := range role.LocationIDs { + if id == 0 { + continue + } + locationSet[id] = struct{}{} + } + } + + scope.areaIDs = keysUint(areaSet) + scope.locationIDs = keysUint(locationSet) + + scope.hasAnyScopes = scope.hasAnyScopes && + (scope.allArea || scope.allLocation || len(scope.areaIDs) > 0 || len(scope.locationIDs) > 0) + + return scope, nil +} + +func areaIDsByLocationIDs(db *gorm.DB, locationIDs []uint) ([]uint, error) { + if db == nil { + return nil, errors.New("database not configured") + } + if len(locationIDs) == 0 { + return nil, nil + } + var areaIDs []uint + if err := db.Model(&entity.Location{}). + Where("deleted_at IS NULL"). + Where("id IN ?", locationIDs). + Distinct("area_id"). + Pluck("area_id", &areaIDs).Error; err != nil { + return nil, err + } + return areaIDs, nil +} + +func locationIDsByAreaIDs(db *gorm.DB, areaIDs []uint) ([]uint, error) { + if db == nil { + return nil, errors.New("database not configured") + } + if len(areaIDs) == 0 { + return nil, nil + } + var locationIDs []uint + if err := db.Model(&entity.Location{}). + Where("deleted_at IS NULL"). + Where("area_id IN ?", areaIDs). + Distinct("id"). + Pluck("id", &locationIDs).Error; err != nil { + return nil, err + } + return locationIDs, nil +} + +func filterLocationIDsByAreaIDs(db *gorm.DB, locationIDs, areaIDs []uint) ([]uint, error) { + if db == nil { + return nil, errors.New("database not configured") + } + if len(locationIDs) == 0 || len(areaIDs) == 0 { + return nil, nil + } + var filtered []uint + if err := db.Model(&entity.Location{}). + Where("deleted_at IS NULL"). + Where("id IN ?", locationIDs). + Where("area_id IN ?", areaIDs). + Distinct("id"). + Pluck("id", &filtered).Error; err != nil { + return nil, err + } + return filtered, nil +} + +func uniqueUint(ids []uint) []uint { + if len(ids) == 0 { + return nil + } + seen := make(map[uint]struct{}, len(ids)) + result := make([]uint, 0, len(ids)) + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + result = append(result, id) + } + return result +} + +func keysUint(set map[uint]struct{}) []uint { + if len(set) == 0 { + return nil + } + out := make([]uint, 0, len(set)) + for id := range set { + out = append(out, id) + } + return out +} + +func resolveClientAlias(ctx *AuthContext) string { + if ctx == nil || ctx.Verification == nil || ctx.Verification.Claims == nil { + return "" + } + + scopes := ctx.Verification.Claims.Scopes() + if len(scopes) == 0 { + return "" + } + + seen := make(map[string]struct{}) + for _, scope := range scopes { + scope = strings.ToLower(strings.TrimSpace(scope)) + if scope == "" { + continue + } + prefix := scope + if idx := strings.IndexAny(prefix, ".:"); idx > 0 { + prefix = prefix[:idx] + } + prefix = strings.TrimSpace(prefix) + if prefix == "" { + continue + } + if alias := matchAlias(prefix); alias != "" { + seen[alias] = struct{}{} + } + } + + if len(seen) != 1 { + return "" + } + for alias := range seen { + return alias + } + return "" +} + +func matchAlias(alias string) string { + alias = strings.ToLower(strings.TrimSpace(alias)) + if alias == "" { + return "" + } + if _, ok := config.SSOClients[alias]; ok { + return alias + } + for key := range config.SSOClients { + if strings.EqualFold(key, alias) { + return strings.ToLower(strings.TrimSpace(key)) + } + } + return "" +} + +func ApplyScopeFilter(db *gorm.DB, scope ScopeFilter, column string) *gorm.DB { + if db == nil || !scope.Restrict { + return db + } + if len(scope.IDs) == 0 { + return db.Where("1 = 0") + } + return db.Where(column+" IN ?", scope.IDs) +} diff --git a/internal/modules/daily-checklists/services/daily-checklist.service.go b/internal/modules/daily-checklists/services/daily-checklist.service.go index f306c74d..64802560 100644 --- a/internal/modules/daily-checklists/services/daily-checklist.service.go +++ b/internal/modules/daily-checklists/services/daily-checklist.service.go @@ -9,7 +9,7 @@ import ( "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" - middleware "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" 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" @@ -456,7 +456,7 @@ func (s dailyChecklistService) UpdateOne(c *fiber.Ctx, req *validation.Update, i updateBody["reject_reason"] = *req.RejectReason } - actorID, err := middleware.ActorIDFromContext(c) + actorID, err := m.ActorIDFromContext(c) if err != nil { return &entity.DailyChecklist{}, fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context") } @@ -946,6 +946,11 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report return nil, 0, err } + scope, err := m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, 0, err + } + offset := (params.Page - 1) * params.Limit buildBase := func() *gorm.DB { @@ -962,6 +967,8 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report Where("EXTRACT(YEAR FROM dc.date) = ?", params.Year). Where("dc.status = ?", "APPROVED") + db = m.ApplyScopeFilter(db, scope, "loc.id") + if params.AreaID != nil { db = db.Where("a.id = ?", *params.AreaID) } diff --git a/internal/modules/dashboards/controllers/dashboard.controller.go b/internal/modules/dashboards/controllers/dashboard.controller.go index bebad10f..f74f31a7 100644 --- a/internal/modules/dashboards/controllers/dashboard.controller.go +++ b/internal/modules/dashboards/controllers/dashboard.controller.go @@ -6,6 +6,7 @@ import ( "strings" "time" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/dto" service "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/services" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/validations" @@ -81,6 +82,20 @@ func (u *DashboardController) GetAll(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Invalid include") } + scope, err := m.ResolveLocationScope(c, u.DashboardService.DB()) + if err != nil { + return err + } + if scope.Restrict { + if len(scope.IDs) == 0 { + lokasiIds = []uint{} + } else if len(lokasiIds) > 0 { + lokasiIds = intersectUint(lokasiIds, scope.IDs) + } else { + lokasiIds = scope.IDs + } + } + analysisMode := strings.ToUpper(strings.TrimSpace(c.Query("analysis_mode", validation.AnalysisModeOverview))) metric := strings.ToLower(strings.TrimSpace(c.Query("metric", ""))) @@ -176,6 +191,23 @@ func defaultUintSlice(values []uint) []uint { return values } +func intersectUint(a, b []uint) []uint { + if len(a) == 0 || len(b) == 0 { + return nil + } + set := make(map[uint]struct{}, len(b)) + for _, id := range b { + set[id] = struct{}{} + } + out := make([]uint, 0, len(a)) + for _, id := range a { + if _, ok := set[id]; ok { + out = append(out, id) + } + } + return out +} + func parsePeriodDates(startDateRaw, endDateRaw string, location *time.Location) (time.Time, time.Time, time.Time, error) { now := time.Now().In(location) startDate := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, location) diff --git a/internal/modules/dashboards/services/dashboard.service.go b/internal/modules/dashboards/services/dashboard.service.go index b4635b2e..afdbd1c1 100644 --- a/internal/modules/dashboards/services/dashboard.service.go +++ b/internal/modules/dashboards/services/dashboard.service.go @@ -17,10 +17,12 @@ import ( "github.com/go-playground/validator/v10" "github.com/sirupsen/logrus" + "gorm.io/gorm" ) type DashboardService interface { GetAll(ctx context.Context, params *validation.Query) (dto.DashboardPerformanceOverviewDTO, int64, error) + DB() *gorm.DB } type dashboardService struct { @@ -37,6 +39,10 @@ func NewDashboardService(repo repository.DashboardRepository, validate *validato } } +func (s dashboardService) DB() *gorm.DB { + return s.Repository.DB() +} + func (s dashboardService) GetAll(ctx context.Context, params *validation.Query) (dto.DashboardPerformanceOverviewDTO, int64, error) { if err := s.Validate.Struct(params); err != nil { return dto.DashboardPerformanceOverviewDTO{}, 0, err diff --git a/internal/modules/expenses/repositories/expense_realization.repository.go b/internal/modules/expenses/repositories/expense_realization.repository.go index 60ec97a7..504e65ad 100644 --- a/internal/modules/expenses/repositories/expense_realization.repository.go +++ b/internal/modules/expenses/repositories/expense_realization.repository.go @@ -138,9 +138,28 @@ func (r *ExpenseRealizationRepositoryImpl) GetAllWithFilters(ctx context.Context locationID := filters.LocationId areaID := filters.AreaId - if locationID > 0 || areaID > 0 { + if filters.AllowedLocationIDs != nil || filters.AllowedAreaIDs != nil || locationID > 0 || areaID > 0 { db = db.Joins("JOIN kandangs ON kandangs.id = expense_nonstocks.kandang_id") + } + if filters.AllowedLocationIDs != nil { + if len(filters.AllowedLocationIDs) == 0 { + db = db.Where("1 = 0") + } else { + db = db.Where("kandangs.location_id IN ?", filters.AllowedLocationIDs) + } + } + + if filters.AllowedAreaIDs != nil { + if len(filters.AllowedAreaIDs) == 0 { + db = db.Where("1 = 0") + } else { + db = db.Joins("JOIN locations ON locations.id = kandangs.location_id"). + Where("locations.area_id IN ?", filters.AllowedAreaIDs) + } + } + + if locationID > 0 || areaID > 0 { if locationID > 0 { db = db.Where("kandangs.location_id = ?", uint(locationID)) } diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index 3bf2db55..27b4a07f 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -87,10 +87,16 @@ func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expens return nil, 0, err } + scope, err := middleware.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, 0, err + } + offset := (params.Page - 1) * params.Limit expenses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) + db = middleware.ApplyScopeFilter(db, scope, "location_id") if params.Search != "" { return db.Where("category ILIKE ?", "%"+params.Search+"%") } @@ -117,7 +123,16 @@ func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expens } func (s expenseService) GetOne(c *fiber.Ctx, id uint) (*expenseDto.ExpenseDetailDTO, error) { - expense, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + scope, err := middleware.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, err + } + + expense, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + db = middleware.ApplyScopeFilter(db, scope, "location_id") + return db + }) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { diff --git a/internal/modules/inventory/product-stocks/services/product-stock.service.go b/internal/modules/inventory/product-stocks/services/product-stock.service.go index 11475109..a4e404d6 100644 --- a/internal/modules/inventory/product-stocks/services/product-stock.service.go +++ b/internal/modules/inventory/product-stocks/services/product-stock.service.go @@ -4,6 +4,7 @@ import ( "errors" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-stocks/validations" productRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" @@ -61,15 +62,34 @@ func (s productStockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e return nil, 0, err } + scope, err := m.ResolveLocationScope(c, s.ProductRepository.DB()) + if err != nil { + return nil, 0, err + } + offset := (params.Page - 1) * params.Limit productStocks, total, err := s.ProductRepository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { - db = db.Where(`EXISTS ( - SELECT 1 - FROM product_warehouses pw - WHERE pw.product_id = products.id - AND pw.qty > 0 - )`) + if scope.Restrict { + if len(scope.IDs) == 0 { + return db.Where("1 = 0") + } + db = db.Where(`EXISTS ( + SELECT 1 + FROM product_warehouses pw + JOIN warehouses w ON w.id = pw.warehouse_id + WHERE pw.product_id = products.id + AND pw.qty > 0 + AND w.location_id IN ? + )`, scope.IDs) + } else { + db = db.Where(`EXISTS ( + SELECT 1 + FROM product_warehouses pw + WHERE pw.product_id = products.id + AND pw.qty > 0 + )`) + } db = s.withRelations(db) if params.Search != "" { @@ -86,6 +106,30 @@ func (s productStockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e } func (s productStockService) GetOne(c *fiber.Ctx, id uint) (*entity.Product, error) { + scope, err := m.ResolveLocationScope(c, s.ProductRepository.DB()) + if err != nil { + return nil, err + } + + if scope.Restrict { + if len(scope.IDs) == 0 { + return nil, fiber.NewError(fiber.StatusNotFound, "Product not found") + } + var count int64 + if err := s.ProductRepository.DB().WithContext(c.Context()). + Table("product_warehouses pw"). + Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). + Where("pw.product_id = ?", id). + Where("pw.qty > 0"). + Where("w.location_id IN ?", scope.IDs). + Count(&count).Error; err != nil { + return nil, err + } + if count == 0 { + return nil, fiber.NewError(fiber.StatusNotFound, "Product not found") + } + } + product, err := s.ProductRepository.GetByID(c.Context(), id, s.withRelations) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Product not found") diff --git a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go index 152bfa24..5c162084 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -8,7 +8,7 @@ import ( validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/validations" kandangrepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" - + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "github.com/sirupsen/logrus" @@ -53,6 +53,11 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) return nil, 0, err } + scope, err := m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, 0, err + } + if params.ProductId > 0 { isProductExist, err := s.Repository.IsProductExist(c.Context(), params.ProductId) if err != nil { @@ -90,6 +95,14 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) productWarehouses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) + if scope.Restrict { + if len(scope.IDs) == 0 { + return db.Where("1 = 0") + } + db = db.Joins("JOIN warehouses w_scope ON product_warehouses.warehouse_id = w_scope.id"). + Where("w_scope.location_id IN ?", scope.IDs) + } + if params.ProductId != 0 { db = db.Where("product_id = ?", params.ProductId) } @@ -116,7 +129,22 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) } func (s productWarehouseService) GetOne(c *fiber.Ctx, id uint) (*entity.ProductWarehouse, error) { - productWarehouse, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + scope, err := m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, err + } + + productWarehouse, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + if scope.Restrict { + if len(scope.IDs) == 0 { + return db.Where("1 = 0") + } + db = db.Joins("JOIN warehouses w_scope ON product_warehouses.warehouse_id = w_scope.id"). + Where("w_scope.location_id IN ?", scope.IDs) + } + return db + }) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "ProductWarehouse not found") } diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index 3f12b444..b4652a5d 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -94,10 +94,24 @@ func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit return nil, 0, err } + scope, err := m.ResolveLocationScope(c, s.StockTransferRepo.DB()) + if err != nil { + return nil, 0, err + } + offset := (params.Page - 1) * params.Limit transfers, total, err := s.StockTransferRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) + if scope.Restrict { + if len(scope.IDs) == 0 { + return db.Where("1 = 0") + } + db = db. + Joins("JOIN warehouses w_from ON w_from.id = stock_transfers.from_warehouse_id"). + Joins("JOIN warehouses w_to ON w_to.id = stock_transfers.to_warehouse_id"). + Where("w_from.location_id IN ? OR w_to.location_id IN ?", scope.IDs, scope.IDs) + } if params.Search != "" { db = db.Where("movement_number ILIKE ?", "%"+strings.TrimSpace(params.Search)+"%") } @@ -112,6 +126,28 @@ func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit } func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, error) { + scope, err := m.ResolveLocationScope(c, s.StockTransferRepo.DB()) + if err != nil { + return nil, err + } + if scope.Restrict { + if len(scope.IDs) == 0 { + return nil, fiber.NewError(fiber.StatusNotFound, "Transfer not found") + } + var count int64 + if err := s.StockTransferRepo.DB().WithContext(c.Context()). + Table("stock_transfers"). + Joins("JOIN warehouses w_from ON w_from.id = stock_transfers.from_warehouse_id"). + Joins("JOIN warehouses w_to ON w_to.id = stock_transfers.to_warehouse_id"). + Where("stock_transfers.id = ?", id). + Where("w_from.location_id IN ? OR w_to.location_id IN ?", scope.IDs, scope.IDs). + Count(&count).Error; err != nil { + return nil, err + } + if count == 0 { + return nil, fiber.NewError(fiber.StatusNotFound, "Transfer not found") + } + } transferPtr, err := s.StockTransferRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { return s.withRelations(db) diff --git a/internal/modules/master/areas/services/area.service.go b/internal/modules/master/areas/services/area.service.go index e6f9205c..1ec30d8d 100644 --- a/internal/modules/master/areas/services/area.service.go +++ b/internal/modules/master/areas/services/area.service.go @@ -47,10 +47,21 @@ func (s areaService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Ar return nil, 0, err } + scope, err := m.ResolveAreaScope(c, s.Repository.DB()) + if err != nil { + return nil, 0, err + } + offset := (params.Page - 1) * params.Limit areas, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) + if scope.Restrict { + if len(scope.IDs) == 0 { + return db.Where("1 = 0") + } + db = db.Where("id IN ?", scope.IDs) + } if params.Search != "" { return db.Where("name ILIKE ?", "%"+params.Search+"%") } @@ -65,7 +76,21 @@ func (s areaService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Ar } func (s areaService) GetOne(c *fiber.Ctx, id uint) (*entity.Area, error) { - area, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + scope, err := m.ResolveAreaScope(c, s.Repository.DB()) + if err != nil { + return nil, err + } + + area, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + if scope.Restrict { + if len(scope.IDs) == 0 { + return db.Where("1 = 0") + } + db = db.Where("id IN ?", scope.IDs) + } + return db + }) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Area not found") } diff --git a/internal/modules/master/kandangs/services/kandang.service.go b/internal/modules/master/kandangs/services/kandang.service.go index 9f83f0ce..b70577d6 100644 --- a/internal/modules/master/kandangs/services/kandang.service.go +++ b/internal/modules/master/kandangs/services/kandang.service.go @@ -49,10 +49,16 @@ func (s kandangService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity return nil, 0, err } + scope, err := m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, 0, err + } + offset := (params.Page - 1) * params.Limit kandangs, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) + db = m.ApplyScopeFilter(db, scope, "location_id") if params.Search != "" { return db.Where("name ILIKE ?", "%"+params.Search+"%") } @@ -73,7 +79,16 @@ func (s kandangService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity } func (s kandangService) GetOne(c *fiber.Ctx, id uint) (*entity.Kandang, error) { - kandang, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + scope, err := m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, err + } + + kandang, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + db = m.ApplyScopeFilter(db, scope, "location_id") + return db + }) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Kandang not found") } diff --git a/internal/modules/master/locations/services/location.service.go b/internal/modules/master/locations/services/location.service.go index 3a1d1e23..633d7419 100644 --- a/internal/modules/master/locations/services/location.service.go +++ b/internal/modules/master/locations/services/location.service.go @@ -47,10 +47,21 @@ func (s locationService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit return nil, 0, err } + scope, err := m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, 0, err + } + offset := (params.Page - 1) * params.Limit locations, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) + if scope.Restrict { + if len(scope.IDs) == 0 { + return db.Where("1 = 0") + } + db = db.Where("id IN ?", scope.IDs) + } if params.Search != "" { db = db.Where("name ILIKE ?", "%"+params.Search+"%") } @@ -68,7 +79,21 @@ func (s locationService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit } func (s locationService) GetOne(c *fiber.Ctx, id uint) (*entity.Location, error) { - location, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + scope, err := m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, err + } + + location, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + if scope.Restrict { + if len(scope.IDs) == 0 { + return db.Where("1 = 0") + } + db = db.Where("id IN ?", scope.IDs) + } + return db + }) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Location not found") } diff --git a/internal/modules/master/warehouses/services/warehouse.service.go b/internal/modules/master/warehouses/services/warehouse.service.go index 7eeaad3d..aaa5ca7e 100644 --- a/internal/modules/master/warehouses/services/warehouse.service.go +++ b/internal/modules/master/warehouses/services/warehouse.service.go @@ -48,10 +48,16 @@ func (s warehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti return nil, 0, err } + scope, err := m.ResolveAreaScope(c, s.Repository.DB()) + if err != nil { + return nil, 0, err + } + offset := (params.Page - 1) * params.Limit warehouses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) + db = m.ApplyScopeFilter(db, scope, "area_id") if params.Search != "" { db = db.Where("warehouses.name ILIKE ?", "%"+params.Search+"%") } @@ -86,7 +92,16 @@ func (s warehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti } func (s warehouseService) GetOne(c *fiber.Ctx, id uint) (*entity.Warehouse, error) { - warehouse, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + scope, err := m.ResolveAreaScope(c, s.Repository.DB()) + if err != nil { + return nil, err + } + + warehouse, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + db = m.ApplyScopeFilter(db, scope, "area_id") + return db + }) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Warehouse not found") } diff --git a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go index 6f019ffa..61a593d5 100644 --- a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go +++ b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go @@ -88,9 +88,14 @@ func (s projectFlockKandangService) GetAll(c *fiber.Ctx, params *validation.Quer return nil, 0, err } + scope, err := m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, 0, err + } + offset := (params.Page - 1) * params.Limit - projectFlockKandangs, total, err := s.Repository.GetAllWithFilters(c.Context(), offset, params.Limit, params) + projectFlockKandangs, total, err := s.Repository.GetAllWithFiltersScoped(c.Context(), offset, params.Limit, params, scope.IDs, scope.Restrict) if err != nil { s.Log.Errorf("Failed to get projectFlockKandangs: %+v", err) @@ -106,6 +111,28 @@ func (s projectFlockKandangService) GetAll(c *fiber.Ctx, params *validation.Quer } func (s projectFlockKandangService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlockKandang, map[uint]float64, []entity.ProductWarehouse, error) { + scope, err := m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, nil, nil, err + } + if scope.Restrict { + if len(scope.IDs) == 0 { + return nil, nil, nil, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found") + } + var count int64 + if err := s.Repository.DB().WithContext(c.Context()). + Table("project_flock_kandangs"). + Joins("JOIN project_flocks ON project_flocks.id = project_flock_kandangs.project_flock_id"). + Where("project_flock_kandangs.id = ?", id). + Where("project_flocks.location_id IN ?", scope.IDs). + Count(&count).Error; err != nil { + return nil, nil, nil, err + } + if count == 0 { + return nil, nil, nil, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found") + } + } + projectFlockKandang, err := s.Repository.GetByID(c.Context(), id) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil, nil, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found") diff --git a/internal/modules/production/project_flocks/repositories/projectflock.repository.go b/internal/modules/production/project_flocks/repositories/projectflock.repository.go index e65dfb4a..346f2176 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock.repository.go @@ -14,6 +14,7 @@ import ( type ProjectflockRepository interface { repository.BaseRepository[entity.ProjectFlock] GetAllWithFilters(ctx context.Context, offset, limit int, params *validation.Query) ([]entity.ProjectFlock, int64, error) + GetAllWithFiltersScoped(ctx context.Context, offset, limit int, params *validation.Query, locationIDs []uint, restrict bool) ([]entity.ProjectFlock, int64, error) WithDefaultRelations() func(*gorm.DB) *gorm.DB ExistsByFlockName(ctx context.Context, flockName string, excludeID *uint) (bool, error) GetNextPeriodsForKandangs(ctx context.Context, kandangIDs []uint) (map[uint]int, error) @@ -48,6 +49,19 @@ func (r *ProjectflockRepositoryImpl) GetAllWithFilters(ctx context.Context, offs }) } +func (r *ProjectflockRepositoryImpl) GetAllWithFiltersScoped(ctx context.Context, offset, limit int, params *validation.Query, locationIDs []uint, restrict bool) ([]entity.ProjectFlock, int64, error) { + return r.GetAll(ctx, offset, limit, func(db *gorm.DB) *gorm.DB { + db = r.applyQueryFilters(r.WithDefaultRelations()(db), params) + if restrict { + if len(locationIDs) == 0 { + return db.Where("1 = 0") + } + db = db.Where("project_flocks.location_id IN ?", locationIDs) + } + return db + }) +} + func (r *ProjectflockRepositoryImpl) WithDefaultRelations() func(*gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { return db. diff --git a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go index 474a53c2..24c4c083 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go @@ -20,6 +20,7 @@ type ProjectFlockKandangRepository interface { DeleteMany(ctx context.Context, projectFlockID uint, kandangIDs []uint) error GetAll(ctx context.Context, offset int, limit int, modifier func(*gorm.DB) *gorm.DB) ([]entity.ProjectFlockKandang, int64, error) GetAllWithFilters(ctx context.Context, offset int, limit int, params interface{}) ([]entity.ProjectFlockKandang, int64, error) + GetAllWithFiltersScoped(ctx context.Context, offset int, limit int, params interface{}, locationIDs []uint, restrict bool) ([]entity.ProjectFlockKandang, int64, error) GetByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.ProjectFlockKandang, error) ListExistingKandangIDs(ctx context.Context, projectFlockID uint, kandangIDs []uint) ([]uint, error) HasKandangsLinkedToOtherProject(ctx context.Context, kandangIDs []uint, exceptProjectID *uint) (bool, error) @@ -196,6 +197,104 @@ func (r *projectFlockKandangRepositoryImpl) GetAllWithFilters(ctx context.Contex return records, total, nil } +func (r *projectFlockKandangRepositoryImpl) GetAllWithFiltersScoped(ctx context.Context, offset int, limit int, params interface{}, locationIDs []uint, restrict bool) ([]entity.ProjectFlockKandang, int64, error) { + var records []entity.ProjectFlockKandang + var total int64 + + query, ok := params.(*validation.Query) + + q := r.db.WithContext(ctx). + Joins("JOIN \"kandangs\" ON \"project_flock_kandangs\".\"kandang_id\" = \"kandangs\".\"id\""). + Joins("JOIN \"project_flocks\" ON \"project_flock_kandangs\".\"project_flock_id\" = \"project_flocks\".\"id\""). + Preload("ProjectFlock"). + Preload("ProjectFlock.Fcr"). + Preload("ProjectFlock.Area"). + Preload("ProjectFlock.Location"). + Preload("ProjectFlock.CreatedUser"). + Preload("ProjectFlock.Kandangs"). + Preload("ProjectFlock.KandangHistory"). + Preload("Kandang"). + Preload("Chickins"). + Preload("Chickins.CreatedUser"). + Preload("Chickins.ProductWarehouse") + + if restrict { + if len(locationIDs) == 0 { + return []entity.ProjectFlockKandang{}, 0, nil + } + q = q.Where("\"project_flocks\".\"location_id\" IN ?", locationIDs) + } + + if ok && query != nil && query.StepName != "" { + q = q.Where(` + EXISTS ( + SELECT 1 FROM "approvals" + WHERE "approvals"."approvable_id" = "project_flock_kandangs"."id" + AND "approvals"."approvable_type" = ? + AND LOWER("approvals"."step_name") = LOWER(?) + AND "approvals"."id" IN ( + SELECT "approvals"."id" FROM "approvals" + WHERE "approvals"."approvable_id" = "project_flock_kandangs"."id" + AND "approvals"."approvable_type" = ? + ORDER BY "approvals"."id" DESC + LIMIT 1 + ) + ) + `, "PROJECT_FLOCK_KANDANGS", query.StepName, "PROJECT_FLOCK_KANDANGS") + } + + if ok && query != nil { + if query.Search != "" { + escapedSearch := strings.NewReplacer("\\", "\\\\", "%", "\\%", "_", "\\_").Replace(query.Search) + q = q.Where( + r.db.Where("LOWER(\"kandangs\".\"name\") LIKE LOWER(?) ESCAPE '\\'", "%"+escapedSearch+"%"). + Or("LOWER(\"project_flocks\".\"flock_name\") LIKE LOWER(?) ESCAPE '\\'", "%"+escapedSearch+"%"), + ) + } + + if query.ProjectFlockId > 0 { + q = q.Where("\"project_flock_kandangs\".\"project_flock_id\" = ?", query.ProjectFlockId) + } + + if query.KandangId > 0 { + q = q.Where("\"project_flock_kandangs\".\"kandang_id\" = ?", query.KandangId) + } + + if query.Category != "" { + q = q.Where("\"project_flocks\".\"category\" = ?", query.Category) + } + + if query.AreaId > 0 { + q = q.Where("\"project_flocks\".\"area_id\" = ?", query.AreaId) + } + } + + if err := q.Model(&entity.ProjectFlockKandang{}).Count(&total).Error; err != nil { + return nil, 0, err + } + + sortBy := "\"project_flock_kandangs\".\"created_at\" DESC" + if ok && query != nil && query.SortBy != "" { + sortOrder := "DESC" + if query.SortOrder == "ASC" { + sortOrder = "ASC" + } + + switch query.SortBy { + case "created_at": + sortBy = "\"project_flock_kandangs\".\"created_at\" " + sortOrder + case "period": + sortBy = "\"project_flocks\".\"period\" " + sortOrder + } + } + + if err := q.Order(sortBy).Offset(offset).Limit(limit).Find(&records).Error; err != nil { + return nil, 0, err + } + + return records, total, nil +} + func (r *projectFlockKandangRepositoryImpl) WithTx(tx *gorm.DB) ProjectFlockKandangRepository { return &projectFlockKandangRepositoryImpl{db: tx} } diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index 3dbe3f4b..5bccfbf9 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -114,9 +114,14 @@ func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e return nil, 0, nil, err } + scope, err := m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, 0, nil, err + } + offset := (params.Page - 1) * params.Limit - projectflocks, total, err := s.Repository.GetAllWithFilters(c.Context(), offset, params.Limit, params) + projectflocks, total, err := s.Repository.GetAllWithFiltersScoped(c.Context(), offset, params.Limit, params, scope.IDs, scope.Restrict) if err != nil { s.Log.Errorf("Failed to get projectflocks: %+v", err) @@ -190,7 +195,16 @@ func (s projectflockService) getOneEntityOnly(c *fiber.Ctx, id uint) (*entity.Pr } func (s projectflockService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlock, *flockDTO.FlockRelationDTO, error) { - projectflock, err := s.Repository.GetByID(c.Context(), id, s.Repository.WithDefaultRelations()) + scope, err := m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, nil, err + } + + projectflock, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + db = s.Repository.WithDefaultRelations()(db) + db = m.ApplyScopeFilter(db, scope, "project_flocks.location_id") + return db + }) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found") } diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index 35ca2f75..6b9537cc 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -124,6 +124,11 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti return nil, 0, err } + scope, err := m.ResolveLocationScope(c, s.PurchaseRepo.DB()) + if err != nil { + return nil, 0, err + } + offset := (params.Page - 1) * params.Limit createdFrom, createdTo, err := utils.ParseDateRangeForQuery(params.CreatedFrom, params.CreatedTo) @@ -147,6 +152,21 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti db = db.Where("created_at < ?", *createdTo) } + if scope.Restrict { + if len(scope.IDs) == 0 { + return db.Where("1 = 0") + } + db = db.Where( + `EXISTS ( + SELECT 1 + FROM purchase_items pi + JOIN warehouses w ON w.id = pi.warehouse_id + WHERE pi.purchase_id = purchases.id AND w.location_id IN ? + )`, + scope.IDs, + ) + } + if params.AreaID > 0 { db = db.Where( `EXISTS ( @@ -201,7 +221,42 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti } func (s *purchaseService) GetOne(c *fiber.Ctx, id uint) (*entity.Purchase, error) { - return s.loadPurchase(c.Context(), id) + scope, err := m.ResolveLocationScope(c, s.PurchaseRepo.DB()) + if err != nil { + return nil, err + } + + purchase, err := s.PurchaseRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + if scope.Restrict { + if len(scope.IDs) == 0 { + return db.Where("1 = 0") + } + db = db.Where( + `EXISTS ( + SELECT 1 + FROM purchase_items pi + JOIN warehouses w ON w.id = pi.warehouse_id + WHERE pi.purchase_id = purchases.id AND w.location_id IN ? + )`, + scope.IDs, + ) + } + return db + }) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, utils.NotFound("Purchase not found") + } + s.Log.Errorf("Failed to get purchase %d: %+v", id, err) + return nil, utils.Internal("Failed to get purchase") + } + if err := s.attachLatestApproval(c.Context(), purchase); err != nil { + s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", id, err) + } + s.applyTravelDocumentURLs(c.Context(), purchase) + + return purchase, nil } func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchaseRequest) (*entity.Purchase, error) { diff --git a/internal/modules/repports/controllers/repport.controller.go b/internal/modules/repports/controllers/repport.controller.go index 1d273af1..4ed7a855 100644 --- a/internal/modules/repports/controllers/repport.controller.go +++ b/internal/modules/repports/controllers/repport.controller.go @@ -5,6 +5,7 @@ import ( "strconv" "strings" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto" service "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/services" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations" @@ -49,6 +50,21 @@ func (c *RepportController) GetExpense(ctx *fiber.Ctx) error { RealizationDate: ctx.Query("realization_date", ""), } + locationScope, err := m.ResolveLocationScope(ctx, c.RepportService.DB()) + if err != nil { + return err + } + areaScope, err := m.ResolveAreaScope(ctx, c.RepportService.DB()) + if err != nil { + return err + } + if locationScope.Restrict { + query.AllowedLocationIDs = toInt64Slice(locationScope.IDs) + } + if areaScope.Restrict { + query.AllowedAreaIDs = toInt64Slice(areaScope.IDs) + } + if query.Page < 1 || query.Limit < 1 { return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") } @@ -130,6 +146,14 @@ func (c *RepportController) GetPurchaseSupplier(ctx *fiber.Ctx) error { FilterBy: ctx.Query("filter_by", ""), } + areaScope, err := m.ResolveAreaScope(ctx, c.RepportService.DB()) + if err != nil { + return err + } + if areaScope.Restrict { + query.AllowedAreaIDs = toInt64Slice(areaScope.IDs) + } + if query.Page < 1 || query.Limit < 1 { return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") } @@ -306,3 +330,14 @@ func parseCommaSeparatedInt64s(raw string) ([]int64, error) { return result, nil } + +func toInt64Slice(ids []uint) []int64 { + if len(ids) == 0 { + return nil + } + out := make([]int64, 0, len(ids)) + for _, id := range ids { + out = append(out, int64(id)) + } + return out +} diff --git a/internal/modules/repports/repositories/purchase_supplier.repository.go b/internal/modules/repports/repositories/purchase_supplier.repository.go index 979623fc..048fe02c 100644 --- a/internal/modules/repports/repositories/purchase_supplier.repository.go +++ b/internal/modules/repports/repositories/purchase_supplier.repository.go @@ -53,10 +53,18 @@ func (r *purchaseSupplierRepositoryImpl) baseSupplierQuery(ctx context.Context, Where("products.product_category_id = ?", filters.ProductCategoryId) } - if filters.AreaId > 0 { - db = db. - Joins("JOIN warehouses ON warehouses.id = purchase_items.warehouse_id"). - Where("warehouses.area_id = ?", filters.AreaId) + if filters.AreaId > 0 || filters.AllowedAreaIDs != nil { + db = db.Joins("JOIN warehouses ON warehouses.id = purchase_items.warehouse_id") + if filters.AreaId > 0 { + db = db.Where("warehouses.area_id = ?", filters.AreaId) + } + if filters.AllowedAreaIDs != nil { + if len(filters.AllowedAreaIDs) == 0 { + db = db.Where("1 = 0") + } else { + db = db.Where("warehouses.area_id IN ?", filters.AllowedAreaIDs) + } + } } if filters.StartDate != "" { @@ -164,10 +172,18 @@ func (r *purchaseSupplierRepositoryImpl) GetItemsBySuppliers(ctx context.Context Where("products.product_category_id = ?", filters.ProductCategoryId) } - if filters.AreaId > 0 { - db = db. - Joins("JOIN warehouses ON warehouses.id = purchase_items.warehouse_id"). - Where("warehouses.area_id = ?", filters.AreaId) + if filters.AreaId > 0 || filters.AllowedAreaIDs != nil { + db = db.Joins("JOIN warehouses ON warehouses.id = purchase_items.warehouse_id") + if filters.AreaId > 0 { + db = db.Where("warehouses.area_id = ?", filters.AreaId) + } + if filters.AllowedAreaIDs != nil { + if len(filters.AllowedAreaIDs) == 0 { + db = db.Where("1 = 0") + } else { + db = db.Where("warehouses.area_id IN ?", filters.AllowedAreaIDs) + } + } } if filters.StartDate != "" { diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index c4883b72..0e3dbada 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -9,6 +9,7 @@ import ( "strings" "time" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto" repportRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations" @@ -40,6 +41,7 @@ type RepportService interface { GetDebtSupplier(ctx *fiber.Ctx, params *validation.DebtSupplierQuery) ([]dto.DebtSupplierDTO, int64, error) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error) GetProductionResult(ctx *fiber.Ctx, params *validation.ProductionResultQuery) ([]dto.ProductionResultDTO, int64, error) + DB() *gorm.DB } type repportService struct { @@ -95,6 +97,10 @@ func NewRepportService( } } +func (s *repportService) DB() *gorm.DB { + return s.ExpenseRealizationRepo.DB() +} + func (s *repportService) GetExpense(c *fiber.Ctx, params *validation.ExpenseQuery) ([]dto.RepportExpenseListDTO, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err @@ -1303,6 +1309,36 @@ func (s *repportService) parseHppPerKandangQuery(ctx *fiber.Ctx) (*validation.Hp return nil, dto.HppPerKandangFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error()) } + locationScope, err := m.ResolveLocationScope(ctx, s.ExpenseRealizationRepo.DB()) + if err != nil { + return nil, dto.HppPerKandangFiltersDTO{}, err + } + areaScope, err := m.ResolveAreaScope(ctx, s.ExpenseRealizationRepo.DB()) + if err != nil { + return nil, dto.HppPerKandangFiltersDTO{}, err + } + + if locationScope.Restrict { + allowed := toInt64Slice(locationScope.IDs) + if len(allowed) == 0 { + locationIDs = []int64{-1} + } else if len(locationIDs) > 0 { + locationIDs = intersectInt64(locationIDs, allowed) + } else { + locationIDs = allowed + } + } + if areaScope.Restrict { + allowed := toInt64Slice(areaScope.IDs) + if len(allowed) == 0 { + areaIDs = []int64{-1} + } else if len(areaIDs) > 0 { + areaIDs = intersectInt64(areaIDs, allowed) + } else { + areaIDs = allowed + } + } + weightMin, err := parseOptionalFloat64(rawWeightMin) if err != nil { return nil, dto.HppPerKandangFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error()) @@ -1366,6 +1402,34 @@ func parseCommaSeparatedInt64s(raw string) ([]int64, error) { return result, nil } +func toInt64Slice(ids []uint) []int64 { + if len(ids) == 0 { + return nil + } + out := make([]int64, 0, len(ids)) + for _, id := range ids { + out = append(out, int64(id)) + } + return out +} + +func intersectInt64(a, b []int64) []int64 { + if len(a) == 0 || len(b) == 0 { + return nil + } + set := make(map[int64]struct{}, len(b)) + for _, id := range b { + set[id] = struct{}{} + } + out := make([]int64, 0, len(a)) + for _, id := range a { + if _, ok := set[id]; ok { + out = append(out, id) + } + } + return out +} + func parseOptionalFloat64(raw string) (*float64, error) { raw = strings.TrimSpace(raw) if raw == "" { diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index 6d50f3e6..4c1d7356 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -13,6 +13,8 @@ type ExpenseQuery struct { AreaId int64 `query:"area_id" validate:"omitempty"` LocationId int64 `query:"location_id" validate:"omitempty"` RealizationDate string `query:"realization_date" validate:"omitempty"` + AllowedAreaIDs []int64 `query:"-"` + AllowedLocationIDs []int64 `query:"-"` } type MarketingQuery struct { @@ -42,6 +44,7 @@ type PurchaseSupplierQuery struct { EndDate string `query:"end_date" validate:"omitempty"` SortBy string `query:"sort_by" validate:"omitempty"` FilterBy string `query:"filter_by" validate:"omitempty"` + AllowedAreaIDs []int64 `query:"-"` } type DebtSupplierQuery struct { diff --git a/internal/modules/sso/controllers/master_data.controller.go b/internal/modules/sso/controllers/master_data.controller.go new file mode 100644 index 00000000..2364011f --- /dev/null +++ b/internal/modules/sso/controllers/master_data.controller.go @@ -0,0 +1,331 @@ +package controllers + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "strconv" + "strings" + "sync" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/redis/go-redis/v9" + "gorm.io/gorm" + + "gitlab.com/mbugroup/lti-api.git/internal/config" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier" + "gitlab.com/mbugroup/lti-api.git/internal/response" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +type MasterDataController struct { + db *gorm.DB + redis *redis.Client + clients map[string]config.SSOClientConfig + drift time.Duration + nonceTTL time.Duration + localNonce sync.Map +} + +type masterArea struct { + ID uint `json:"id"` + Name string `json:"name"` +} + +type masterLocation struct { + ID uint `json:"id"` + Name string `json:"name"` + AreaID uint `json:"area_id"` +} + +func NewMasterDataController(db *gorm.DB, redis *redis.Client, clients map[string]config.SSOClientConfig) *MasterDataController { + normalized := make(map[string]config.SSOClientConfig, len(clients)) + for alias, cfg := range clients { + alias = strings.ToLower(strings.TrimSpace(alias)) + normalized[alias] = cfg + } + + drift := config.SSOUserSyncDrift + if drift <= 0 { + drift = 2 * time.Minute + } + + nonceTTL := config.SSOUserSyncNonceTTL + if nonceTTL <= 0 { + nonceTTL = 10 * time.Minute + } + + return &MasterDataController{ + db: db, + redis: redis, + clients: normalized, + drift: drift, + nonceTTL: nonceTTL, + } +} + +func (h *MasterDataController) GetAreas(c *fiber.Ctx) error { + if _, _, err := h.authenticate(c, nil); err != nil { + return err + } + + search := strings.TrimSpace(c.Query("search", "")) + ids := parseUintList(c.Query("ids", "")) + + query := h.db.WithContext(c.Context()). + Model(&entity.Area{}). + Where("deleted_at IS NULL") + if search != "" { + query = query.Where("name ILIKE ?", "%"+search+"%") + } + if len(ids) > 0 { + query = query.Where("id IN ?", ids) + } + + var areas []masterArea + if err := query.Order("name ASC").Find(&areas).Error; err != nil { + utils.Log.WithError(err).Error("failed to fetch areas for master data") + return fiber.NewError(fiber.StatusInternalServerError, "failed to fetch areas") + } + + return c.Status(fiber.StatusOK).JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get areas successfully", + Data: areas, + }) +} + +func (h *MasterDataController) GetLocations(c *fiber.Ctx) error { + if _, _, err := h.authenticate(c, nil); err != nil { + return err + } + + search := strings.TrimSpace(c.Query("search", "")) + areaIDs := parseUintList(c.Query("area_ids", "")) + ids := parseUintList(c.Query("ids", "")) + + query := h.db.WithContext(c.Context()). + Model(&entity.Location{}). + Where("deleted_at IS NULL") + if search != "" { + query = query.Where("name ILIKE ?", "%"+search+"%") + } + if len(areaIDs) > 0 { + query = query.Where("area_id IN ?", areaIDs) + } + if len(ids) > 0 { + query = query.Where("id IN ?", ids) + } + + var locations []masterLocation + if err := query.Order("name ASC").Find(&locations).Error; err != nil { + utils.Log.WithError(err).Error("failed to fetch locations for master data") + return fiber.NewError(fiber.StatusInternalServerError, "failed to fetch locations") + } + + return c.Status(fiber.StatusOK).JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get locations successfully", + Data: locations, + }) +} + +func (h *MasterDataController) authenticate(c *fiber.Ctx, body []byte) (string, config.SSOClientConfig, error) { + rawAlias := strings.TrimSpace(c.Get("X-Sync-Client")) + if rawAlias == "" { + return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "missing sync client header") + } + + aliasKey := strings.ToLower(rawAlias) + clientCfg, ok := h.clients[aliasKey] + if !ok { + return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "unknown sync client") + } + + if err := h.verifyAuthorization(c, aliasKey); err != nil { + return "", config.SSOClientConfig{}, err + } + + secret := strings.TrimSpace(clientCfg.SyncSecret) + if secret == "" { + return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "sync secret not configured") + } + + timestamp := strings.TrimSpace(c.Get("X-Sync-Timestamp")) + nonce := strings.TrimSpace(c.Get("X-Sync-Nonce")) + signature := strings.TrimSpace(c.Get("X-Sync-Signature")) + if timestamp == "" || nonce == "" || signature == "" { + return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "missing signature headers") + } + if len(nonce) < 16 { + return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "nonce too short") + } + + ts, err := strconv.ParseInt(timestamp, 10, 64) + if err != nil { + return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusBadRequest, "invalid timestamp") + } + + msgTime := time.Unix(ts, 0).UTC() + now := time.Now().UTC() + drift := now.Sub(msgTime) + if drift > h.drift || drift < -h.drift { + return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "timestamp outside allowed window") + } + + providedSig, err := decodeMasterSignature(signature) + if err != nil { + return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "invalid signature encoding") + } + + expectedSignature := calculateSignature(secret, rawAlias, timestamp, nonce, body) + if !hmac.Equal(providedSig, expectedSignature) { + return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "invalid signature") + } + + if err := h.registerNonce(c.Context(), aliasKey, nonce); err != nil { + return "", config.SSOClientConfig{}, err + } + + return aliasKey, clientCfg, nil +} + +func (h *MasterDataController) verifyAuthorization(c *fiber.Ctx, alias string) error { + authHeader := strings.TrimSpace(c.Get(fiber.HeaderAuthorization)) + if authHeader == "" { + return fiber.NewError(fiber.StatusUnauthorized, "missing authorization header") + } + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + return fiber.NewError(fiber.StatusUnauthorized, "invalid authorization header") + } + + token := strings.TrimSpace(parts[1]) + if token == "" { + return fiber.NewError(fiber.StatusUnauthorized, "invalid authorization header") + } + + verification, err := sso.VerifyAccessToken(token) + if err != nil { + return fiber.NewError(fiber.StatusUnauthorized, "invalid access token") + } + + if verification.ServiceAlias == "" || verification.ServiceAlias != alias { + return fiber.NewError(fiber.StatusUnauthorized, "service subject mismatch") + } + if !hasAnyScope(verification.Claims.Scopes(), []string{"sync.master", "sync.users"}) { + return fiber.NewError(fiber.StatusForbidden, "missing sync scope") + } + return nil +} + +func (h *MasterDataController) registerNonce(ctx context.Context, alias, nonce string) error { + ttl := h.nonceTTL + if ttl <= 0 { + ttl = 10 * time.Minute + } + + key := fmt.Sprintf("sso:sync:%s:%s", alias, nonce) + if h.redis != nil { + stored, err := h.redis.SetNX(ctx, key, "1", ttl).Result() + if err == nil { + if !stored { + return fiber.NewError(fiber.StatusUnauthorized, "nonce already used") + } + return nil + } + utils.Log.WithError(err).Warn("store sync nonce failed") + } + + now := time.Now().UTC() + if expRaw, ok := h.localNonce.Load(key); ok { + if expTime, ok := expRaw.(time.Time); ok && expTime.After(now) { + return fiber.NewError(fiber.StatusUnauthorized, "nonce already used") + } + } + h.localNonce.Store(key, now.Add(ttl)) + return nil +} + +func calculateSignature(secret, alias, timestamp, nonce string, body []byte) []byte { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(alias)) + mac.Write([]byte("\n")) + mac.Write([]byte(timestamp)) + mac.Write([]byte("\n")) + mac.Write([]byte(nonce)) + mac.Write([]byte("\n")) + if len(body) > 0 { + mac.Write(body) + } + return mac.Sum(nil) +} + +func decodeMasterSignature(sig string) ([]byte, error) { + sig = strings.TrimSpace(sig) + if sig == "" { + return nil, errors.New("empty signature") + } + if decoded, err := hex.DecodeString(sig); err == nil { + return decoded, nil + } + if decoded, err := base64.StdEncoding.DecodeString(sig); err == nil { + return decoded, nil + } + if decoded, err := base64.URLEncoding.DecodeString(sig); err == nil { + return decoded, nil + } + return nil, errors.New("unrecognized signature encoding") +} + +func parseUintList(raw string) []uint { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + out := make([]uint, 0, len(parts)) + seen := make(map[uint]struct{}, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + val, err := strconv.ParseUint(part, 10, 64) + if err != nil || val == 0 { + continue + } + if _, ok := seen[uint(val)]; ok { + continue + } + seen[uint(val)] = struct{}{} + out = append(out, uint(val)) + } + return out +} + +func hasAnyScope(scopes []string, targets []string) bool { + if len(scopes) == 0 || len(targets) == 0 { + return false + } + for _, scope := range scopes { + scope = strings.ToLower(strings.TrimSpace(scope)) + if scope == "" { + continue + } + for _, target := range targets { + if scope == strings.ToLower(strings.TrimSpace(target)) { + return true + } + } + } + return false +} diff --git a/internal/modules/sso/controllers/sso.controller.go b/internal/modules/sso/controllers/sso.controller.go index 410e9577..b49d73e5 100644 --- a/internal/modules/sso/controllers/sso.controller.go +++ b/internal/modules/sso/controllers/sso.controller.go @@ -16,7 +16,7 @@ import ( "gitlab.com/mbugroup/lti-api.git/internal/config" "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session" - "gitlab.com/mbugroup/lti-api.git/internal/sso" + sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier" "gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils/secure" ) diff --git a/internal/modules/sso/controllers/user_sync.controller.go b/internal/modules/sso/controllers/user_sync.controller.go index 9aeb9555..72c7768a 100644 --- a/internal/modules/sso/controllers/user_sync.controller.go +++ b/internal/modules/sso/controllers/user_sync.controller.go @@ -9,23 +9,24 @@ import ( "encoding/json" "errors" "fmt" - "github.com/go-playground/validator/v10" - "github.com/gofiber/fiber/v2" - "github.com/redis/go-redis/v9" - "github.com/sirupsen/logrus" - "gorm.io/gorm" "strconv" "strings" "sync" "time" + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/redis/go-redis/v9" + "github.com/sirupsen/logrus" + "gorm.io/gorm" + "gitlab.com/mbugroup/lti-api.git/internal/config" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session" + sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier" "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" userRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" "gitlab.com/mbugroup/lti-api.git/internal/response" - "gitlab.com/mbugroup/lti-api.git/internal/sso" "gitlab.com/mbugroup/lti-api.git/internal/utils" ) diff --git a/internal/modules/sso/route.go b/internal/modules/sso/route.go index 3f2a699e..a864611e 100644 --- a/internal/modules/sso/route.go +++ b/internal/modules/sso/route.go @@ -26,6 +26,7 @@ func Routes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { ctrl := ssoController.NewController(&http.Client{Timeout: 10 * time.Second}, store, session.GetRevocationStore()) userRepo := userRepository.NewUserRepository(db) syncCtrl := ssoController.NewUserSyncController(validate, userRepo, cache.Redis(), config.SSOClients) + masterCtrl := ssoController.NewMasterDataController(db, cache.Redis(), config.SSOClients) group := router.Group("/sso") group.Get("/start", middleware.NewLimiter(30, time.Minute), ctrl.Start) @@ -34,4 +35,6 @@ func Routes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { group.Post("/refresh", middleware.NewLimiter(60, time.Minute), ctrl.Refresh) group.Post("/logout", middleware.NewLimiter(60, time.Minute), ctrl.Logout) group.Post("/users/sync", middleware.NewLimiter(30, time.Minute), syncCtrl.Sync) + group.Get("/master/areas", middleware.NewLimiter(60, time.Minute), masterCtrl.GetAreas) + group.Get("/master/locations", middleware.NewLimiter(60, time.Minute), masterCtrl.GetLocations) } diff --git a/internal/sso/profile.go b/internal/modules/sso/verifier/profile.go similarity index 95% rename from internal/sso/profile.go rename to internal/modules/sso/verifier/profile.go index a211fc74..52b7ef5a 100644 --- a/internal/sso/profile.go +++ b/internal/modules/sso/verifier/profile.go @@ -49,6 +49,10 @@ type Role struct { ClientID uint ClientAlias string ClientName string + AllArea bool + AllLocation bool + AreaIDs []uint + LocationIDs []uint Permissions []Permission RawReference json.RawMessage `json:"-"` } @@ -162,6 +166,10 @@ func fetchProfileFromSSO(ctx context.Context, token string) (*UserProfile, error ClientAlias: strings.TrimSpace(r.Client.Alias), ClientName: strings.TrimSpace(r.Client.Name), ClientID: uint(r.Client.ID), + AllArea: r.AllArea, + AllLocation: r.AllLocation, + AreaIDs: r.AreaIDs, + LocationIDs: r.LocationIDs, } rolePerms := make([]Permission, 0, len(r.Permissions)) for _, p := range r.Permissions { @@ -288,6 +296,10 @@ type userInfoRole struct { ID int64 `json:"id"` Key string `json:"key"` Name string `json:"name"` + AllArea bool `json:"all_area"` + AllLocation bool `json:"all_location"` + AreaIDs []uint `json:"area_ids"` + LocationIDs []uint `json:"location_ids"` Client userInfoClient `json:"client"` Permissions []userInfoPermRaw `json:"permissions"` } diff --git a/internal/sso/verifier.go b/internal/modules/sso/verifier/verifier.go similarity index 100% rename from internal/sso/verifier.go rename to internal/modules/sso/verifier/verifier.go From 26bf7f165e2c4d5bede4f16771ae1b95e476a2c5 Mon Sep 17 00:00:00 2001 From: ragilap Date: Wed, 14 Jan 2026 13:30:48 +0700 Subject: [PATCH 02/37] Revert "[FIX/BE-US]add feature restrict by location and areas in roles" This reverts commit dff9e73ab104e9adf4ca3ab3fefec80769e55c63. --- cmd/api/main.go | 2 +- internal/middleware/auth.go | 2 +- internal/middleware/role_scope.go | 280 --------------- .../services/daily-checklist.service.go | 11 +- .../controllers/dashboard.controller.go | 32 -- .../dashboards/services/dashboard.service.go | 6 - .../expense_realization.repository.go | 23 +- .../expenses/services/expense.service.go | 17 +- .../services/product-stock.service.go | 56 +-- .../services/product_warehouse.service.go | 32 +- .../transfers/services/transfer.service.go | 36 -- .../master/areas/services/area.service.go | 27 +- .../kandangs/services/kandang.service.go | 17 +- .../locations/services/location.service.go | 27 +- .../warehouses/services/warehouse.service.go | 17 +- .../services/project_flock_kandang.service.go | 29 +- .../repositories/projectflock.repository.go | 14 - .../projectflock_kandang.repository.go | 99 ------ .../services/projectflock.service.go | 18 +- .../purchases/services/purchase.service.go | 57 +-- .../controllers/repport.controller.go | 35 -- .../purchase_supplier.repository.go | 32 +- .../repports/services/repport.service.go | 64 ---- .../validations/repport.validation.go | 3 - .../sso/controllers/master_data.controller.go | 331 ------------------ .../modules/sso/controllers/sso.controller.go | 2 +- .../sso/controllers/user_sync.controller.go | 11 +- internal/modules/sso/route.go | 3 - .../{modules/sso/verifier => sso}/profile.go | 12 - .../{modules/sso/verifier => sso}/verifier.go | 0 30 files changed, 37 insertions(+), 1258 deletions(-) delete mode 100644 internal/middleware/role_scope.go delete mode 100644 internal/modules/sso/controllers/master_data.controller.go rename internal/{modules/sso/verifier => sso}/profile.go (95%) rename internal/{modules/sso/verifier => sso}/verifier.go (100%) diff --git a/cmd/api/main.go b/cmd/api/main.go index 76de7729..a7c278d7 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -14,8 +14,8 @@ import ( "gitlab.com/mbugroup/lti-api.git/internal/database" "gitlab.com/mbugroup/lti-api.git/internal/middleware" "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session" - sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier" "gitlab.com/mbugroup/lti-api.git/internal/route" + "gitlab.com/mbugroup/lti-api.git/internal/sso" "gitlab.com/mbugroup/lti-api.git/internal/utils" "github.com/gofiber/fiber/v2" diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 1b670c14..a831c25b 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -7,8 +7,8 @@ import ( "gitlab.com/mbugroup/lti-api.git/internal/config" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session" - sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier" service "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + "gitlab.com/mbugroup/lti-api.git/internal/sso" "gitlab.com/mbugroup/lti-api.git/internal/utils" ) diff --git a/internal/middleware/role_scope.go b/internal/middleware/role_scope.go deleted file mode 100644 index f00e12d9..00000000 --- a/internal/middleware/role_scope.go +++ /dev/null @@ -1,280 +0,0 @@ -package middleware - -import ( - "errors" - "strings" - - "github.com/gofiber/fiber/v2" - "gorm.io/gorm" - - "gitlab.com/mbugroup/lti-api.git/internal/config" - entity "gitlab.com/mbugroup/lti-api.git/internal/entities" -) - -type ScopeFilter struct { - IDs []uint - Restrict bool -} - -type roleScope struct { - allArea bool - allLocation bool - areaIDs []uint - locationIDs []uint - hasAnyScopes bool -} - -func ResolveAreaScope(c *fiber.Ctx, db *gorm.DB) (ScopeFilter, error) { - scope, err := collectRoleScope(c) - if err != nil || !scope.hasAnyScopes { - return ScopeFilter{}, err - } - - if scope.allArea || scope.allLocation { - return ScopeFilter{}, nil - } - - allowed := uniqueUint(scope.areaIDs) - if len(scope.locationIDs) > 0 { - derived, err := areaIDsByLocationIDs(db, scope.locationIDs) - if err != nil { - return ScopeFilter{}, err - } - allowed = uniqueUint(append(allowed, derived...)) - } - - if len(allowed) == 0 { - return ScopeFilter{Restrict: true}, nil - } - return ScopeFilter{IDs: allowed, Restrict: true}, nil -} - -func ResolveLocationScope(c *fiber.Ctx, db *gorm.DB) (ScopeFilter, error) { - scope, err := collectRoleScope(c) - if err != nil || !scope.hasAnyScopes { - return ScopeFilter{}, err - } - - if scope.allLocation || scope.allArea { - return ScopeFilter{}, nil - } - - areaIDs := uniqueUint(scope.areaIDs) - locationIDs := uniqueUint(scope.locationIDs) - - switch { - case len(locationIDs) > 0 && len(areaIDs) > 0: - filtered, err := filterLocationIDsByAreaIDs(db, locationIDs, areaIDs) - if err != nil { - return ScopeFilter{}, err - } - locationIDs = filtered - case len(locationIDs) == 0 && len(areaIDs) > 0: - derived, err := locationIDsByAreaIDs(db, areaIDs) - if err != nil { - return ScopeFilter{}, err - } - locationIDs = derived - } - - locationIDs = uniqueUint(locationIDs) - if len(locationIDs) == 0 { - return ScopeFilter{Restrict: true}, nil - } - return ScopeFilter{IDs: locationIDs, Restrict: true}, nil -} - -func collectRoleScope(c *fiber.Ctx) (roleScope, error) { - ctx, ok := AuthDetails(c) - if !ok || ctx == nil || len(ctx.Roles) == 0 { - return roleScope{}, nil - } - - clientAlias := resolveClientAlias(ctx) - - scope := roleScope{} - areaSet := make(map[uint]struct{}) - locationSet := make(map[uint]struct{}) - - for _, role := range ctx.Roles { - if clientAlias != "" && !strings.EqualFold(strings.TrimSpace(role.ClientAlias), clientAlias) { - continue - } - scope.hasAnyScopes = true - if role.AllArea { - scope.allArea = true - } - if role.AllLocation { - scope.allLocation = true - } - for _, id := range role.AreaIDs { - if id == 0 { - continue - } - areaSet[id] = struct{}{} - } - for _, id := range role.LocationIDs { - if id == 0 { - continue - } - locationSet[id] = struct{}{} - } - } - - scope.areaIDs = keysUint(areaSet) - scope.locationIDs = keysUint(locationSet) - - scope.hasAnyScopes = scope.hasAnyScopes && - (scope.allArea || scope.allLocation || len(scope.areaIDs) > 0 || len(scope.locationIDs) > 0) - - return scope, nil -} - -func areaIDsByLocationIDs(db *gorm.DB, locationIDs []uint) ([]uint, error) { - if db == nil { - return nil, errors.New("database not configured") - } - if len(locationIDs) == 0 { - return nil, nil - } - var areaIDs []uint - if err := db.Model(&entity.Location{}). - Where("deleted_at IS NULL"). - Where("id IN ?", locationIDs). - Distinct("area_id"). - Pluck("area_id", &areaIDs).Error; err != nil { - return nil, err - } - return areaIDs, nil -} - -func locationIDsByAreaIDs(db *gorm.DB, areaIDs []uint) ([]uint, error) { - if db == nil { - return nil, errors.New("database not configured") - } - if len(areaIDs) == 0 { - return nil, nil - } - var locationIDs []uint - if err := db.Model(&entity.Location{}). - Where("deleted_at IS NULL"). - Where("area_id IN ?", areaIDs). - Distinct("id"). - Pluck("id", &locationIDs).Error; err != nil { - return nil, err - } - return locationIDs, nil -} - -func filterLocationIDsByAreaIDs(db *gorm.DB, locationIDs, areaIDs []uint) ([]uint, error) { - if db == nil { - return nil, errors.New("database not configured") - } - if len(locationIDs) == 0 || len(areaIDs) == 0 { - return nil, nil - } - var filtered []uint - if err := db.Model(&entity.Location{}). - Where("deleted_at IS NULL"). - Where("id IN ?", locationIDs). - Where("area_id IN ?", areaIDs). - Distinct("id"). - Pluck("id", &filtered).Error; err != nil { - return nil, err - } - return filtered, nil -} - -func uniqueUint(ids []uint) []uint { - if len(ids) == 0 { - return nil - } - seen := make(map[uint]struct{}, len(ids)) - result := make([]uint, 0, len(ids)) - for _, id := range ids { - if id == 0 { - continue - } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - result = append(result, id) - } - return result -} - -func keysUint(set map[uint]struct{}) []uint { - if len(set) == 0 { - return nil - } - out := make([]uint, 0, len(set)) - for id := range set { - out = append(out, id) - } - return out -} - -func resolveClientAlias(ctx *AuthContext) string { - if ctx == nil || ctx.Verification == nil || ctx.Verification.Claims == nil { - return "" - } - - scopes := ctx.Verification.Claims.Scopes() - if len(scopes) == 0 { - return "" - } - - seen := make(map[string]struct{}) - for _, scope := range scopes { - scope = strings.ToLower(strings.TrimSpace(scope)) - if scope == "" { - continue - } - prefix := scope - if idx := strings.IndexAny(prefix, ".:"); idx > 0 { - prefix = prefix[:idx] - } - prefix = strings.TrimSpace(prefix) - if prefix == "" { - continue - } - if alias := matchAlias(prefix); alias != "" { - seen[alias] = struct{}{} - } - } - - if len(seen) != 1 { - return "" - } - for alias := range seen { - return alias - } - return "" -} - -func matchAlias(alias string) string { - alias = strings.ToLower(strings.TrimSpace(alias)) - if alias == "" { - return "" - } - if _, ok := config.SSOClients[alias]; ok { - return alias - } - for key := range config.SSOClients { - if strings.EqualFold(key, alias) { - return strings.ToLower(strings.TrimSpace(key)) - } - } - return "" -} - -func ApplyScopeFilter(db *gorm.DB, scope ScopeFilter, column string) *gorm.DB { - if db == nil || !scope.Restrict { - return db - } - if len(scope.IDs) == 0 { - return db.Where("1 = 0") - } - return db.Where(column+" IN ?", scope.IDs) -} diff --git a/internal/modules/daily-checklists/services/daily-checklist.service.go b/internal/modules/daily-checklists/services/daily-checklist.service.go index 64802560..f306c74d 100644 --- a/internal/modules/daily-checklists/services/daily-checklist.service.go +++ b/internal/modules/daily-checklists/services/daily-checklist.service.go @@ -9,7 +9,7 @@ import ( "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" - m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + middleware "gitlab.com/mbugroup/lti-api.git/internal/middleware" 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" @@ -456,7 +456,7 @@ func (s dailyChecklistService) UpdateOne(c *fiber.Ctx, req *validation.Update, i updateBody["reject_reason"] = *req.RejectReason } - actorID, err := m.ActorIDFromContext(c) + actorID, err := middleware.ActorIDFromContext(c) if err != nil { return &entity.DailyChecklist{}, fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context") } @@ -946,11 +946,6 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report return nil, 0, err } - scope, err := m.ResolveLocationScope(c, s.Repository.DB()) - if err != nil { - return nil, 0, err - } - offset := (params.Page - 1) * params.Limit buildBase := func() *gorm.DB { @@ -967,8 +962,6 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report Where("EXTRACT(YEAR FROM dc.date) = ?", params.Year). Where("dc.status = ?", "APPROVED") - db = m.ApplyScopeFilter(db, scope, "loc.id") - if params.AreaID != nil { db = db.Where("a.id = ?", *params.AreaID) } diff --git a/internal/modules/dashboards/controllers/dashboard.controller.go b/internal/modules/dashboards/controllers/dashboard.controller.go index f74f31a7..bebad10f 100644 --- a/internal/modules/dashboards/controllers/dashboard.controller.go +++ b/internal/modules/dashboards/controllers/dashboard.controller.go @@ -6,7 +6,6 @@ import ( "strings" "time" - m "gitlab.com/mbugroup/lti-api.git/internal/middleware" "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/dto" service "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/services" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/validations" @@ -82,20 +81,6 @@ func (u *DashboardController) GetAll(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Invalid include") } - scope, err := m.ResolveLocationScope(c, u.DashboardService.DB()) - if err != nil { - return err - } - if scope.Restrict { - if len(scope.IDs) == 0 { - lokasiIds = []uint{} - } else if len(lokasiIds) > 0 { - lokasiIds = intersectUint(lokasiIds, scope.IDs) - } else { - lokasiIds = scope.IDs - } - } - analysisMode := strings.ToUpper(strings.TrimSpace(c.Query("analysis_mode", validation.AnalysisModeOverview))) metric := strings.ToLower(strings.TrimSpace(c.Query("metric", ""))) @@ -191,23 +176,6 @@ func defaultUintSlice(values []uint) []uint { return values } -func intersectUint(a, b []uint) []uint { - if len(a) == 0 || len(b) == 0 { - return nil - } - set := make(map[uint]struct{}, len(b)) - for _, id := range b { - set[id] = struct{}{} - } - out := make([]uint, 0, len(a)) - for _, id := range a { - if _, ok := set[id]; ok { - out = append(out, id) - } - } - return out -} - func parsePeriodDates(startDateRaw, endDateRaw string, location *time.Location) (time.Time, time.Time, time.Time, error) { now := time.Now().In(location) startDate := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, location) diff --git a/internal/modules/dashboards/services/dashboard.service.go b/internal/modules/dashboards/services/dashboard.service.go index afdbd1c1..b4635b2e 100644 --- a/internal/modules/dashboards/services/dashboard.service.go +++ b/internal/modules/dashboards/services/dashboard.service.go @@ -17,12 +17,10 @@ import ( "github.com/go-playground/validator/v10" "github.com/sirupsen/logrus" - "gorm.io/gorm" ) type DashboardService interface { GetAll(ctx context.Context, params *validation.Query) (dto.DashboardPerformanceOverviewDTO, int64, error) - DB() *gorm.DB } type dashboardService struct { @@ -39,10 +37,6 @@ func NewDashboardService(repo repository.DashboardRepository, validate *validato } } -func (s dashboardService) DB() *gorm.DB { - return s.Repository.DB() -} - func (s dashboardService) GetAll(ctx context.Context, params *validation.Query) (dto.DashboardPerformanceOverviewDTO, int64, error) { if err := s.Validate.Struct(params); err != nil { return dto.DashboardPerformanceOverviewDTO{}, 0, err diff --git a/internal/modules/expenses/repositories/expense_realization.repository.go b/internal/modules/expenses/repositories/expense_realization.repository.go index 504e65ad..60ec97a7 100644 --- a/internal/modules/expenses/repositories/expense_realization.repository.go +++ b/internal/modules/expenses/repositories/expense_realization.repository.go @@ -138,28 +138,9 @@ func (r *ExpenseRealizationRepositoryImpl) GetAllWithFilters(ctx context.Context locationID := filters.LocationId areaID := filters.AreaId - if filters.AllowedLocationIDs != nil || filters.AllowedAreaIDs != nil || locationID > 0 || areaID > 0 { - db = db.Joins("JOIN kandangs ON kandangs.id = expense_nonstocks.kandang_id") - } - - if filters.AllowedLocationIDs != nil { - if len(filters.AllowedLocationIDs) == 0 { - db = db.Where("1 = 0") - } else { - db = db.Where("kandangs.location_id IN ?", filters.AllowedLocationIDs) - } - } - - if filters.AllowedAreaIDs != nil { - if len(filters.AllowedAreaIDs) == 0 { - db = db.Where("1 = 0") - } else { - db = db.Joins("JOIN locations ON locations.id = kandangs.location_id"). - Where("locations.area_id IN ?", filters.AllowedAreaIDs) - } - } - if locationID > 0 || areaID > 0 { + db = db.Joins("JOIN kandangs ON kandangs.id = expense_nonstocks.kandang_id") + if locationID > 0 { db = db.Where("kandangs.location_id = ?", uint(locationID)) } diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index 27b4a07f..3bf2db55 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -87,16 +87,10 @@ func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expens return nil, 0, err } - scope, err := middleware.ResolveLocationScope(c, s.Repository.DB()) - if err != nil { - return nil, 0, err - } - offset := (params.Page - 1) * params.Limit expenses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) - db = middleware.ApplyScopeFilter(db, scope, "location_id") if params.Search != "" { return db.Where("category ILIKE ?", "%"+params.Search+"%") } @@ -123,16 +117,7 @@ func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expens } func (s expenseService) GetOne(c *fiber.Ctx, id uint) (*expenseDto.ExpenseDetailDTO, error) { - scope, err := middleware.ResolveLocationScope(c, s.Repository.DB()) - if err != nil { - return nil, err - } - - expense, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { - db = s.withRelations(db) - db = middleware.ApplyScopeFilter(db, scope, "location_id") - return db - }) + expense, err := s.Repository.GetByID(c.Context(), id, s.withRelations) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { diff --git a/internal/modules/inventory/product-stocks/services/product-stock.service.go b/internal/modules/inventory/product-stocks/services/product-stock.service.go index a4e404d6..11475109 100644 --- a/internal/modules/inventory/product-stocks/services/product-stock.service.go +++ b/internal/modules/inventory/product-stocks/services/product-stock.service.go @@ -4,7 +4,6 @@ import ( "errors" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" - m "gitlab.com/mbugroup/lti-api.git/internal/middleware" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-stocks/validations" productRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" @@ -62,34 +61,15 @@ func (s productStockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e return nil, 0, err } - scope, err := m.ResolveLocationScope(c, s.ProductRepository.DB()) - if err != nil { - return nil, 0, err - } - offset := (params.Page - 1) * params.Limit productStocks, total, err := s.ProductRepository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { - if scope.Restrict { - if len(scope.IDs) == 0 { - return db.Where("1 = 0") - } - db = db.Where(`EXISTS ( - SELECT 1 - FROM product_warehouses pw - JOIN warehouses w ON w.id = pw.warehouse_id - WHERE pw.product_id = products.id - AND pw.qty > 0 - AND w.location_id IN ? - )`, scope.IDs) - } else { - db = db.Where(`EXISTS ( - SELECT 1 - FROM product_warehouses pw - WHERE pw.product_id = products.id - AND pw.qty > 0 - )`) - } + db = db.Where(`EXISTS ( + SELECT 1 + FROM product_warehouses pw + WHERE pw.product_id = products.id + AND pw.qty > 0 + )`) db = s.withRelations(db) if params.Search != "" { @@ -106,30 +86,6 @@ func (s productStockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e } func (s productStockService) GetOne(c *fiber.Ctx, id uint) (*entity.Product, error) { - scope, err := m.ResolveLocationScope(c, s.ProductRepository.DB()) - if err != nil { - return nil, err - } - - if scope.Restrict { - if len(scope.IDs) == 0 { - return nil, fiber.NewError(fiber.StatusNotFound, "Product not found") - } - var count int64 - if err := s.ProductRepository.DB().WithContext(c.Context()). - Table("product_warehouses pw"). - Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). - Where("pw.product_id = ?", id). - Where("pw.qty > 0"). - Where("w.location_id IN ?", scope.IDs). - Count(&count).Error; err != nil { - return nil, err - } - if count == 0 { - return nil, fiber.NewError(fiber.StatusNotFound, "Product not found") - } - } - product, err := s.ProductRepository.GetByID(c.Context(), id, s.withRelations) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Product not found") diff --git a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go index 5c162084..152bfa24 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -8,7 +8,7 @@ import ( validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/validations" kandangrepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" - m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "github.com/sirupsen/logrus" @@ -53,11 +53,6 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) return nil, 0, err } - scope, err := m.ResolveLocationScope(c, s.Repository.DB()) - if err != nil { - return nil, 0, err - } - if params.ProductId > 0 { isProductExist, err := s.Repository.IsProductExist(c.Context(), params.ProductId) if err != nil { @@ -95,14 +90,6 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) productWarehouses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) - if scope.Restrict { - if len(scope.IDs) == 0 { - return db.Where("1 = 0") - } - db = db.Joins("JOIN warehouses w_scope ON product_warehouses.warehouse_id = w_scope.id"). - Where("w_scope.location_id IN ?", scope.IDs) - } - if params.ProductId != 0 { db = db.Where("product_id = ?", params.ProductId) } @@ -129,22 +116,7 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) } func (s productWarehouseService) GetOne(c *fiber.Ctx, id uint) (*entity.ProductWarehouse, error) { - scope, err := m.ResolveLocationScope(c, s.Repository.DB()) - if err != nil { - return nil, err - } - - productWarehouse, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { - db = s.withRelations(db) - if scope.Restrict { - if len(scope.IDs) == 0 { - return db.Where("1 = 0") - } - db = db.Joins("JOIN warehouses w_scope ON product_warehouses.warehouse_id = w_scope.id"). - Where("w_scope.location_id IN ?", scope.IDs) - } - return db - }) + productWarehouse, err := s.Repository.GetByID(c.Context(), id, s.withRelations) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "ProductWarehouse not found") } diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index b4652a5d..3f12b444 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -94,24 +94,10 @@ func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit return nil, 0, err } - scope, err := m.ResolveLocationScope(c, s.StockTransferRepo.DB()) - if err != nil { - return nil, 0, err - } - offset := (params.Page - 1) * params.Limit transfers, total, err := s.StockTransferRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) - if scope.Restrict { - if len(scope.IDs) == 0 { - return db.Where("1 = 0") - } - db = db. - Joins("JOIN warehouses w_from ON w_from.id = stock_transfers.from_warehouse_id"). - Joins("JOIN warehouses w_to ON w_to.id = stock_transfers.to_warehouse_id"). - Where("w_from.location_id IN ? OR w_to.location_id IN ?", scope.IDs, scope.IDs) - } if params.Search != "" { db = db.Where("movement_number ILIKE ?", "%"+strings.TrimSpace(params.Search)+"%") } @@ -126,28 +112,6 @@ func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit } func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, error) { - scope, err := m.ResolveLocationScope(c, s.StockTransferRepo.DB()) - if err != nil { - return nil, err - } - if scope.Restrict { - if len(scope.IDs) == 0 { - return nil, fiber.NewError(fiber.StatusNotFound, "Transfer not found") - } - var count int64 - if err := s.StockTransferRepo.DB().WithContext(c.Context()). - Table("stock_transfers"). - Joins("JOIN warehouses w_from ON w_from.id = stock_transfers.from_warehouse_id"). - Joins("JOIN warehouses w_to ON w_to.id = stock_transfers.to_warehouse_id"). - Where("stock_transfers.id = ?", id). - Where("w_from.location_id IN ? OR w_to.location_id IN ?", scope.IDs, scope.IDs). - Count(&count).Error; err != nil { - return nil, err - } - if count == 0 { - return nil, fiber.NewError(fiber.StatusNotFound, "Transfer not found") - } - } transferPtr, err := s.StockTransferRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { return s.withRelations(db) diff --git a/internal/modules/master/areas/services/area.service.go b/internal/modules/master/areas/services/area.service.go index 1ec30d8d..e6f9205c 100644 --- a/internal/modules/master/areas/services/area.service.go +++ b/internal/modules/master/areas/services/area.service.go @@ -47,21 +47,10 @@ func (s areaService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Ar return nil, 0, err } - scope, err := m.ResolveAreaScope(c, s.Repository.DB()) - if err != nil { - return nil, 0, err - } - offset := (params.Page - 1) * params.Limit areas, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) - if scope.Restrict { - if len(scope.IDs) == 0 { - return db.Where("1 = 0") - } - db = db.Where("id IN ?", scope.IDs) - } if params.Search != "" { return db.Where("name ILIKE ?", "%"+params.Search+"%") } @@ -76,21 +65,7 @@ func (s areaService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Ar } func (s areaService) GetOne(c *fiber.Ctx, id uint) (*entity.Area, error) { - scope, err := m.ResolveAreaScope(c, s.Repository.DB()) - if err != nil { - return nil, err - } - - area, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { - db = s.withRelations(db) - if scope.Restrict { - if len(scope.IDs) == 0 { - return db.Where("1 = 0") - } - db = db.Where("id IN ?", scope.IDs) - } - return db - }) + area, err := s.Repository.GetByID(c.Context(), id, s.withRelations) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Area not found") } diff --git a/internal/modules/master/kandangs/services/kandang.service.go b/internal/modules/master/kandangs/services/kandang.service.go index b70577d6..9f83f0ce 100644 --- a/internal/modules/master/kandangs/services/kandang.service.go +++ b/internal/modules/master/kandangs/services/kandang.service.go @@ -49,16 +49,10 @@ func (s kandangService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity return nil, 0, err } - scope, err := m.ResolveLocationScope(c, s.Repository.DB()) - if err != nil { - return nil, 0, err - } - offset := (params.Page - 1) * params.Limit kandangs, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) - db = m.ApplyScopeFilter(db, scope, "location_id") if params.Search != "" { return db.Where("name ILIKE ?", "%"+params.Search+"%") } @@ -79,16 +73,7 @@ func (s kandangService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity } func (s kandangService) GetOne(c *fiber.Ctx, id uint) (*entity.Kandang, error) { - scope, err := m.ResolveLocationScope(c, s.Repository.DB()) - if err != nil { - return nil, err - } - - kandang, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { - db = s.withRelations(db) - db = m.ApplyScopeFilter(db, scope, "location_id") - return db - }) + kandang, err := s.Repository.GetByID(c.Context(), id, s.withRelations) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Kandang not found") } diff --git a/internal/modules/master/locations/services/location.service.go b/internal/modules/master/locations/services/location.service.go index 633d7419..3a1d1e23 100644 --- a/internal/modules/master/locations/services/location.service.go +++ b/internal/modules/master/locations/services/location.service.go @@ -47,21 +47,10 @@ func (s locationService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit return nil, 0, err } - scope, err := m.ResolveLocationScope(c, s.Repository.DB()) - if err != nil { - return nil, 0, err - } - offset := (params.Page - 1) * params.Limit locations, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) - if scope.Restrict { - if len(scope.IDs) == 0 { - return db.Where("1 = 0") - } - db = db.Where("id IN ?", scope.IDs) - } if params.Search != "" { db = db.Where("name ILIKE ?", "%"+params.Search+"%") } @@ -79,21 +68,7 @@ func (s locationService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit } func (s locationService) GetOne(c *fiber.Ctx, id uint) (*entity.Location, error) { - scope, err := m.ResolveLocationScope(c, s.Repository.DB()) - if err != nil { - return nil, err - } - - location, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { - db = s.withRelations(db) - if scope.Restrict { - if len(scope.IDs) == 0 { - return db.Where("1 = 0") - } - db = db.Where("id IN ?", scope.IDs) - } - return db - }) + location, err := s.Repository.GetByID(c.Context(), id, s.withRelations) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Location not found") } diff --git a/internal/modules/master/warehouses/services/warehouse.service.go b/internal/modules/master/warehouses/services/warehouse.service.go index aaa5ca7e..7eeaad3d 100644 --- a/internal/modules/master/warehouses/services/warehouse.service.go +++ b/internal/modules/master/warehouses/services/warehouse.service.go @@ -48,16 +48,10 @@ func (s warehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti return nil, 0, err } - scope, err := m.ResolveAreaScope(c, s.Repository.DB()) - if err != nil { - return nil, 0, err - } - offset := (params.Page - 1) * params.Limit warehouses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) - db = m.ApplyScopeFilter(db, scope, "area_id") if params.Search != "" { db = db.Where("warehouses.name ILIKE ?", "%"+params.Search+"%") } @@ -92,16 +86,7 @@ func (s warehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti } func (s warehouseService) GetOne(c *fiber.Ctx, id uint) (*entity.Warehouse, error) { - scope, err := m.ResolveAreaScope(c, s.Repository.DB()) - if err != nil { - return nil, err - } - - warehouse, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { - db = s.withRelations(db) - db = m.ApplyScopeFilter(db, scope, "area_id") - return db - }) + warehouse, err := s.Repository.GetByID(c.Context(), id, s.withRelations) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Warehouse not found") } diff --git a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go index 61a593d5..6f019ffa 100644 --- a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go +++ b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go @@ -88,14 +88,9 @@ func (s projectFlockKandangService) GetAll(c *fiber.Ctx, params *validation.Quer return nil, 0, err } - scope, err := m.ResolveLocationScope(c, s.Repository.DB()) - if err != nil { - return nil, 0, err - } - offset := (params.Page - 1) * params.Limit - projectFlockKandangs, total, err := s.Repository.GetAllWithFiltersScoped(c.Context(), offset, params.Limit, params, scope.IDs, scope.Restrict) + projectFlockKandangs, total, err := s.Repository.GetAllWithFilters(c.Context(), offset, params.Limit, params) if err != nil { s.Log.Errorf("Failed to get projectFlockKandangs: %+v", err) @@ -111,28 +106,6 @@ func (s projectFlockKandangService) GetAll(c *fiber.Ctx, params *validation.Quer } func (s projectFlockKandangService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlockKandang, map[uint]float64, []entity.ProductWarehouse, error) { - scope, err := m.ResolveLocationScope(c, s.Repository.DB()) - if err != nil { - return nil, nil, nil, err - } - if scope.Restrict { - if len(scope.IDs) == 0 { - return nil, nil, nil, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found") - } - var count int64 - if err := s.Repository.DB().WithContext(c.Context()). - Table("project_flock_kandangs"). - Joins("JOIN project_flocks ON project_flocks.id = project_flock_kandangs.project_flock_id"). - Where("project_flock_kandangs.id = ?", id). - Where("project_flocks.location_id IN ?", scope.IDs). - Count(&count).Error; err != nil { - return nil, nil, nil, err - } - if count == 0 { - return nil, nil, nil, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found") - } - } - projectFlockKandang, err := s.Repository.GetByID(c.Context(), id) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil, nil, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found") diff --git a/internal/modules/production/project_flocks/repositories/projectflock.repository.go b/internal/modules/production/project_flocks/repositories/projectflock.repository.go index 346f2176..e65dfb4a 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock.repository.go @@ -14,7 +14,6 @@ import ( type ProjectflockRepository interface { repository.BaseRepository[entity.ProjectFlock] GetAllWithFilters(ctx context.Context, offset, limit int, params *validation.Query) ([]entity.ProjectFlock, int64, error) - GetAllWithFiltersScoped(ctx context.Context, offset, limit int, params *validation.Query, locationIDs []uint, restrict bool) ([]entity.ProjectFlock, int64, error) WithDefaultRelations() func(*gorm.DB) *gorm.DB ExistsByFlockName(ctx context.Context, flockName string, excludeID *uint) (bool, error) GetNextPeriodsForKandangs(ctx context.Context, kandangIDs []uint) (map[uint]int, error) @@ -49,19 +48,6 @@ func (r *ProjectflockRepositoryImpl) GetAllWithFilters(ctx context.Context, offs }) } -func (r *ProjectflockRepositoryImpl) GetAllWithFiltersScoped(ctx context.Context, offset, limit int, params *validation.Query, locationIDs []uint, restrict bool) ([]entity.ProjectFlock, int64, error) { - return r.GetAll(ctx, offset, limit, func(db *gorm.DB) *gorm.DB { - db = r.applyQueryFilters(r.WithDefaultRelations()(db), params) - if restrict { - if len(locationIDs) == 0 { - return db.Where("1 = 0") - } - db = db.Where("project_flocks.location_id IN ?", locationIDs) - } - return db - }) -} - func (r *ProjectflockRepositoryImpl) WithDefaultRelations() func(*gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { return db. diff --git a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go index 24c4c083..474a53c2 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go @@ -20,7 +20,6 @@ type ProjectFlockKandangRepository interface { DeleteMany(ctx context.Context, projectFlockID uint, kandangIDs []uint) error GetAll(ctx context.Context, offset int, limit int, modifier func(*gorm.DB) *gorm.DB) ([]entity.ProjectFlockKandang, int64, error) GetAllWithFilters(ctx context.Context, offset int, limit int, params interface{}) ([]entity.ProjectFlockKandang, int64, error) - GetAllWithFiltersScoped(ctx context.Context, offset int, limit int, params interface{}, locationIDs []uint, restrict bool) ([]entity.ProjectFlockKandang, int64, error) GetByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.ProjectFlockKandang, error) ListExistingKandangIDs(ctx context.Context, projectFlockID uint, kandangIDs []uint) ([]uint, error) HasKandangsLinkedToOtherProject(ctx context.Context, kandangIDs []uint, exceptProjectID *uint) (bool, error) @@ -197,104 +196,6 @@ func (r *projectFlockKandangRepositoryImpl) GetAllWithFilters(ctx context.Contex return records, total, nil } -func (r *projectFlockKandangRepositoryImpl) GetAllWithFiltersScoped(ctx context.Context, offset int, limit int, params interface{}, locationIDs []uint, restrict bool) ([]entity.ProjectFlockKandang, int64, error) { - var records []entity.ProjectFlockKandang - var total int64 - - query, ok := params.(*validation.Query) - - q := r.db.WithContext(ctx). - Joins("JOIN \"kandangs\" ON \"project_flock_kandangs\".\"kandang_id\" = \"kandangs\".\"id\""). - Joins("JOIN \"project_flocks\" ON \"project_flock_kandangs\".\"project_flock_id\" = \"project_flocks\".\"id\""). - Preload("ProjectFlock"). - Preload("ProjectFlock.Fcr"). - Preload("ProjectFlock.Area"). - Preload("ProjectFlock.Location"). - Preload("ProjectFlock.CreatedUser"). - Preload("ProjectFlock.Kandangs"). - Preload("ProjectFlock.KandangHistory"). - Preload("Kandang"). - Preload("Chickins"). - Preload("Chickins.CreatedUser"). - Preload("Chickins.ProductWarehouse") - - if restrict { - if len(locationIDs) == 0 { - return []entity.ProjectFlockKandang{}, 0, nil - } - q = q.Where("\"project_flocks\".\"location_id\" IN ?", locationIDs) - } - - if ok && query != nil && query.StepName != "" { - q = q.Where(` - EXISTS ( - SELECT 1 FROM "approvals" - WHERE "approvals"."approvable_id" = "project_flock_kandangs"."id" - AND "approvals"."approvable_type" = ? - AND LOWER("approvals"."step_name") = LOWER(?) - AND "approvals"."id" IN ( - SELECT "approvals"."id" FROM "approvals" - WHERE "approvals"."approvable_id" = "project_flock_kandangs"."id" - AND "approvals"."approvable_type" = ? - ORDER BY "approvals"."id" DESC - LIMIT 1 - ) - ) - `, "PROJECT_FLOCK_KANDANGS", query.StepName, "PROJECT_FLOCK_KANDANGS") - } - - if ok && query != nil { - if query.Search != "" { - escapedSearch := strings.NewReplacer("\\", "\\\\", "%", "\\%", "_", "\\_").Replace(query.Search) - q = q.Where( - r.db.Where("LOWER(\"kandangs\".\"name\") LIKE LOWER(?) ESCAPE '\\'", "%"+escapedSearch+"%"). - Or("LOWER(\"project_flocks\".\"flock_name\") LIKE LOWER(?) ESCAPE '\\'", "%"+escapedSearch+"%"), - ) - } - - if query.ProjectFlockId > 0 { - q = q.Where("\"project_flock_kandangs\".\"project_flock_id\" = ?", query.ProjectFlockId) - } - - if query.KandangId > 0 { - q = q.Where("\"project_flock_kandangs\".\"kandang_id\" = ?", query.KandangId) - } - - if query.Category != "" { - q = q.Where("\"project_flocks\".\"category\" = ?", query.Category) - } - - if query.AreaId > 0 { - q = q.Where("\"project_flocks\".\"area_id\" = ?", query.AreaId) - } - } - - if err := q.Model(&entity.ProjectFlockKandang{}).Count(&total).Error; err != nil { - return nil, 0, err - } - - sortBy := "\"project_flock_kandangs\".\"created_at\" DESC" - if ok && query != nil && query.SortBy != "" { - sortOrder := "DESC" - if query.SortOrder == "ASC" { - sortOrder = "ASC" - } - - switch query.SortBy { - case "created_at": - sortBy = "\"project_flock_kandangs\".\"created_at\" " + sortOrder - case "period": - sortBy = "\"project_flocks\".\"period\" " + sortOrder - } - } - - if err := q.Order(sortBy).Offset(offset).Limit(limit).Find(&records).Error; err != nil { - return nil, 0, err - } - - return records, total, nil -} - func (r *projectFlockKandangRepositoryImpl) WithTx(tx *gorm.DB) ProjectFlockKandangRepository { return &projectFlockKandangRepositoryImpl{db: tx} } diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index 5bccfbf9..3dbe3f4b 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -114,14 +114,9 @@ func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e return nil, 0, nil, err } - scope, err := m.ResolveLocationScope(c, s.Repository.DB()) - if err != nil { - return nil, 0, nil, err - } - offset := (params.Page - 1) * params.Limit - projectflocks, total, err := s.Repository.GetAllWithFiltersScoped(c.Context(), offset, params.Limit, params, scope.IDs, scope.Restrict) + projectflocks, total, err := s.Repository.GetAllWithFilters(c.Context(), offset, params.Limit, params) if err != nil { s.Log.Errorf("Failed to get projectflocks: %+v", err) @@ -195,16 +190,7 @@ func (s projectflockService) getOneEntityOnly(c *fiber.Ctx, id uint) (*entity.Pr } func (s projectflockService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlock, *flockDTO.FlockRelationDTO, error) { - scope, err := m.ResolveLocationScope(c, s.Repository.DB()) - if err != nil { - return nil, nil, err - } - - projectflock, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { - db = s.Repository.WithDefaultRelations()(db) - db = m.ApplyScopeFilter(db, scope, "project_flocks.location_id") - return db - }) + projectflock, err := s.Repository.GetByID(c.Context(), id, s.Repository.WithDefaultRelations()) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found") } diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index 6b9537cc..35ca2f75 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -124,11 +124,6 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti return nil, 0, err } - scope, err := m.ResolveLocationScope(c, s.PurchaseRepo.DB()) - if err != nil { - return nil, 0, err - } - offset := (params.Page - 1) * params.Limit createdFrom, createdTo, err := utils.ParseDateRangeForQuery(params.CreatedFrom, params.CreatedTo) @@ -152,21 +147,6 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti db = db.Where("created_at < ?", *createdTo) } - if scope.Restrict { - if len(scope.IDs) == 0 { - return db.Where("1 = 0") - } - db = db.Where( - `EXISTS ( - SELECT 1 - FROM purchase_items pi - JOIN warehouses w ON w.id = pi.warehouse_id - WHERE pi.purchase_id = purchases.id AND w.location_id IN ? - )`, - scope.IDs, - ) - } - if params.AreaID > 0 { db = db.Where( `EXISTS ( @@ -221,42 +201,7 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti } func (s *purchaseService) GetOne(c *fiber.Ctx, id uint) (*entity.Purchase, error) { - scope, err := m.ResolveLocationScope(c, s.PurchaseRepo.DB()) - if err != nil { - return nil, err - } - - purchase, err := s.PurchaseRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { - db = s.withRelations(db) - if scope.Restrict { - if len(scope.IDs) == 0 { - return db.Where("1 = 0") - } - db = db.Where( - `EXISTS ( - SELECT 1 - FROM purchase_items pi - JOIN warehouses w ON w.id = pi.warehouse_id - WHERE pi.purchase_id = purchases.id AND w.location_id IN ? - )`, - scope.IDs, - ) - } - return db - }) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, utils.NotFound("Purchase not found") - } - s.Log.Errorf("Failed to get purchase %d: %+v", id, err) - return nil, utils.Internal("Failed to get purchase") - } - if err := s.attachLatestApproval(c.Context(), purchase); err != nil { - s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", id, err) - } - s.applyTravelDocumentURLs(c.Context(), purchase) - - return purchase, nil + return s.loadPurchase(c.Context(), id) } func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchaseRequest) (*entity.Purchase, error) { diff --git a/internal/modules/repports/controllers/repport.controller.go b/internal/modules/repports/controllers/repport.controller.go index 4ed7a855..1d273af1 100644 --- a/internal/modules/repports/controllers/repport.controller.go +++ b/internal/modules/repports/controllers/repport.controller.go @@ -5,7 +5,6 @@ import ( "strconv" "strings" - m "gitlab.com/mbugroup/lti-api.git/internal/middleware" "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto" service "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/services" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations" @@ -50,21 +49,6 @@ func (c *RepportController) GetExpense(ctx *fiber.Ctx) error { RealizationDate: ctx.Query("realization_date", ""), } - locationScope, err := m.ResolveLocationScope(ctx, c.RepportService.DB()) - if err != nil { - return err - } - areaScope, err := m.ResolveAreaScope(ctx, c.RepportService.DB()) - if err != nil { - return err - } - if locationScope.Restrict { - query.AllowedLocationIDs = toInt64Slice(locationScope.IDs) - } - if areaScope.Restrict { - query.AllowedAreaIDs = toInt64Slice(areaScope.IDs) - } - if query.Page < 1 || query.Limit < 1 { return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") } @@ -146,14 +130,6 @@ func (c *RepportController) GetPurchaseSupplier(ctx *fiber.Ctx) error { FilterBy: ctx.Query("filter_by", ""), } - areaScope, err := m.ResolveAreaScope(ctx, c.RepportService.DB()) - if err != nil { - return err - } - if areaScope.Restrict { - query.AllowedAreaIDs = toInt64Slice(areaScope.IDs) - } - if query.Page < 1 || query.Limit < 1 { return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") } @@ -330,14 +306,3 @@ func parseCommaSeparatedInt64s(raw string) ([]int64, error) { return result, nil } - -func toInt64Slice(ids []uint) []int64 { - if len(ids) == 0 { - return nil - } - out := make([]int64, 0, len(ids)) - for _, id := range ids { - out = append(out, int64(id)) - } - return out -} diff --git a/internal/modules/repports/repositories/purchase_supplier.repository.go b/internal/modules/repports/repositories/purchase_supplier.repository.go index 048fe02c..979623fc 100644 --- a/internal/modules/repports/repositories/purchase_supplier.repository.go +++ b/internal/modules/repports/repositories/purchase_supplier.repository.go @@ -53,18 +53,10 @@ func (r *purchaseSupplierRepositoryImpl) baseSupplierQuery(ctx context.Context, Where("products.product_category_id = ?", filters.ProductCategoryId) } - if filters.AreaId > 0 || filters.AllowedAreaIDs != nil { - db = db.Joins("JOIN warehouses ON warehouses.id = purchase_items.warehouse_id") - if filters.AreaId > 0 { - db = db.Where("warehouses.area_id = ?", filters.AreaId) - } - if filters.AllowedAreaIDs != nil { - if len(filters.AllowedAreaIDs) == 0 { - db = db.Where("1 = 0") - } else { - db = db.Where("warehouses.area_id IN ?", filters.AllowedAreaIDs) - } - } + if filters.AreaId > 0 { + db = db. + Joins("JOIN warehouses ON warehouses.id = purchase_items.warehouse_id"). + Where("warehouses.area_id = ?", filters.AreaId) } if filters.StartDate != "" { @@ -172,18 +164,10 @@ func (r *purchaseSupplierRepositoryImpl) GetItemsBySuppliers(ctx context.Context Where("products.product_category_id = ?", filters.ProductCategoryId) } - if filters.AreaId > 0 || filters.AllowedAreaIDs != nil { - db = db.Joins("JOIN warehouses ON warehouses.id = purchase_items.warehouse_id") - if filters.AreaId > 0 { - db = db.Where("warehouses.area_id = ?", filters.AreaId) - } - if filters.AllowedAreaIDs != nil { - if len(filters.AllowedAreaIDs) == 0 { - db = db.Where("1 = 0") - } else { - db = db.Where("warehouses.area_id IN ?", filters.AllowedAreaIDs) - } - } + if filters.AreaId > 0 { + db = db. + Joins("JOIN warehouses ON warehouses.id = purchase_items.warehouse_id"). + Where("warehouses.area_id = ?", filters.AreaId) } if filters.StartDate != "" { diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 0e3dbada..c4883b72 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -9,7 +9,6 @@ import ( "strings" "time" - m "gitlab.com/mbugroup/lti-api.git/internal/middleware" "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto" repportRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations" @@ -41,7 +40,6 @@ type RepportService interface { GetDebtSupplier(ctx *fiber.Ctx, params *validation.DebtSupplierQuery) ([]dto.DebtSupplierDTO, int64, error) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error) GetProductionResult(ctx *fiber.Ctx, params *validation.ProductionResultQuery) ([]dto.ProductionResultDTO, int64, error) - DB() *gorm.DB } type repportService struct { @@ -97,10 +95,6 @@ func NewRepportService( } } -func (s *repportService) DB() *gorm.DB { - return s.ExpenseRealizationRepo.DB() -} - func (s *repportService) GetExpense(c *fiber.Ctx, params *validation.ExpenseQuery) ([]dto.RepportExpenseListDTO, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err @@ -1309,36 +1303,6 @@ func (s *repportService) parseHppPerKandangQuery(ctx *fiber.Ctx) (*validation.Hp return nil, dto.HppPerKandangFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error()) } - locationScope, err := m.ResolveLocationScope(ctx, s.ExpenseRealizationRepo.DB()) - if err != nil { - return nil, dto.HppPerKandangFiltersDTO{}, err - } - areaScope, err := m.ResolveAreaScope(ctx, s.ExpenseRealizationRepo.DB()) - if err != nil { - return nil, dto.HppPerKandangFiltersDTO{}, err - } - - if locationScope.Restrict { - allowed := toInt64Slice(locationScope.IDs) - if len(allowed) == 0 { - locationIDs = []int64{-1} - } else if len(locationIDs) > 0 { - locationIDs = intersectInt64(locationIDs, allowed) - } else { - locationIDs = allowed - } - } - if areaScope.Restrict { - allowed := toInt64Slice(areaScope.IDs) - if len(allowed) == 0 { - areaIDs = []int64{-1} - } else if len(areaIDs) > 0 { - areaIDs = intersectInt64(areaIDs, allowed) - } else { - areaIDs = allowed - } - } - weightMin, err := parseOptionalFloat64(rawWeightMin) if err != nil { return nil, dto.HppPerKandangFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error()) @@ -1402,34 +1366,6 @@ func parseCommaSeparatedInt64s(raw string) ([]int64, error) { return result, nil } -func toInt64Slice(ids []uint) []int64 { - if len(ids) == 0 { - return nil - } - out := make([]int64, 0, len(ids)) - for _, id := range ids { - out = append(out, int64(id)) - } - return out -} - -func intersectInt64(a, b []int64) []int64 { - if len(a) == 0 || len(b) == 0 { - return nil - } - set := make(map[int64]struct{}, len(b)) - for _, id := range b { - set[id] = struct{}{} - } - out := make([]int64, 0, len(a)) - for _, id := range a { - if _, ok := set[id]; ok { - out = append(out, id) - } - } - return out -} - func parseOptionalFloat64(raw string) (*float64, error) { raw = strings.TrimSpace(raw) if raw == "" { diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index 4c1d7356..6d50f3e6 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -13,8 +13,6 @@ type ExpenseQuery struct { AreaId int64 `query:"area_id" validate:"omitempty"` LocationId int64 `query:"location_id" validate:"omitempty"` RealizationDate string `query:"realization_date" validate:"omitempty"` - AllowedAreaIDs []int64 `query:"-"` - AllowedLocationIDs []int64 `query:"-"` } type MarketingQuery struct { @@ -44,7 +42,6 @@ type PurchaseSupplierQuery struct { EndDate string `query:"end_date" validate:"omitempty"` SortBy string `query:"sort_by" validate:"omitempty"` FilterBy string `query:"filter_by" validate:"omitempty"` - AllowedAreaIDs []int64 `query:"-"` } type DebtSupplierQuery struct { diff --git a/internal/modules/sso/controllers/master_data.controller.go b/internal/modules/sso/controllers/master_data.controller.go deleted file mode 100644 index 2364011f..00000000 --- a/internal/modules/sso/controllers/master_data.controller.go +++ /dev/null @@ -1,331 +0,0 @@ -package controllers - -import ( - "context" - "crypto/hmac" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "errors" - "fmt" - "strconv" - "strings" - "sync" - "time" - - "github.com/gofiber/fiber/v2" - "github.com/redis/go-redis/v9" - "gorm.io/gorm" - - "gitlab.com/mbugroup/lti-api.git/internal/config" - entity "gitlab.com/mbugroup/lti-api.git/internal/entities" - sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier" - "gitlab.com/mbugroup/lti-api.git/internal/response" - "gitlab.com/mbugroup/lti-api.git/internal/utils" -) - -type MasterDataController struct { - db *gorm.DB - redis *redis.Client - clients map[string]config.SSOClientConfig - drift time.Duration - nonceTTL time.Duration - localNonce sync.Map -} - -type masterArea struct { - ID uint `json:"id"` - Name string `json:"name"` -} - -type masterLocation struct { - ID uint `json:"id"` - Name string `json:"name"` - AreaID uint `json:"area_id"` -} - -func NewMasterDataController(db *gorm.DB, redis *redis.Client, clients map[string]config.SSOClientConfig) *MasterDataController { - normalized := make(map[string]config.SSOClientConfig, len(clients)) - for alias, cfg := range clients { - alias = strings.ToLower(strings.TrimSpace(alias)) - normalized[alias] = cfg - } - - drift := config.SSOUserSyncDrift - if drift <= 0 { - drift = 2 * time.Minute - } - - nonceTTL := config.SSOUserSyncNonceTTL - if nonceTTL <= 0 { - nonceTTL = 10 * time.Minute - } - - return &MasterDataController{ - db: db, - redis: redis, - clients: normalized, - drift: drift, - nonceTTL: nonceTTL, - } -} - -func (h *MasterDataController) GetAreas(c *fiber.Ctx) error { - if _, _, err := h.authenticate(c, nil); err != nil { - return err - } - - search := strings.TrimSpace(c.Query("search", "")) - ids := parseUintList(c.Query("ids", "")) - - query := h.db.WithContext(c.Context()). - Model(&entity.Area{}). - Where("deleted_at IS NULL") - if search != "" { - query = query.Where("name ILIKE ?", "%"+search+"%") - } - if len(ids) > 0 { - query = query.Where("id IN ?", ids) - } - - var areas []masterArea - if err := query.Order("name ASC").Find(&areas).Error; err != nil { - utils.Log.WithError(err).Error("failed to fetch areas for master data") - return fiber.NewError(fiber.StatusInternalServerError, "failed to fetch areas") - } - - return c.Status(fiber.StatusOK).JSON(response.Success{ - Code: fiber.StatusOK, - Status: "success", - Message: "Get areas successfully", - Data: areas, - }) -} - -func (h *MasterDataController) GetLocations(c *fiber.Ctx) error { - if _, _, err := h.authenticate(c, nil); err != nil { - return err - } - - search := strings.TrimSpace(c.Query("search", "")) - areaIDs := parseUintList(c.Query("area_ids", "")) - ids := parseUintList(c.Query("ids", "")) - - query := h.db.WithContext(c.Context()). - Model(&entity.Location{}). - Where("deleted_at IS NULL") - if search != "" { - query = query.Where("name ILIKE ?", "%"+search+"%") - } - if len(areaIDs) > 0 { - query = query.Where("area_id IN ?", areaIDs) - } - if len(ids) > 0 { - query = query.Where("id IN ?", ids) - } - - var locations []masterLocation - if err := query.Order("name ASC").Find(&locations).Error; err != nil { - utils.Log.WithError(err).Error("failed to fetch locations for master data") - return fiber.NewError(fiber.StatusInternalServerError, "failed to fetch locations") - } - - return c.Status(fiber.StatusOK).JSON(response.Success{ - Code: fiber.StatusOK, - Status: "success", - Message: "Get locations successfully", - Data: locations, - }) -} - -func (h *MasterDataController) authenticate(c *fiber.Ctx, body []byte) (string, config.SSOClientConfig, error) { - rawAlias := strings.TrimSpace(c.Get("X-Sync-Client")) - if rawAlias == "" { - return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "missing sync client header") - } - - aliasKey := strings.ToLower(rawAlias) - clientCfg, ok := h.clients[aliasKey] - if !ok { - return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "unknown sync client") - } - - if err := h.verifyAuthorization(c, aliasKey); err != nil { - return "", config.SSOClientConfig{}, err - } - - secret := strings.TrimSpace(clientCfg.SyncSecret) - if secret == "" { - return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "sync secret not configured") - } - - timestamp := strings.TrimSpace(c.Get("X-Sync-Timestamp")) - nonce := strings.TrimSpace(c.Get("X-Sync-Nonce")) - signature := strings.TrimSpace(c.Get("X-Sync-Signature")) - if timestamp == "" || nonce == "" || signature == "" { - return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "missing signature headers") - } - if len(nonce) < 16 { - return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "nonce too short") - } - - ts, err := strconv.ParseInt(timestamp, 10, 64) - if err != nil { - return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusBadRequest, "invalid timestamp") - } - - msgTime := time.Unix(ts, 0).UTC() - now := time.Now().UTC() - drift := now.Sub(msgTime) - if drift > h.drift || drift < -h.drift { - return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "timestamp outside allowed window") - } - - providedSig, err := decodeMasterSignature(signature) - if err != nil { - return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "invalid signature encoding") - } - - expectedSignature := calculateSignature(secret, rawAlias, timestamp, nonce, body) - if !hmac.Equal(providedSig, expectedSignature) { - return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "invalid signature") - } - - if err := h.registerNonce(c.Context(), aliasKey, nonce); err != nil { - return "", config.SSOClientConfig{}, err - } - - return aliasKey, clientCfg, nil -} - -func (h *MasterDataController) verifyAuthorization(c *fiber.Ctx, alias string) error { - authHeader := strings.TrimSpace(c.Get(fiber.HeaderAuthorization)) - if authHeader == "" { - return fiber.NewError(fiber.StatusUnauthorized, "missing authorization header") - } - parts := strings.SplitN(authHeader, " ", 2) - if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { - return fiber.NewError(fiber.StatusUnauthorized, "invalid authorization header") - } - - token := strings.TrimSpace(parts[1]) - if token == "" { - return fiber.NewError(fiber.StatusUnauthorized, "invalid authorization header") - } - - verification, err := sso.VerifyAccessToken(token) - if err != nil { - return fiber.NewError(fiber.StatusUnauthorized, "invalid access token") - } - - if verification.ServiceAlias == "" || verification.ServiceAlias != alias { - return fiber.NewError(fiber.StatusUnauthorized, "service subject mismatch") - } - if !hasAnyScope(verification.Claims.Scopes(), []string{"sync.master", "sync.users"}) { - return fiber.NewError(fiber.StatusForbidden, "missing sync scope") - } - return nil -} - -func (h *MasterDataController) registerNonce(ctx context.Context, alias, nonce string) error { - ttl := h.nonceTTL - if ttl <= 0 { - ttl = 10 * time.Minute - } - - key := fmt.Sprintf("sso:sync:%s:%s", alias, nonce) - if h.redis != nil { - stored, err := h.redis.SetNX(ctx, key, "1", ttl).Result() - if err == nil { - if !stored { - return fiber.NewError(fiber.StatusUnauthorized, "nonce already used") - } - return nil - } - utils.Log.WithError(err).Warn("store sync nonce failed") - } - - now := time.Now().UTC() - if expRaw, ok := h.localNonce.Load(key); ok { - if expTime, ok := expRaw.(time.Time); ok && expTime.After(now) { - return fiber.NewError(fiber.StatusUnauthorized, "nonce already used") - } - } - h.localNonce.Store(key, now.Add(ttl)) - return nil -} - -func calculateSignature(secret, alias, timestamp, nonce string, body []byte) []byte { - mac := hmac.New(sha256.New, []byte(secret)) - mac.Write([]byte(alias)) - mac.Write([]byte("\n")) - mac.Write([]byte(timestamp)) - mac.Write([]byte("\n")) - mac.Write([]byte(nonce)) - mac.Write([]byte("\n")) - if len(body) > 0 { - mac.Write(body) - } - return mac.Sum(nil) -} - -func decodeMasterSignature(sig string) ([]byte, error) { - sig = strings.TrimSpace(sig) - if sig == "" { - return nil, errors.New("empty signature") - } - if decoded, err := hex.DecodeString(sig); err == nil { - return decoded, nil - } - if decoded, err := base64.StdEncoding.DecodeString(sig); err == nil { - return decoded, nil - } - if decoded, err := base64.URLEncoding.DecodeString(sig); err == nil { - return decoded, nil - } - return nil, errors.New("unrecognized signature encoding") -} - -func parseUintList(raw string) []uint { - raw = strings.TrimSpace(raw) - if raw == "" { - return nil - } - parts := strings.Split(raw, ",") - out := make([]uint, 0, len(parts)) - seen := make(map[uint]struct{}, len(parts)) - for _, part := range parts { - part = strings.TrimSpace(part) - if part == "" { - continue - } - val, err := strconv.ParseUint(part, 10, 64) - if err != nil || val == 0 { - continue - } - if _, ok := seen[uint(val)]; ok { - continue - } - seen[uint(val)] = struct{}{} - out = append(out, uint(val)) - } - return out -} - -func hasAnyScope(scopes []string, targets []string) bool { - if len(scopes) == 0 || len(targets) == 0 { - return false - } - for _, scope := range scopes { - scope = strings.ToLower(strings.TrimSpace(scope)) - if scope == "" { - continue - } - for _, target := range targets { - if scope == strings.ToLower(strings.TrimSpace(target)) { - return true - } - } - } - return false -} diff --git a/internal/modules/sso/controllers/sso.controller.go b/internal/modules/sso/controllers/sso.controller.go index b49d73e5..410e9577 100644 --- a/internal/modules/sso/controllers/sso.controller.go +++ b/internal/modules/sso/controllers/sso.controller.go @@ -16,7 +16,7 @@ import ( "gitlab.com/mbugroup/lti-api.git/internal/config" "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session" - sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier" + "gitlab.com/mbugroup/lti-api.git/internal/sso" "gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils/secure" ) diff --git a/internal/modules/sso/controllers/user_sync.controller.go b/internal/modules/sso/controllers/user_sync.controller.go index 72c7768a..9aeb9555 100644 --- a/internal/modules/sso/controllers/user_sync.controller.go +++ b/internal/modules/sso/controllers/user_sync.controller.go @@ -9,24 +9,23 @@ import ( "encoding/json" "errors" "fmt" - "strconv" - "strings" - "sync" - "time" - "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "github.com/redis/go-redis/v9" "github.com/sirupsen/logrus" "gorm.io/gorm" + "strconv" + "strings" + "sync" + "time" "gitlab.com/mbugroup/lti-api.git/internal/config" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session" - sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier" "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" userRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" "gitlab.com/mbugroup/lti-api.git/internal/response" + "gitlab.com/mbugroup/lti-api.git/internal/sso" "gitlab.com/mbugroup/lti-api.git/internal/utils" ) diff --git a/internal/modules/sso/route.go b/internal/modules/sso/route.go index a864611e..3f2a699e 100644 --- a/internal/modules/sso/route.go +++ b/internal/modules/sso/route.go @@ -26,7 +26,6 @@ func Routes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { ctrl := ssoController.NewController(&http.Client{Timeout: 10 * time.Second}, store, session.GetRevocationStore()) userRepo := userRepository.NewUserRepository(db) syncCtrl := ssoController.NewUserSyncController(validate, userRepo, cache.Redis(), config.SSOClients) - masterCtrl := ssoController.NewMasterDataController(db, cache.Redis(), config.SSOClients) group := router.Group("/sso") group.Get("/start", middleware.NewLimiter(30, time.Minute), ctrl.Start) @@ -35,6 +34,4 @@ func Routes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { group.Post("/refresh", middleware.NewLimiter(60, time.Minute), ctrl.Refresh) group.Post("/logout", middleware.NewLimiter(60, time.Minute), ctrl.Logout) group.Post("/users/sync", middleware.NewLimiter(30, time.Minute), syncCtrl.Sync) - group.Get("/master/areas", middleware.NewLimiter(60, time.Minute), masterCtrl.GetAreas) - group.Get("/master/locations", middleware.NewLimiter(60, time.Minute), masterCtrl.GetLocations) } diff --git a/internal/modules/sso/verifier/profile.go b/internal/sso/profile.go similarity index 95% rename from internal/modules/sso/verifier/profile.go rename to internal/sso/profile.go index 52b7ef5a..a211fc74 100644 --- a/internal/modules/sso/verifier/profile.go +++ b/internal/sso/profile.go @@ -49,10 +49,6 @@ type Role struct { ClientID uint ClientAlias string ClientName string - AllArea bool - AllLocation bool - AreaIDs []uint - LocationIDs []uint Permissions []Permission RawReference json.RawMessage `json:"-"` } @@ -166,10 +162,6 @@ func fetchProfileFromSSO(ctx context.Context, token string) (*UserProfile, error ClientAlias: strings.TrimSpace(r.Client.Alias), ClientName: strings.TrimSpace(r.Client.Name), ClientID: uint(r.Client.ID), - AllArea: r.AllArea, - AllLocation: r.AllLocation, - AreaIDs: r.AreaIDs, - LocationIDs: r.LocationIDs, } rolePerms := make([]Permission, 0, len(r.Permissions)) for _, p := range r.Permissions { @@ -296,10 +288,6 @@ type userInfoRole struct { ID int64 `json:"id"` Key string `json:"key"` Name string `json:"name"` - AllArea bool `json:"all_area"` - AllLocation bool `json:"all_location"` - AreaIDs []uint `json:"area_ids"` - LocationIDs []uint `json:"location_ids"` Client userInfoClient `json:"client"` Permissions []userInfoPermRaw `json:"permissions"` } diff --git a/internal/modules/sso/verifier/verifier.go b/internal/sso/verifier.go similarity index 100% rename from internal/modules/sso/verifier/verifier.go rename to internal/sso/verifier.go From 32772a63c8fd6e98ea4459e10e91427fc4207dfe Mon Sep 17 00:00:00 2001 From: ragilap Date: Wed, 14 Jan 2026 13:34:44 +0700 Subject: [PATCH 03/37] Revert "Revert "[FIX/BE-US]add feature restrict by location and areas in roles"" This reverts commit 26bf7f165e2c4d5bede4f16771ae1b95e476a2c5. --- cmd/api/main.go | 2 +- internal/middleware/auth.go | 2 +- internal/middleware/role_scope.go | 280 +++++++++++++++ .../services/daily-checklist.service.go | 11 +- .../controllers/dashboard.controller.go | 32 ++ .../dashboards/services/dashboard.service.go | 6 + .../expense_realization.repository.go | 21 +- .../expenses/services/expense.service.go | 17 +- .../services/product-stock.service.go | 56 ++- .../services/product_warehouse.service.go | 32 +- .../transfers/services/transfer.service.go | 36 ++ .../master/areas/services/area.service.go | 27 +- .../kandangs/services/kandang.service.go | 17 +- .../locations/services/location.service.go | 27 +- .../warehouses/services/warehouse.service.go | 17 +- .../services/project_flock_kandang.service.go | 29 +- .../repositories/projectflock.repository.go | 14 + .../projectflock_kandang.repository.go | 99 ++++++ .../services/projectflock.service.go | 18 +- .../purchases/services/purchase.service.go | 57 ++- .../controllers/repport.controller.go | 35 ++ .../purchase_supplier.repository.go | 32 +- .../repports/services/repport.service.go | 64 ++++ .../validations/repport.validation.go | 3 + .../sso/controllers/master_data.controller.go | 331 ++++++++++++++++++ .../modules/sso/controllers/sso.controller.go | 2 +- .../sso/controllers/user_sync.controller.go | 13 +- internal/modules/sso/route.go | 3 + .../{sso => modules/sso/verifier}/profile.go | 12 + .../{sso => modules/sso/verifier}/verifier.go | 0 30 files changed, 1258 insertions(+), 37 deletions(-) create mode 100644 internal/middleware/role_scope.go create mode 100644 internal/modules/sso/controllers/master_data.controller.go rename internal/{sso => modules/sso/verifier}/profile.go (95%) rename internal/{sso => modules/sso/verifier}/verifier.go (100%) diff --git a/cmd/api/main.go b/cmd/api/main.go index a7c278d7..76de7729 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -14,8 +14,8 @@ import ( "gitlab.com/mbugroup/lti-api.git/internal/database" "gitlab.com/mbugroup/lti-api.git/internal/middleware" "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session" + sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier" "gitlab.com/mbugroup/lti-api.git/internal/route" - "gitlab.com/mbugroup/lti-api.git/internal/sso" "gitlab.com/mbugroup/lti-api.git/internal/utils" "github.com/gofiber/fiber/v2" diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index a831c25b..1b670c14 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -7,8 +7,8 @@ import ( "gitlab.com/mbugroup/lti-api.git/internal/config" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session" + sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier" service "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" - "gitlab.com/mbugroup/lti-api.git/internal/sso" "gitlab.com/mbugroup/lti-api.git/internal/utils" ) diff --git a/internal/middleware/role_scope.go b/internal/middleware/role_scope.go new file mode 100644 index 00000000..f00e12d9 --- /dev/null +++ b/internal/middleware/role_scope.go @@ -0,0 +1,280 @@ +package middleware + +import ( + "errors" + "strings" + + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + "gitlab.com/mbugroup/lti-api.git/internal/config" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" +) + +type ScopeFilter struct { + IDs []uint + Restrict bool +} + +type roleScope struct { + allArea bool + allLocation bool + areaIDs []uint + locationIDs []uint + hasAnyScopes bool +} + +func ResolveAreaScope(c *fiber.Ctx, db *gorm.DB) (ScopeFilter, error) { + scope, err := collectRoleScope(c) + if err != nil || !scope.hasAnyScopes { + return ScopeFilter{}, err + } + + if scope.allArea || scope.allLocation { + return ScopeFilter{}, nil + } + + allowed := uniqueUint(scope.areaIDs) + if len(scope.locationIDs) > 0 { + derived, err := areaIDsByLocationIDs(db, scope.locationIDs) + if err != nil { + return ScopeFilter{}, err + } + allowed = uniqueUint(append(allowed, derived...)) + } + + if len(allowed) == 0 { + return ScopeFilter{Restrict: true}, nil + } + return ScopeFilter{IDs: allowed, Restrict: true}, nil +} + +func ResolveLocationScope(c *fiber.Ctx, db *gorm.DB) (ScopeFilter, error) { + scope, err := collectRoleScope(c) + if err != nil || !scope.hasAnyScopes { + return ScopeFilter{}, err + } + + if scope.allLocation || scope.allArea { + return ScopeFilter{}, nil + } + + areaIDs := uniqueUint(scope.areaIDs) + locationIDs := uniqueUint(scope.locationIDs) + + switch { + case len(locationIDs) > 0 && len(areaIDs) > 0: + filtered, err := filterLocationIDsByAreaIDs(db, locationIDs, areaIDs) + if err != nil { + return ScopeFilter{}, err + } + locationIDs = filtered + case len(locationIDs) == 0 && len(areaIDs) > 0: + derived, err := locationIDsByAreaIDs(db, areaIDs) + if err != nil { + return ScopeFilter{}, err + } + locationIDs = derived + } + + locationIDs = uniqueUint(locationIDs) + if len(locationIDs) == 0 { + return ScopeFilter{Restrict: true}, nil + } + return ScopeFilter{IDs: locationIDs, Restrict: true}, nil +} + +func collectRoleScope(c *fiber.Ctx) (roleScope, error) { + ctx, ok := AuthDetails(c) + if !ok || ctx == nil || len(ctx.Roles) == 0 { + return roleScope{}, nil + } + + clientAlias := resolveClientAlias(ctx) + + scope := roleScope{} + areaSet := make(map[uint]struct{}) + locationSet := make(map[uint]struct{}) + + for _, role := range ctx.Roles { + if clientAlias != "" && !strings.EqualFold(strings.TrimSpace(role.ClientAlias), clientAlias) { + continue + } + scope.hasAnyScopes = true + if role.AllArea { + scope.allArea = true + } + if role.AllLocation { + scope.allLocation = true + } + for _, id := range role.AreaIDs { + if id == 0 { + continue + } + areaSet[id] = struct{}{} + } + for _, id := range role.LocationIDs { + if id == 0 { + continue + } + locationSet[id] = struct{}{} + } + } + + scope.areaIDs = keysUint(areaSet) + scope.locationIDs = keysUint(locationSet) + + scope.hasAnyScopes = scope.hasAnyScopes && + (scope.allArea || scope.allLocation || len(scope.areaIDs) > 0 || len(scope.locationIDs) > 0) + + return scope, nil +} + +func areaIDsByLocationIDs(db *gorm.DB, locationIDs []uint) ([]uint, error) { + if db == nil { + return nil, errors.New("database not configured") + } + if len(locationIDs) == 0 { + return nil, nil + } + var areaIDs []uint + if err := db.Model(&entity.Location{}). + Where("deleted_at IS NULL"). + Where("id IN ?", locationIDs). + Distinct("area_id"). + Pluck("area_id", &areaIDs).Error; err != nil { + return nil, err + } + return areaIDs, nil +} + +func locationIDsByAreaIDs(db *gorm.DB, areaIDs []uint) ([]uint, error) { + if db == nil { + return nil, errors.New("database not configured") + } + if len(areaIDs) == 0 { + return nil, nil + } + var locationIDs []uint + if err := db.Model(&entity.Location{}). + Where("deleted_at IS NULL"). + Where("area_id IN ?", areaIDs). + Distinct("id"). + Pluck("id", &locationIDs).Error; err != nil { + return nil, err + } + return locationIDs, nil +} + +func filterLocationIDsByAreaIDs(db *gorm.DB, locationIDs, areaIDs []uint) ([]uint, error) { + if db == nil { + return nil, errors.New("database not configured") + } + if len(locationIDs) == 0 || len(areaIDs) == 0 { + return nil, nil + } + var filtered []uint + if err := db.Model(&entity.Location{}). + Where("deleted_at IS NULL"). + Where("id IN ?", locationIDs). + Where("area_id IN ?", areaIDs). + Distinct("id"). + Pluck("id", &filtered).Error; err != nil { + return nil, err + } + return filtered, nil +} + +func uniqueUint(ids []uint) []uint { + if len(ids) == 0 { + return nil + } + seen := make(map[uint]struct{}, len(ids)) + result := make([]uint, 0, len(ids)) + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + result = append(result, id) + } + return result +} + +func keysUint(set map[uint]struct{}) []uint { + if len(set) == 0 { + return nil + } + out := make([]uint, 0, len(set)) + for id := range set { + out = append(out, id) + } + return out +} + +func resolveClientAlias(ctx *AuthContext) string { + if ctx == nil || ctx.Verification == nil || ctx.Verification.Claims == nil { + return "" + } + + scopes := ctx.Verification.Claims.Scopes() + if len(scopes) == 0 { + return "" + } + + seen := make(map[string]struct{}) + for _, scope := range scopes { + scope = strings.ToLower(strings.TrimSpace(scope)) + if scope == "" { + continue + } + prefix := scope + if idx := strings.IndexAny(prefix, ".:"); idx > 0 { + prefix = prefix[:idx] + } + prefix = strings.TrimSpace(prefix) + if prefix == "" { + continue + } + if alias := matchAlias(prefix); alias != "" { + seen[alias] = struct{}{} + } + } + + if len(seen) != 1 { + return "" + } + for alias := range seen { + return alias + } + return "" +} + +func matchAlias(alias string) string { + alias = strings.ToLower(strings.TrimSpace(alias)) + if alias == "" { + return "" + } + if _, ok := config.SSOClients[alias]; ok { + return alias + } + for key := range config.SSOClients { + if strings.EqualFold(key, alias) { + return strings.ToLower(strings.TrimSpace(key)) + } + } + return "" +} + +func ApplyScopeFilter(db *gorm.DB, scope ScopeFilter, column string) *gorm.DB { + if db == nil || !scope.Restrict { + return db + } + if len(scope.IDs) == 0 { + return db.Where("1 = 0") + } + return db.Where(column+" IN ?", scope.IDs) +} diff --git a/internal/modules/daily-checklists/services/daily-checklist.service.go b/internal/modules/daily-checklists/services/daily-checklist.service.go index f306c74d..64802560 100644 --- a/internal/modules/daily-checklists/services/daily-checklist.service.go +++ b/internal/modules/daily-checklists/services/daily-checklist.service.go @@ -9,7 +9,7 @@ import ( "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" - middleware "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" 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" @@ -456,7 +456,7 @@ func (s dailyChecklistService) UpdateOne(c *fiber.Ctx, req *validation.Update, i updateBody["reject_reason"] = *req.RejectReason } - actorID, err := middleware.ActorIDFromContext(c) + actorID, err := m.ActorIDFromContext(c) if err != nil { return &entity.DailyChecklist{}, fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context") } @@ -946,6 +946,11 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report return nil, 0, err } + scope, err := m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, 0, err + } + offset := (params.Page - 1) * params.Limit buildBase := func() *gorm.DB { @@ -962,6 +967,8 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report Where("EXTRACT(YEAR FROM dc.date) = ?", params.Year). Where("dc.status = ?", "APPROVED") + db = m.ApplyScopeFilter(db, scope, "loc.id") + if params.AreaID != nil { db = db.Where("a.id = ?", *params.AreaID) } diff --git a/internal/modules/dashboards/controllers/dashboard.controller.go b/internal/modules/dashboards/controllers/dashboard.controller.go index bebad10f..f74f31a7 100644 --- a/internal/modules/dashboards/controllers/dashboard.controller.go +++ b/internal/modules/dashboards/controllers/dashboard.controller.go @@ -6,6 +6,7 @@ import ( "strings" "time" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/dto" service "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/services" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/validations" @@ -81,6 +82,20 @@ func (u *DashboardController) GetAll(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Invalid include") } + scope, err := m.ResolveLocationScope(c, u.DashboardService.DB()) + if err != nil { + return err + } + if scope.Restrict { + if len(scope.IDs) == 0 { + lokasiIds = []uint{} + } else if len(lokasiIds) > 0 { + lokasiIds = intersectUint(lokasiIds, scope.IDs) + } else { + lokasiIds = scope.IDs + } + } + analysisMode := strings.ToUpper(strings.TrimSpace(c.Query("analysis_mode", validation.AnalysisModeOverview))) metric := strings.ToLower(strings.TrimSpace(c.Query("metric", ""))) @@ -176,6 +191,23 @@ func defaultUintSlice(values []uint) []uint { return values } +func intersectUint(a, b []uint) []uint { + if len(a) == 0 || len(b) == 0 { + return nil + } + set := make(map[uint]struct{}, len(b)) + for _, id := range b { + set[id] = struct{}{} + } + out := make([]uint, 0, len(a)) + for _, id := range a { + if _, ok := set[id]; ok { + out = append(out, id) + } + } + return out +} + func parsePeriodDates(startDateRaw, endDateRaw string, location *time.Location) (time.Time, time.Time, time.Time, error) { now := time.Now().In(location) startDate := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, location) diff --git a/internal/modules/dashboards/services/dashboard.service.go b/internal/modules/dashboards/services/dashboard.service.go index b4635b2e..afdbd1c1 100644 --- a/internal/modules/dashboards/services/dashboard.service.go +++ b/internal/modules/dashboards/services/dashboard.service.go @@ -17,10 +17,12 @@ import ( "github.com/go-playground/validator/v10" "github.com/sirupsen/logrus" + "gorm.io/gorm" ) type DashboardService interface { GetAll(ctx context.Context, params *validation.Query) (dto.DashboardPerformanceOverviewDTO, int64, error) + DB() *gorm.DB } type dashboardService struct { @@ -37,6 +39,10 @@ func NewDashboardService(repo repository.DashboardRepository, validate *validato } } +func (s dashboardService) DB() *gorm.DB { + return s.Repository.DB() +} + func (s dashboardService) GetAll(ctx context.Context, params *validation.Query) (dto.DashboardPerformanceOverviewDTO, int64, error) { if err := s.Validate.Struct(params); err != nil { return dto.DashboardPerformanceOverviewDTO{}, 0, err diff --git a/internal/modules/expenses/repositories/expense_realization.repository.go b/internal/modules/expenses/repositories/expense_realization.repository.go index 60ec97a7..504e65ad 100644 --- a/internal/modules/expenses/repositories/expense_realization.repository.go +++ b/internal/modules/expenses/repositories/expense_realization.repository.go @@ -138,9 +138,28 @@ func (r *ExpenseRealizationRepositoryImpl) GetAllWithFilters(ctx context.Context locationID := filters.LocationId areaID := filters.AreaId - if locationID > 0 || areaID > 0 { + if filters.AllowedLocationIDs != nil || filters.AllowedAreaIDs != nil || locationID > 0 || areaID > 0 { db = db.Joins("JOIN kandangs ON kandangs.id = expense_nonstocks.kandang_id") + } + if filters.AllowedLocationIDs != nil { + if len(filters.AllowedLocationIDs) == 0 { + db = db.Where("1 = 0") + } else { + db = db.Where("kandangs.location_id IN ?", filters.AllowedLocationIDs) + } + } + + if filters.AllowedAreaIDs != nil { + if len(filters.AllowedAreaIDs) == 0 { + db = db.Where("1 = 0") + } else { + db = db.Joins("JOIN locations ON locations.id = kandangs.location_id"). + Where("locations.area_id IN ?", filters.AllowedAreaIDs) + } + } + + if locationID > 0 || areaID > 0 { if locationID > 0 { db = db.Where("kandangs.location_id = ?", uint(locationID)) } diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index 3bf2db55..27b4a07f 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -87,10 +87,16 @@ func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expens return nil, 0, err } + scope, err := middleware.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, 0, err + } + offset := (params.Page - 1) * params.Limit expenses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) + db = middleware.ApplyScopeFilter(db, scope, "location_id") if params.Search != "" { return db.Where("category ILIKE ?", "%"+params.Search+"%") } @@ -117,7 +123,16 @@ func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expens } func (s expenseService) GetOne(c *fiber.Ctx, id uint) (*expenseDto.ExpenseDetailDTO, error) { - expense, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + scope, err := middleware.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, err + } + + expense, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + db = middleware.ApplyScopeFilter(db, scope, "location_id") + return db + }) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { diff --git a/internal/modules/inventory/product-stocks/services/product-stock.service.go b/internal/modules/inventory/product-stocks/services/product-stock.service.go index 11475109..a4e404d6 100644 --- a/internal/modules/inventory/product-stocks/services/product-stock.service.go +++ b/internal/modules/inventory/product-stocks/services/product-stock.service.go @@ -4,6 +4,7 @@ import ( "errors" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-stocks/validations" productRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" @@ -61,15 +62,34 @@ func (s productStockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e return nil, 0, err } + scope, err := m.ResolveLocationScope(c, s.ProductRepository.DB()) + if err != nil { + return nil, 0, err + } + offset := (params.Page - 1) * params.Limit productStocks, total, err := s.ProductRepository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { - db = db.Where(`EXISTS ( - SELECT 1 - FROM product_warehouses pw - WHERE pw.product_id = products.id - AND pw.qty > 0 - )`) + if scope.Restrict { + if len(scope.IDs) == 0 { + return db.Where("1 = 0") + } + db = db.Where(`EXISTS ( + SELECT 1 + FROM product_warehouses pw + JOIN warehouses w ON w.id = pw.warehouse_id + WHERE pw.product_id = products.id + AND pw.qty > 0 + AND w.location_id IN ? + )`, scope.IDs) + } else { + db = db.Where(`EXISTS ( + SELECT 1 + FROM product_warehouses pw + WHERE pw.product_id = products.id + AND pw.qty > 0 + )`) + } db = s.withRelations(db) if params.Search != "" { @@ -86,6 +106,30 @@ func (s productStockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e } func (s productStockService) GetOne(c *fiber.Ctx, id uint) (*entity.Product, error) { + scope, err := m.ResolveLocationScope(c, s.ProductRepository.DB()) + if err != nil { + return nil, err + } + + if scope.Restrict { + if len(scope.IDs) == 0 { + return nil, fiber.NewError(fiber.StatusNotFound, "Product not found") + } + var count int64 + if err := s.ProductRepository.DB().WithContext(c.Context()). + Table("product_warehouses pw"). + Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). + Where("pw.product_id = ?", id). + Where("pw.qty > 0"). + Where("w.location_id IN ?", scope.IDs). + Count(&count).Error; err != nil { + return nil, err + } + if count == 0 { + return nil, fiber.NewError(fiber.StatusNotFound, "Product not found") + } + } + product, err := s.ProductRepository.GetByID(c.Context(), id, s.withRelations) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Product not found") diff --git a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go index 152bfa24..5c162084 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -8,7 +8,7 @@ import ( validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/validations" kandangrepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" - + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "github.com/sirupsen/logrus" @@ -53,6 +53,11 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) return nil, 0, err } + scope, err := m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, 0, err + } + if params.ProductId > 0 { isProductExist, err := s.Repository.IsProductExist(c.Context(), params.ProductId) if err != nil { @@ -90,6 +95,14 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) productWarehouses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) + if scope.Restrict { + if len(scope.IDs) == 0 { + return db.Where("1 = 0") + } + db = db.Joins("JOIN warehouses w_scope ON product_warehouses.warehouse_id = w_scope.id"). + Where("w_scope.location_id IN ?", scope.IDs) + } + if params.ProductId != 0 { db = db.Where("product_id = ?", params.ProductId) } @@ -116,7 +129,22 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) } func (s productWarehouseService) GetOne(c *fiber.Ctx, id uint) (*entity.ProductWarehouse, error) { - productWarehouse, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + scope, err := m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, err + } + + productWarehouse, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + if scope.Restrict { + if len(scope.IDs) == 0 { + return db.Where("1 = 0") + } + db = db.Joins("JOIN warehouses w_scope ON product_warehouses.warehouse_id = w_scope.id"). + Where("w_scope.location_id IN ?", scope.IDs) + } + return db + }) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "ProductWarehouse not found") } diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index 3f12b444..b4652a5d 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -94,10 +94,24 @@ func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit return nil, 0, err } + scope, err := m.ResolveLocationScope(c, s.StockTransferRepo.DB()) + if err != nil { + return nil, 0, err + } + offset := (params.Page - 1) * params.Limit transfers, total, err := s.StockTransferRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) + if scope.Restrict { + if len(scope.IDs) == 0 { + return db.Where("1 = 0") + } + db = db. + Joins("JOIN warehouses w_from ON w_from.id = stock_transfers.from_warehouse_id"). + Joins("JOIN warehouses w_to ON w_to.id = stock_transfers.to_warehouse_id"). + Where("w_from.location_id IN ? OR w_to.location_id IN ?", scope.IDs, scope.IDs) + } if params.Search != "" { db = db.Where("movement_number ILIKE ?", "%"+strings.TrimSpace(params.Search)+"%") } @@ -112,6 +126,28 @@ func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit } func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, error) { + scope, err := m.ResolveLocationScope(c, s.StockTransferRepo.DB()) + if err != nil { + return nil, err + } + if scope.Restrict { + if len(scope.IDs) == 0 { + return nil, fiber.NewError(fiber.StatusNotFound, "Transfer not found") + } + var count int64 + if err := s.StockTransferRepo.DB().WithContext(c.Context()). + Table("stock_transfers"). + Joins("JOIN warehouses w_from ON w_from.id = stock_transfers.from_warehouse_id"). + Joins("JOIN warehouses w_to ON w_to.id = stock_transfers.to_warehouse_id"). + Where("stock_transfers.id = ?", id). + Where("w_from.location_id IN ? OR w_to.location_id IN ?", scope.IDs, scope.IDs). + Count(&count).Error; err != nil { + return nil, err + } + if count == 0 { + return nil, fiber.NewError(fiber.StatusNotFound, "Transfer not found") + } + } transferPtr, err := s.StockTransferRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { return s.withRelations(db) diff --git a/internal/modules/master/areas/services/area.service.go b/internal/modules/master/areas/services/area.service.go index e6f9205c..1ec30d8d 100644 --- a/internal/modules/master/areas/services/area.service.go +++ b/internal/modules/master/areas/services/area.service.go @@ -47,10 +47,21 @@ func (s areaService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Ar return nil, 0, err } + scope, err := m.ResolveAreaScope(c, s.Repository.DB()) + if err != nil { + return nil, 0, err + } + offset := (params.Page - 1) * params.Limit areas, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) + if scope.Restrict { + if len(scope.IDs) == 0 { + return db.Where("1 = 0") + } + db = db.Where("id IN ?", scope.IDs) + } if params.Search != "" { return db.Where("name ILIKE ?", "%"+params.Search+"%") } @@ -65,7 +76,21 @@ func (s areaService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Ar } func (s areaService) GetOne(c *fiber.Ctx, id uint) (*entity.Area, error) { - area, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + scope, err := m.ResolveAreaScope(c, s.Repository.DB()) + if err != nil { + return nil, err + } + + area, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + if scope.Restrict { + if len(scope.IDs) == 0 { + return db.Where("1 = 0") + } + db = db.Where("id IN ?", scope.IDs) + } + return db + }) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Area not found") } diff --git a/internal/modules/master/kandangs/services/kandang.service.go b/internal/modules/master/kandangs/services/kandang.service.go index 9f83f0ce..b70577d6 100644 --- a/internal/modules/master/kandangs/services/kandang.service.go +++ b/internal/modules/master/kandangs/services/kandang.service.go @@ -49,10 +49,16 @@ func (s kandangService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity return nil, 0, err } + scope, err := m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, 0, err + } + offset := (params.Page - 1) * params.Limit kandangs, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) + db = m.ApplyScopeFilter(db, scope, "location_id") if params.Search != "" { return db.Where("name ILIKE ?", "%"+params.Search+"%") } @@ -73,7 +79,16 @@ func (s kandangService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity } func (s kandangService) GetOne(c *fiber.Ctx, id uint) (*entity.Kandang, error) { - kandang, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + scope, err := m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, err + } + + kandang, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + db = m.ApplyScopeFilter(db, scope, "location_id") + return db + }) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Kandang not found") } diff --git a/internal/modules/master/locations/services/location.service.go b/internal/modules/master/locations/services/location.service.go index 3a1d1e23..633d7419 100644 --- a/internal/modules/master/locations/services/location.service.go +++ b/internal/modules/master/locations/services/location.service.go @@ -47,10 +47,21 @@ func (s locationService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit return nil, 0, err } + scope, err := m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, 0, err + } + offset := (params.Page - 1) * params.Limit locations, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) + if scope.Restrict { + if len(scope.IDs) == 0 { + return db.Where("1 = 0") + } + db = db.Where("id IN ?", scope.IDs) + } if params.Search != "" { db = db.Where("name ILIKE ?", "%"+params.Search+"%") } @@ -68,7 +79,21 @@ func (s locationService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit } func (s locationService) GetOne(c *fiber.Ctx, id uint) (*entity.Location, error) { - location, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + scope, err := m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, err + } + + location, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + if scope.Restrict { + if len(scope.IDs) == 0 { + return db.Where("1 = 0") + } + db = db.Where("id IN ?", scope.IDs) + } + return db + }) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Location not found") } diff --git a/internal/modules/master/warehouses/services/warehouse.service.go b/internal/modules/master/warehouses/services/warehouse.service.go index 7eeaad3d..aaa5ca7e 100644 --- a/internal/modules/master/warehouses/services/warehouse.service.go +++ b/internal/modules/master/warehouses/services/warehouse.service.go @@ -48,10 +48,16 @@ func (s warehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti return nil, 0, err } + scope, err := m.ResolveAreaScope(c, s.Repository.DB()) + if err != nil { + return nil, 0, err + } + offset := (params.Page - 1) * params.Limit warehouses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) + db = m.ApplyScopeFilter(db, scope, "area_id") if params.Search != "" { db = db.Where("warehouses.name ILIKE ?", "%"+params.Search+"%") } @@ -86,7 +92,16 @@ func (s warehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti } func (s warehouseService) GetOne(c *fiber.Ctx, id uint) (*entity.Warehouse, error) { - warehouse, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + scope, err := m.ResolveAreaScope(c, s.Repository.DB()) + if err != nil { + return nil, err + } + + warehouse, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + db = m.ApplyScopeFilter(db, scope, "area_id") + return db + }) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Warehouse not found") } diff --git a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go index 6f019ffa..61a593d5 100644 --- a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go +++ b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go @@ -88,9 +88,14 @@ func (s projectFlockKandangService) GetAll(c *fiber.Ctx, params *validation.Quer return nil, 0, err } + scope, err := m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, 0, err + } + offset := (params.Page - 1) * params.Limit - projectFlockKandangs, total, err := s.Repository.GetAllWithFilters(c.Context(), offset, params.Limit, params) + projectFlockKandangs, total, err := s.Repository.GetAllWithFiltersScoped(c.Context(), offset, params.Limit, params, scope.IDs, scope.Restrict) if err != nil { s.Log.Errorf("Failed to get projectFlockKandangs: %+v", err) @@ -106,6 +111,28 @@ func (s projectFlockKandangService) GetAll(c *fiber.Ctx, params *validation.Quer } func (s projectFlockKandangService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlockKandang, map[uint]float64, []entity.ProductWarehouse, error) { + scope, err := m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, nil, nil, err + } + if scope.Restrict { + if len(scope.IDs) == 0 { + return nil, nil, nil, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found") + } + var count int64 + if err := s.Repository.DB().WithContext(c.Context()). + Table("project_flock_kandangs"). + Joins("JOIN project_flocks ON project_flocks.id = project_flock_kandangs.project_flock_id"). + Where("project_flock_kandangs.id = ?", id). + Where("project_flocks.location_id IN ?", scope.IDs). + Count(&count).Error; err != nil { + return nil, nil, nil, err + } + if count == 0 { + return nil, nil, nil, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found") + } + } + projectFlockKandang, err := s.Repository.GetByID(c.Context(), id) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil, nil, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found") diff --git a/internal/modules/production/project_flocks/repositories/projectflock.repository.go b/internal/modules/production/project_flocks/repositories/projectflock.repository.go index e65dfb4a..346f2176 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock.repository.go @@ -14,6 +14,7 @@ import ( type ProjectflockRepository interface { repository.BaseRepository[entity.ProjectFlock] GetAllWithFilters(ctx context.Context, offset, limit int, params *validation.Query) ([]entity.ProjectFlock, int64, error) + GetAllWithFiltersScoped(ctx context.Context, offset, limit int, params *validation.Query, locationIDs []uint, restrict bool) ([]entity.ProjectFlock, int64, error) WithDefaultRelations() func(*gorm.DB) *gorm.DB ExistsByFlockName(ctx context.Context, flockName string, excludeID *uint) (bool, error) GetNextPeriodsForKandangs(ctx context.Context, kandangIDs []uint) (map[uint]int, error) @@ -48,6 +49,19 @@ func (r *ProjectflockRepositoryImpl) GetAllWithFilters(ctx context.Context, offs }) } +func (r *ProjectflockRepositoryImpl) GetAllWithFiltersScoped(ctx context.Context, offset, limit int, params *validation.Query, locationIDs []uint, restrict bool) ([]entity.ProjectFlock, int64, error) { + return r.GetAll(ctx, offset, limit, func(db *gorm.DB) *gorm.DB { + db = r.applyQueryFilters(r.WithDefaultRelations()(db), params) + if restrict { + if len(locationIDs) == 0 { + return db.Where("1 = 0") + } + db = db.Where("project_flocks.location_id IN ?", locationIDs) + } + return db + }) +} + func (r *ProjectflockRepositoryImpl) WithDefaultRelations() func(*gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { return db. diff --git a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go index 474a53c2..24c4c083 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go @@ -20,6 +20,7 @@ type ProjectFlockKandangRepository interface { DeleteMany(ctx context.Context, projectFlockID uint, kandangIDs []uint) error GetAll(ctx context.Context, offset int, limit int, modifier func(*gorm.DB) *gorm.DB) ([]entity.ProjectFlockKandang, int64, error) GetAllWithFilters(ctx context.Context, offset int, limit int, params interface{}) ([]entity.ProjectFlockKandang, int64, error) + GetAllWithFiltersScoped(ctx context.Context, offset int, limit int, params interface{}, locationIDs []uint, restrict bool) ([]entity.ProjectFlockKandang, int64, error) GetByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.ProjectFlockKandang, error) ListExistingKandangIDs(ctx context.Context, projectFlockID uint, kandangIDs []uint) ([]uint, error) HasKandangsLinkedToOtherProject(ctx context.Context, kandangIDs []uint, exceptProjectID *uint) (bool, error) @@ -196,6 +197,104 @@ func (r *projectFlockKandangRepositoryImpl) GetAllWithFilters(ctx context.Contex return records, total, nil } +func (r *projectFlockKandangRepositoryImpl) GetAllWithFiltersScoped(ctx context.Context, offset int, limit int, params interface{}, locationIDs []uint, restrict bool) ([]entity.ProjectFlockKandang, int64, error) { + var records []entity.ProjectFlockKandang + var total int64 + + query, ok := params.(*validation.Query) + + q := r.db.WithContext(ctx). + Joins("JOIN \"kandangs\" ON \"project_flock_kandangs\".\"kandang_id\" = \"kandangs\".\"id\""). + Joins("JOIN \"project_flocks\" ON \"project_flock_kandangs\".\"project_flock_id\" = \"project_flocks\".\"id\""). + Preload("ProjectFlock"). + Preload("ProjectFlock.Fcr"). + Preload("ProjectFlock.Area"). + Preload("ProjectFlock.Location"). + Preload("ProjectFlock.CreatedUser"). + Preload("ProjectFlock.Kandangs"). + Preload("ProjectFlock.KandangHistory"). + Preload("Kandang"). + Preload("Chickins"). + Preload("Chickins.CreatedUser"). + Preload("Chickins.ProductWarehouse") + + if restrict { + if len(locationIDs) == 0 { + return []entity.ProjectFlockKandang{}, 0, nil + } + q = q.Where("\"project_flocks\".\"location_id\" IN ?", locationIDs) + } + + if ok && query != nil && query.StepName != "" { + q = q.Where(` + EXISTS ( + SELECT 1 FROM "approvals" + WHERE "approvals"."approvable_id" = "project_flock_kandangs"."id" + AND "approvals"."approvable_type" = ? + AND LOWER("approvals"."step_name") = LOWER(?) + AND "approvals"."id" IN ( + SELECT "approvals"."id" FROM "approvals" + WHERE "approvals"."approvable_id" = "project_flock_kandangs"."id" + AND "approvals"."approvable_type" = ? + ORDER BY "approvals"."id" DESC + LIMIT 1 + ) + ) + `, "PROJECT_FLOCK_KANDANGS", query.StepName, "PROJECT_FLOCK_KANDANGS") + } + + if ok && query != nil { + if query.Search != "" { + escapedSearch := strings.NewReplacer("\\", "\\\\", "%", "\\%", "_", "\\_").Replace(query.Search) + q = q.Where( + r.db.Where("LOWER(\"kandangs\".\"name\") LIKE LOWER(?) ESCAPE '\\'", "%"+escapedSearch+"%"). + Or("LOWER(\"project_flocks\".\"flock_name\") LIKE LOWER(?) ESCAPE '\\'", "%"+escapedSearch+"%"), + ) + } + + if query.ProjectFlockId > 0 { + q = q.Where("\"project_flock_kandangs\".\"project_flock_id\" = ?", query.ProjectFlockId) + } + + if query.KandangId > 0 { + q = q.Where("\"project_flock_kandangs\".\"kandang_id\" = ?", query.KandangId) + } + + if query.Category != "" { + q = q.Where("\"project_flocks\".\"category\" = ?", query.Category) + } + + if query.AreaId > 0 { + q = q.Where("\"project_flocks\".\"area_id\" = ?", query.AreaId) + } + } + + if err := q.Model(&entity.ProjectFlockKandang{}).Count(&total).Error; err != nil { + return nil, 0, err + } + + sortBy := "\"project_flock_kandangs\".\"created_at\" DESC" + if ok && query != nil && query.SortBy != "" { + sortOrder := "DESC" + if query.SortOrder == "ASC" { + sortOrder = "ASC" + } + + switch query.SortBy { + case "created_at": + sortBy = "\"project_flock_kandangs\".\"created_at\" " + sortOrder + case "period": + sortBy = "\"project_flocks\".\"period\" " + sortOrder + } + } + + if err := q.Order(sortBy).Offset(offset).Limit(limit).Find(&records).Error; err != nil { + return nil, 0, err + } + + return records, total, nil +} + func (r *projectFlockKandangRepositoryImpl) WithTx(tx *gorm.DB) ProjectFlockKandangRepository { return &projectFlockKandangRepositoryImpl{db: tx} } diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index 3dbe3f4b..5bccfbf9 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -114,9 +114,14 @@ func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e return nil, 0, nil, err } + scope, err := m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, 0, nil, err + } + offset := (params.Page - 1) * params.Limit - projectflocks, total, err := s.Repository.GetAllWithFilters(c.Context(), offset, params.Limit, params) + projectflocks, total, err := s.Repository.GetAllWithFiltersScoped(c.Context(), offset, params.Limit, params, scope.IDs, scope.Restrict) if err != nil { s.Log.Errorf("Failed to get projectflocks: %+v", err) @@ -190,7 +195,16 @@ func (s projectflockService) getOneEntityOnly(c *fiber.Ctx, id uint) (*entity.Pr } func (s projectflockService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlock, *flockDTO.FlockRelationDTO, error) { - projectflock, err := s.Repository.GetByID(c.Context(), id, s.Repository.WithDefaultRelations()) + scope, err := m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, nil, err + } + + projectflock, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + db = s.Repository.WithDefaultRelations()(db) + db = m.ApplyScopeFilter(db, scope, "project_flocks.location_id") + return db + }) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found") } diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index 35ca2f75..6b9537cc 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -124,6 +124,11 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti return nil, 0, err } + scope, err := m.ResolveLocationScope(c, s.PurchaseRepo.DB()) + if err != nil { + return nil, 0, err + } + offset := (params.Page - 1) * params.Limit createdFrom, createdTo, err := utils.ParseDateRangeForQuery(params.CreatedFrom, params.CreatedTo) @@ -147,6 +152,21 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti db = db.Where("created_at < ?", *createdTo) } + if scope.Restrict { + if len(scope.IDs) == 0 { + return db.Where("1 = 0") + } + db = db.Where( + `EXISTS ( + SELECT 1 + FROM purchase_items pi + JOIN warehouses w ON w.id = pi.warehouse_id + WHERE pi.purchase_id = purchases.id AND w.location_id IN ? + )`, + scope.IDs, + ) + } + if params.AreaID > 0 { db = db.Where( `EXISTS ( @@ -201,7 +221,42 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti } func (s *purchaseService) GetOne(c *fiber.Ctx, id uint) (*entity.Purchase, error) { - return s.loadPurchase(c.Context(), id) + scope, err := m.ResolveLocationScope(c, s.PurchaseRepo.DB()) + if err != nil { + return nil, err + } + + purchase, err := s.PurchaseRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + if scope.Restrict { + if len(scope.IDs) == 0 { + return db.Where("1 = 0") + } + db = db.Where( + `EXISTS ( + SELECT 1 + FROM purchase_items pi + JOIN warehouses w ON w.id = pi.warehouse_id + WHERE pi.purchase_id = purchases.id AND w.location_id IN ? + )`, + scope.IDs, + ) + } + return db + }) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, utils.NotFound("Purchase not found") + } + s.Log.Errorf("Failed to get purchase %d: %+v", id, err) + return nil, utils.Internal("Failed to get purchase") + } + if err := s.attachLatestApproval(c.Context(), purchase); err != nil { + s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", id, err) + } + s.applyTravelDocumentURLs(c.Context(), purchase) + + return purchase, nil } func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchaseRequest) (*entity.Purchase, error) { diff --git a/internal/modules/repports/controllers/repport.controller.go b/internal/modules/repports/controllers/repport.controller.go index 1d273af1..4ed7a855 100644 --- a/internal/modules/repports/controllers/repport.controller.go +++ b/internal/modules/repports/controllers/repport.controller.go @@ -5,6 +5,7 @@ import ( "strconv" "strings" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto" service "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/services" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations" @@ -49,6 +50,21 @@ func (c *RepportController) GetExpense(ctx *fiber.Ctx) error { RealizationDate: ctx.Query("realization_date", ""), } + locationScope, err := m.ResolveLocationScope(ctx, c.RepportService.DB()) + if err != nil { + return err + } + areaScope, err := m.ResolveAreaScope(ctx, c.RepportService.DB()) + if err != nil { + return err + } + if locationScope.Restrict { + query.AllowedLocationIDs = toInt64Slice(locationScope.IDs) + } + if areaScope.Restrict { + query.AllowedAreaIDs = toInt64Slice(areaScope.IDs) + } + if query.Page < 1 || query.Limit < 1 { return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") } @@ -130,6 +146,14 @@ func (c *RepportController) GetPurchaseSupplier(ctx *fiber.Ctx) error { FilterBy: ctx.Query("filter_by", ""), } + areaScope, err := m.ResolveAreaScope(ctx, c.RepportService.DB()) + if err != nil { + return err + } + if areaScope.Restrict { + query.AllowedAreaIDs = toInt64Slice(areaScope.IDs) + } + if query.Page < 1 || query.Limit < 1 { return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") } @@ -306,3 +330,14 @@ func parseCommaSeparatedInt64s(raw string) ([]int64, error) { return result, nil } + +func toInt64Slice(ids []uint) []int64 { + if len(ids) == 0 { + return nil + } + out := make([]int64, 0, len(ids)) + for _, id := range ids { + out = append(out, int64(id)) + } + return out +} diff --git a/internal/modules/repports/repositories/purchase_supplier.repository.go b/internal/modules/repports/repositories/purchase_supplier.repository.go index 979623fc..048fe02c 100644 --- a/internal/modules/repports/repositories/purchase_supplier.repository.go +++ b/internal/modules/repports/repositories/purchase_supplier.repository.go @@ -53,10 +53,18 @@ func (r *purchaseSupplierRepositoryImpl) baseSupplierQuery(ctx context.Context, Where("products.product_category_id = ?", filters.ProductCategoryId) } - if filters.AreaId > 0 { - db = db. - Joins("JOIN warehouses ON warehouses.id = purchase_items.warehouse_id"). - Where("warehouses.area_id = ?", filters.AreaId) + if filters.AreaId > 0 || filters.AllowedAreaIDs != nil { + db = db.Joins("JOIN warehouses ON warehouses.id = purchase_items.warehouse_id") + if filters.AreaId > 0 { + db = db.Where("warehouses.area_id = ?", filters.AreaId) + } + if filters.AllowedAreaIDs != nil { + if len(filters.AllowedAreaIDs) == 0 { + db = db.Where("1 = 0") + } else { + db = db.Where("warehouses.area_id IN ?", filters.AllowedAreaIDs) + } + } } if filters.StartDate != "" { @@ -164,10 +172,18 @@ func (r *purchaseSupplierRepositoryImpl) GetItemsBySuppliers(ctx context.Context Where("products.product_category_id = ?", filters.ProductCategoryId) } - if filters.AreaId > 0 { - db = db. - Joins("JOIN warehouses ON warehouses.id = purchase_items.warehouse_id"). - Where("warehouses.area_id = ?", filters.AreaId) + if filters.AreaId > 0 || filters.AllowedAreaIDs != nil { + db = db.Joins("JOIN warehouses ON warehouses.id = purchase_items.warehouse_id") + if filters.AreaId > 0 { + db = db.Where("warehouses.area_id = ?", filters.AreaId) + } + if filters.AllowedAreaIDs != nil { + if len(filters.AllowedAreaIDs) == 0 { + db = db.Where("1 = 0") + } else { + db = db.Where("warehouses.area_id IN ?", filters.AllowedAreaIDs) + } + } } if filters.StartDate != "" { diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index c4883b72..0e3dbada 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -9,6 +9,7 @@ import ( "strings" "time" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto" repportRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations" @@ -40,6 +41,7 @@ type RepportService interface { GetDebtSupplier(ctx *fiber.Ctx, params *validation.DebtSupplierQuery) ([]dto.DebtSupplierDTO, int64, error) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error) GetProductionResult(ctx *fiber.Ctx, params *validation.ProductionResultQuery) ([]dto.ProductionResultDTO, int64, error) + DB() *gorm.DB } type repportService struct { @@ -95,6 +97,10 @@ func NewRepportService( } } +func (s *repportService) DB() *gorm.DB { + return s.ExpenseRealizationRepo.DB() +} + func (s *repportService) GetExpense(c *fiber.Ctx, params *validation.ExpenseQuery) ([]dto.RepportExpenseListDTO, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err @@ -1303,6 +1309,36 @@ func (s *repportService) parseHppPerKandangQuery(ctx *fiber.Ctx) (*validation.Hp return nil, dto.HppPerKandangFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error()) } + locationScope, err := m.ResolveLocationScope(ctx, s.ExpenseRealizationRepo.DB()) + if err != nil { + return nil, dto.HppPerKandangFiltersDTO{}, err + } + areaScope, err := m.ResolveAreaScope(ctx, s.ExpenseRealizationRepo.DB()) + if err != nil { + return nil, dto.HppPerKandangFiltersDTO{}, err + } + + if locationScope.Restrict { + allowed := toInt64Slice(locationScope.IDs) + if len(allowed) == 0 { + locationIDs = []int64{-1} + } else if len(locationIDs) > 0 { + locationIDs = intersectInt64(locationIDs, allowed) + } else { + locationIDs = allowed + } + } + if areaScope.Restrict { + allowed := toInt64Slice(areaScope.IDs) + if len(allowed) == 0 { + areaIDs = []int64{-1} + } else if len(areaIDs) > 0 { + areaIDs = intersectInt64(areaIDs, allowed) + } else { + areaIDs = allowed + } + } + weightMin, err := parseOptionalFloat64(rawWeightMin) if err != nil { return nil, dto.HppPerKandangFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error()) @@ -1366,6 +1402,34 @@ func parseCommaSeparatedInt64s(raw string) ([]int64, error) { return result, nil } +func toInt64Slice(ids []uint) []int64 { + if len(ids) == 0 { + return nil + } + out := make([]int64, 0, len(ids)) + for _, id := range ids { + out = append(out, int64(id)) + } + return out +} + +func intersectInt64(a, b []int64) []int64 { + if len(a) == 0 || len(b) == 0 { + return nil + } + set := make(map[int64]struct{}, len(b)) + for _, id := range b { + set[id] = struct{}{} + } + out := make([]int64, 0, len(a)) + for _, id := range a { + if _, ok := set[id]; ok { + out = append(out, id) + } + } + return out +} + func parseOptionalFloat64(raw string) (*float64, error) { raw = strings.TrimSpace(raw) if raw == "" { diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index 6d50f3e6..4c1d7356 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -13,6 +13,8 @@ type ExpenseQuery struct { AreaId int64 `query:"area_id" validate:"omitempty"` LocationId int64 `query:"location_id" validate:"omitempty"` RealizationDate string `query:"realization_date" validate:"omitempty"` + AllowedAreaIDs []int64 `query:"-"` + AllowedLocationIDs []int64 `query:"-"` } type MarketingQuery struct { @@ -42,6 +44,7 @@ type PurchaseSupplierQuery struct { EndDate string `query:"end_date" validate:"omitempty"` SortBy string `query:"sort_by" validate:"omitempty"` FilterBy string `query:"filter_by" validate:"omitempty"` + AllowedAreaIDs []int64 `query:"-"` } type DebtSupplierQuery struct { diff --git a/internal/modules/sso/controllers/master_data.controller.go b/internal/modules/sso/controllers/master_data.controller.go new file mode 100644 index 00000000..2364011f --- /dev/null +++ b/internal/modules/sso/controllers/master_data.controller.go @@ -0,0 +1,331 @@ +package controllers + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "strconv" + "strings" + "sync" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/redis/go-redis/v9" + "gorm.io/gorm" + + "gitlab.com/mbugroup/lti-api.git/internal/config" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier" + "gitlab.com/mbugroup/lti-api.git/internal/response" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +type MasterDataController struct { + db *gorm.DB + redis *redis.Client + clients map[string]config.SSOClientConfig + drift time.Duration + nonceTTL time.Duration + localNonce sync.Map +} + +type masterArea struct { + ID uint `json:"id"` + Name string `json:"name"` +} + +type masterLocation struct { + ID uint `json:"id"` + Name string `json:"name"` + AreaID uint `json:"area_id"` +} + +func NewMasterDataController(db *gorm.DB, redis *redis.Client, clients map[string]config.SSOClientConfig) *MasterDataController { + normalized := make(map[string]config.SSOClientConfig, len(clients)) + for alias, cfg := range clients { + alias = strings.ToLower(strings.TrimSpace(alias)) + normalized[alias] = cfg + } + + drift := config.SSOUserSyncDrift + if drift <= 0 { + drift = 2 * time.Minute + } + + nonceTTL := config.SSOUserSyncNonceTTL + if nonceTTL <= 0 { + nonceTTL = 10 * time.Minute + } + + return &MasterDataController{ + db: db, + redis: redis, + clients: normalized, + drift: drift, + nonceTTL: nonceTTL, + } +} + +func (h *MasterDataController) GetAreas(c *fiber.Ctx) error { + if _, _, err := h.authenticate(c, nil); err != nil { + return err + } + + search := strings.TrimSpace(c.Query("search", "")) + ids := parseUintList(c.Query("ids", "")) + + query := h.db.WithContext(c.Context()). + Model(&entity.Area{}). + Where("deleted_at IS NULL") + if search != "" { + query = query.Where("name ILIKE ?", "%"+search+"%") + } + if len(ids) > 0 { + query = query.Where("id IN ?", ids) + } + + var areas []masterArea + if err := query.Order("name ASC").Find(&areas).Error; err != nil { + utils.Log.WithError(err).Error("failed to fetch areas for master data") + return fiber.NewError(fiber.StatusInternalServerError, "failed to fetch areas") + } + + return c.Status(fiber.StatusOK).JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get areas successfully", + Data: areas, + }) +} + +func (h *MasterDataController) GetLocations(c *fiber.Ctx) error { + if _, _, err := h.authenticate(c, nil); err != nil { + return err + } + + search := strings.TrimSpace(c.Query("search", "")) + areaIDs := parseUintList(c.Query("area_ids", "")) + ids := parseUintList(c.Query("ids", "")) + + query := h.db.WithContext(c.Context()). + Model(&entity.Location{}). + Where("deleted_at IS NULL") + if search != "" { + query = query.Where("name ILIKE ?", "%"+search+"%") + } + if len(areaIDs) > 0 { + query = query.Where("area_id IN ?", areaIDs) + } + if len(ids) > 0 { + query = query.Where("id IN ?", ids) + } + + var locations []masterLocation + if err := query.Order("name ASC").Find(&locations).Error; err != nil { + utils.Log.WithError(err).Error("failed to fetch locations for master data") + return fiber.NewError(fiber.StatusInternalServerError, "failed to fetch locations") + } + + return c.Status(fiber.StatusOK).JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get locations successfully", + Data: locations, + }) +} + +func (h *MasterDataController) authenticate(c *fiber.Ctx, body []byte) (string, config.SSOClientConfig, error) { + rawAlias := strings.TrimSpace(c.Get("X-Sync-Client")) + if rawAlias == "" { + return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "missing sync client header") + } + + aliasKey := strings.ToLower(rawAlias) + clientCfg, ok := h.clients[aliasKey] + if !ok { + return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "unknown sync client") + } + + if err := h.verifyAuthorization(c, aliasKey); err != nil { + return "", config.SSOClientConfig{}, err + } + + secret := strings.TrimSpace(clientCfg.SyncSecret) + if secret == "" { + return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "sync secret not configured") + } + + timestamp := strings.TrimSpace(c.Get("X-Sync-Timestamp")) + nonce := strings.TrimSpace(c.Get("X-Sync-Nonce")) + signature := strings.TrimSpace(c.Get("X-Sync-Signature")) + if timestamp == "" || nonce == "" || signature == "" { + return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "missing signature headers") + } + if len(nonce) < 16 { + return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "nonce too short") + } + + ts, err := strconv.ParseInt(timestamp, 10, 64) + if err != nil { + return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusBadRequest, "invalid timestamp") + } + + msgTime := time.Unix(ts, 0).UTC() + now := time.Now().UTC() + drift := now.Sub(msgTime) + if drift > h.drift || drift < -h.drift { + return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "timestamp outside allowed window") + } + + providedSig, err := decodeMasterSignature(signature) + if err != nil { + return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "invalid signature encoding") + } + + expectedSignature := calculateSignature(secret, rawAlias, timestamp, nonce, body) + if !hmac.Equal(providedSig, expectedSignature) { + return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "invalid signature") + } + + if err := h.registerNonce(c.Context(), aliasKey, nonce); err != nil { + return "", config.SSOClientConfig{}, err + } + + return aliasKey, clientCfg, nil +} + +func (h *MasterDataController) verifyAuthorization(c *fiber.Ctx, alias string) error { + authHeader := strings.TrimSpace(c.Get(fiber.HeaderAuthorization)) + if authHeader == "" { + return fiber.NewError(fiber.StatusUnauthorized, "missing authorization header") + } + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + return fiber.NewError(fiber.StatusUnauthorized, "invalid authorization header") + } + + token := strings.TrimSpace(parts[1]) + if token == "" { + return fiber.NewError(fiber.StatusUnauthorized, "invalid authorization header") + } + + verification, err := sso.VerifyAccessToken(token) + if err != nil { + return fiber.NewError(fiber.StatusUnauthorized, "invalid access token") + } + + if verification.ServiceAlias == "" || verification.ServiceAlias != alias { + return fiber.NewError(fiber.StatusUnauthorized, "service subject mismatch") + } + if !hasAnyScope(verification.Claims.Scopes(), []string{"sync.master", "sync.users"}) { + return fiber.NewError(fiber.StatusForbidden, "missing sync scope") + } + return nil +} + +func (h *MasterDataController) registerNonce(ctx context.Context, alias, nonce string) error { + ttl := h.nonceTTL + if ttl <= 0 { + ttl = 10 * time.Minute + } + + key := fmt.Sprintf("sso:sync:%s:%s", alias, nonce) + if h.redis != nil { + stored, err := h.redis.SetNX(ctx, key, "1", ttl).Result() + if err == nil { + if !stored { + return fiber.NewError(fiber.StatusUnauthorized, "nonce already used") + } + return nil + } + utils.Log.WithError(err).Warn("store sync nonce failed") + } + + now := time.Now().UTC() + if expRaw, ok := h.localNonce.Load(key); ok { + if expTime, ok := expRaw.(time.Time); ok && expTime.After(now) { + return fiber.NewError(fiber.StatusUnauthorized, "nonce already used") + } + } + h.localNonce.Store(key, now.Add(ttl)) + return nil +} + +func calculateSignature(secret, alias, timestamp, nonce string, body []byte) []byte { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(alias)) + mac.Write([]byte("\n")) + mac.Write([]byte(timestamp)) + mac.Write([]byte("\n")) + mac.Write([]byte(nonce)) + mac.Write([]byte("\n")) + if len(body) > 0 { + mac.Write(body) + } + return mac.Sum(nil) +} + +func decodeMasterSignature(sig string) ([]byte, error) { + sig = strings.TrimSpace(sig) + if sig == "" { + return nil, errors.New("empty signature") + } + if decoded, err := hex.DecodeString(sig); err == nil { + return decoded, nil + } + if decoded, err := base64.StdEncoding.DecodeString(sig); err == nil { + return decoded, nil + } + if decoded, err := base64.URLEncoding.DecodeString(sig); err == nil { + return decoded, nil + } + return nil, errors.New("unrecognized signature encoding") +} + +func parseUintList(raw string) []uint { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + out := make([]uint, 0, len(parts)) + seen := make(map[uint]struct{}, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + val, err := strconv.ParseUint(part, 10, 64) + if err != nil || val == 0 { + continue + } + if _, ok := seen[uint(val)]; ok { + continue + } + seen[uint(val)] = struct{}{} + out = append(out, uint(val)) + } + return out +} + +func hasAnyScope(scopes []string, targets []string) bool { + if len(scopes) == 0 || len(targets) == 0 { + return false + } + for _, scope := range scopes { + scope = strings.ToLower(strings.TrimSpace(scope)) + if scope == "" { + continue + } + for _, target := range targets { + if scope == strings.ToLower(strings.TrimSpace(target)) { + return true + } + } + } + return false +} diff --git a/internal/modules/sso/controllers/sso.controller.go b/internal/modules/sso/controllers/sso.controller.go index 410e9577..b49d73e5 100644 --- a/internal/modules/sso/controllers/sso.controller.go +++ b/internal/modules/sso/controllers/sso.controller.go @@ -16,7 +16,7 @@ import ( "gitlab.com/mbugroup/lti-api.git/internal/config" "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session" - "gitlab.com/mbugroup/lti-api.git/internal/sso" + sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier" "gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils/secure" ) diff --git a/internal/modules/sso/controllers/user_sync.controller.go b/internal/modules/sso/controllers/user_sync.controller.go index 9aeb9555..72c7768a 100644 --- a/internal/modules/sso/controllers/user_sync.controller.go +++ b/internal/modules/sso/controllers/user_sync.controller.go @@ -9,23 +9,24 @@ import ( "encoding/json" "errors" "fmt" - "github.com/go-playground/validator/v10" - "github.com/gofiber/fiber/v2" - "github.com/redis/go-redis/v9" - "github.com/sirupsen/logrus" - "gorm.io/gorm" "strconv" "strings" "sync" "time" + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/redis/go-redis/v9" + "github.com/sirupsen/logrus" + "gorm.io/gorm" + "gitlab.com/mbugroup/lti-api.git/internal/config" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session" + sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier" "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" userRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" "gitlab.com/mbugroup/lti-api.git/internal/response" - "gitlab.com/mbugroup/lti-api.git/internal/sso" "gitlab.com/mbugroup/lti-api.git/internal/utils" ) diff --git a/internal/modules/sso/route.go b/internal/modules/sso/route.go index 3f2a699e..a864611e 100644 --- a/internal/modules/sso/route.go +++ b/internal/modules/sso/route.go @@ -26,6 +26,7 @@ func Routes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { ctrl := ssoController.NewController(&http.Client{Timeout: 10 * time.Second}, store, session.GetRevocationStore()) userRepo := userRepository.NewUserRepository(db) syncCtrl := ssoController.NewUserSyncController(validate, userRepo, cache.Redis(), config.SSOClients) + masterCtrl := ssoController.NewMasterDataController(db, cache.Redis(), config.SSOClients) group := router.Group("/sso") group.Get("/start", middleware.NewLimiter(30, time.Minute), ctrl.Start) @@ -34,4 +35,6 @@ func Routes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { group.Post("/refresh", middleware.NewLimiter(60, time.Minute), ctrl.Refresh) group.Post("/logout", middleware.NewLimiter(60, time.Minute), ctrl.Logout) group.Post("/users/sync", middleware.NewLimiter(30, time.Minute), syncCtrl.Sync) + group.Get("/master/areas", middleware.NewLimiter(60, time.Minute), masterCtrl.GetAreas) + group.Get("/master/locations", middleware.NewLimiter(60, time.Minute), masterCtrl.GetLocations) } diff --git a/internal/sso/profile.go b/internal/modules/sso/verifier/profile.go similarity index 95% rename from internal/sso/profile.go rename to internal/modules/sso/verifier/profile.go index a211fc74..52b7ef5a 100644 --- a/internal/sso/profile.go +++ b/internal/modules/sso/verifier/profile.go @@ -49,6 +49,10 @@ type Role struct { ClientID uint ClientAlias string ClientName string + AllArea bool + AllLocation bool + AreaIDs []uint + LocationIDs []uint Permissions []Permission RawReference json.RawMessage `json:"-"` } @@ -162,6 +166,10 @@ func fetchProfileFromSSO(ctx context.Context, token string) (*UserProfile, error ClientAlias: strings.TrimSpace(r.Client.Alias), ClientName: strings.TrimSpace(r.Client.Name), ClientID: uint(r.Client.ID), + AllArea: r.AllArea, + AllLocation: r.AllLocation, + AreaIDs: r.AreaIDs, + LocationIDs: r.LocationIDs, } rolePerms := make([]Permission, 0, len(r.Permissions)) for _, p := range r.Permissions { @@ -288,6 +296,10 @@ type userInfoRole struct { ID int64 `json:"id"` Key string `json:"key"` Name string `json:"name"` + AllArea bool `json:"all_area"` + AllLocation bool `json:"all_location"` + AreaIDs []uint `json:"area_ids"` + LocationIDs []uint `json:"location_ids"` Client userInfoClient `json:"client"` Permissions []userInfoPermRaw `json:"permissions"` } diff --git a/internal/sso/verifier.go b/internal/modules/sso/verifier/verifier.go similarity index 100% rename from internal/sso/verifier.go rename to internal/modules/sso/verifier/verifier.go From a3e9017e29219b58bf3e84aec18c6a31a813e3b5 Mon Sep 17 00:00:00 2001 From: ragilap Date: Thu, 15 Jan 2026 14:43:59 +0700 Subject: [PATCH 04/37] [FIX/BE-US] changes role to user and query --- internal/middleware/auth.go | 32 +- internal/middleware/role_scope.go | 501 ++++++++++++++---- .../closings/services/closing.service.go | 57 +- .../services/adjustment.service.go | 28 +- .../salesorder_delivery_product.repository.go | 9 + .../services/deliveryorder.service.go | 32 ++ .../marketing/services/salesorder.service.go | 34 +- .../kandangs/services/kandang.service.go | 15 + .../warehouses/services/warehouse.service.go | 32 ++ .../recordings/services/recording.service.go | 40 ++ .../services/transfer_laying.service.go | 34 ++ .../repositories/uniformity.repository.go | 12 +- .../services/uniformity.service.go | 37 +- .../controllers/repport.controller.go | 16 + .../validations/repport.validation.go | 1 + internal/modules/sso/verifier/profile.go | 72 ++- 16 files changed, 805 insertions(+), 147 deletions(-) diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 1b670c14..b7229382 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -24,6 +24,10 @@ type AuthContext struct { User *entity.User Roles []sso.Role Permissions map[string]struct{} + UserAreaIDs []uint + UserLocationIDs []uint + UserAllArea bool + UserAllLocation bool } // Auth validates the incoming request against the central SSO access token and @@ -67,15 +71,19 @@ func Auth(userService service.UserService, requiredScopes ...string) fiber.Handl var roles []sso.Role permissions := make(map[string]struct{}) + var profile *sso.UserProfile if verification.UserID != 0 { - if profile, err := sso.FetchProfile(c.Context(), token, verification); err != nil { + if p, err := sso.FetchProfile(c.Context(), token, verification); err != nil { utils.Log.WithError(err).Warn("auth: failed to fetch sso profile") - } else if profile != nil { - roles = profile.Roles - for _, perm := range profile.PermissionNames() { - if perm != "" { - permissions[perm] = struct{}{} - } + } else { + profile = p + } + } + if profile != nil { + roles = profile.Roles + for _, perm := range profile.PermissionNames() { + if perm != "" { + permissions[perm] = struct{}{} } } } @@ -86,6 +94,16 @@ func Auth(userService service.UserService, requiredScopes ...string) fiber.Handl User: user, Roles: roles, Permissions: permissions, + UserAreaIDs: nil, + UserLocationIDs: nil, + UserAllArea: false, + UserAllLocation: false, + } + if profile != nil { + ctx.UserAreaIDs = profile.AreaIDs + ctx.UserLocationIDs = profile.LocationIDs + ctx.UserAllArea = profile.AllArea + ctx.UserAllLocation = profile.AllLocation } c.Locals(authContextLocalsKey, ctx) diff --git a/internal/middleware/role_scope.go b/internal/middleware/role_scope.go index f00e12d9..155d2e6a 100644 --- a/internal/middleware/role_scope.go +++ b/internal/middleware/role_scope.go @@ -2,12 +2,10 @@ package middleware import ( "errors" - "strings" "github.com/gofiber/fiber/v2" "gorm.io/gorm" - "gitlab.com/mbugroup/lti-api.git/internal/config" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" ) @@ -86,48 +84,24 @@ func ResolveLocationScope(c *fiber.Ctx, db *gorm.DB) (ScopeFilter, error) { func collectRoleScope(c *fiber.Ctx) (roleScope, error) { ctx, ok := AuthDetails(c) - if !ok || ctx == nil || len(ctx.Roles) == 0 { + if !ok || ctx == nil { return roleScope{}, nil } - clientAlias := resolveClientAlias(ctx) - - scope := roleScope{} - areaSet := make(map[uint]struct{}) - locationSet := make(map[uint]struct{}) - - for _, role := range ctx.Roles { - if clientAlias != "" && !strings.EqualFold(strings.TrimSpace(role.ClientAlias), clientAlias) { - continue - } - scope.hasAnyScopes = true - if role.AllArea { - scope.allArea = true - } - if role.AllLocation { - scope.allLocation = true - } - for _, id := range role.AreaIDs { - if id == 0 { - continue - } - areaSet[id] = struct{}{} - } - for _, id := range role.LocationIDs { - if id == 0 { - continue - } - locationSet[id] = struct{}{} - } + userAreaIDs := uniqueUint(ctx.UserAreaIDs) + userLocationIDs := uniqueUint(ctx.UserLocationIDs) + userScope := roleScope{ + allArea: ctx.UserAllArea, + allLocation: ctx.UserAllLocation, + areaIDs: userAreaIDs, + locationIDs: userLocationIDs, + hasAnyScopes: ctx.UserAllArea || ctx.UserAllLocation || len(userAreaIDs) > 0 || len(userLocationIDs) > 0, + } + if userScope.hasAnyScopes { + return userScope, nil } - scope.areaIDs = keysUint(areaSet) - scope.locationIDs = keysUint(locationSet) - - scope.hasAnyScopes = scope.hasAnyScopes && - (scope.allArea || scope.allLocation || len(scope.areaIDs) > 0 || len(scope.locationIDs) > 0) - - return scope, nil + return roleScope{}, nil } func areaIDsByLocationIDs(db *gorm.DB, locationIDs []uint) ([]uint, error) { @@ -204,70 +178,7 @@ func uniqueUint(ids []uint) []uint { return result } -func keysUint(set map[uint]struct{}) []uint { - if len(set) == 0 { - return nil - } - out := make([]uint, 0, len(set)) - for id := range set { - out = append(out, id) - } - return out -} -func resolveClientAlias(ctx *AuthContext) string { - if ctx == nil || ctx.Verification == nil || ctx.Verification.Claims == nil { - return "" - } - - scopes := ctx.Verification.Claims.Scopes() - if len(scopes) == 0 { - return "" - } - - seen := make(map[string]struct{}) - for _, scope := range scopes { - scope = strings.ToLower(strings.TrimSpace(scope)) - if scope == "" { - continue - } - prefix := scope - if idx := strings.IndexAny(prefix, ".:"); idx > 0 { - prefix = prefix[:idx] - } - prefix = strings.TrimSpace(prefix) - if prefix == "" { - continue - } - if alias := matchAlias(prefix); alias != "" { - seen[alias] = struct{}{} - } - } - - if len(seen) != 1 { - return "" - } - for alias := range seen { - return alias - } - return "" -} - -func matchAlias(alias string) string { - alias = strings.ToLower(strings.TrimSpace(alias)) - if alias == "" { - return "" - } - if _, ok := config.SSOClients[alias]; ok { - return alias - } - for key := range config.SSOClients { - if strings.EqualFold(key, alias) { - return strings.ToLower(strings.TrimSpace(key)) - } - } - return "" -} func ApplyScopeFilter(db *gorm.DB, scope ScopeFilter, column string) *gorm.DB { if db == nil || !scope.Restrict { @@ -278,3 +189,389 @@ func ApplyScopeFilter(db *gorm.DB, scope ScopeFilter, column string) *gorm.DB { } return db.Where(column+" IN ?", scope.IDs) } + +func EnsureWarehouseAccess(c *fiber.Ctx, db *gorm.DB, warehouseID uint) error { + if warehouseID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid warehouse id") + } + if db == nil { + return fiber.NewError(fiber.StatusInternalServerError, "Database not configured") + } + + scope, err := ResolveLocationScope(c, db) + if err != nil || !scope.Restrict { + return err + } + if len(scope.IDs) == 0 { + return fiber.NewError(fiber.StatusNotFound, "Warehouse not found") + } + + var count int64 + if err := ApplyScopeFilter( + db.WithContext(c.Context()). + Model(&entity.Warehouse{}). + Where("id = ?", warehouseID), + scope, + "warehouses.location_id", + ).Count(&count).Error; err != nil { + return err + } + if count == 0 { + return fiber.NewError(fiber.StatusNotFound, "Warehouse not found") + } + return nil +} + +func EnsureAreaAccess(c *fiber.Ctx, db *gorm.DB, areaID uint) error { + if areaID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid area id") + } + if db == nil { + return fiber.NewError(fiber.StatusInternalServerError, "Database not configured") + } + + scope, err := ResolveAreaScope(c, db) + if err != nil || !scope.Restrict { + return err + } + if len(scope.IDs) == 0 { + return fiber.NewError(fiber.StatusNotFound, "Area not found") + } + + var count int64 + if err := ApplyScopeFilter( + db.WithContext(c.Context()). + Model(&entity.Area{}). + Where("id = ?", areaID), + scope, + "areas.id", + ).Count(&count).Error; err != nil { + return err + } + if count == 0 { + return fiber.NewError(fiber.StatusNotFound, "Area not found") + } + return nil +} + +func EnsureLocationAccess(c *fiber.Ctx, db *gorm.DB, locationID uint) error { + if locationID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid location id") + } + if db == nil { + return fiber.NewError(fiber.StatusInternalServerError, "Database not configured") + } + + scope, err := ResolveLocationScope(c, db) + if err != nil || !scope.Restrict { + return err + } + if len(scope.IDs) == 0 { + return fiber.NewError(fiber.StatusNotFound, "Location not found") + } + + var count int64 + if err := ApplyScopeFilter( + db.WithContext(c.Context()). + Model(&entity.Location{}). + Where("id = ?", locationID), + scope, + "locations.id", + ).Count(&count).Error; err != nil { + return err + } + if count == 0 { + return fiber.NewError(fiber.StatusNotFound, "Location not found") + } + return nil +} + +func EnsureKandangAccess(c *fiber.Ctx, db *gorm.DB, kandangID uint) error { + if kandangID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid kandang id") + } + if db == nil { + return fiber.NewError(fiber.StatusInternalServerError, "Database not configured") + } + + scope, err := ResolveLocationScope(c, db) + if err != nil || !scope.Restrict { + return err + } + if len(scope.IDs) == 0 { + return fiber.NewError(fiber.StatusNotFound, "Kandang not found") + } + + var count int64 + if err := ApplyScopeFilter( + db.WithContext(c.Context()). + Model(&entity.Kandang{}). + Where("id = ?", kandangID), + scope, + "kandangs.location_id", + ).Count(&count).Error; err != nil { + return err + } + if count == 0 { + return fiber.NewError(fiber.StatusNotFound, "Kandang not found") + } + return nil +} + +func EnsureProductWarehouseAccess(c *fiber.Ctx, db *gorm.DB, productWarehouseID uint) error { + if productWarehouseID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid product warehouse id") + } + if db == nil { + return fiber.NewError(fiber.StatusInternalServerError, "Database not configured") + } + + scope, err := ResolveLocationScope(c, db) + if err != nil || !scope.Restrict { + return err + } + if len(scope.IDs) == 0 { + return fiber.NewError(fiber.StatusNotFound, "Product warehouse not found") + } + + var count int64 + q := db.WithContext(c.Context()). + Table("product_warehouses pw"). + Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). + Where("pw.id = ?", productWarehouseID) + q = ApplyScopeFilter(q, scope, "w.location_id") + if err := q.Count(&count).Error; err != nil { + return err + } + if count == 0 { + return fiber.NewError(fiber.StatusNotFound, "Product warehouse not found") + } + return nil +} + +func EnsureStockLogAccess(c *fiber.Ctx, db *gorm.DB, stockLogID uint) error { + if stockLogID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid stock log id") + } + if db == nil { + return fiber.NewError(fiber.StatusInternalServerError, "Database not configured") + } + + scope, err := ResolveLocationScope(c, db) + if err != nil || !scope.Restrict { + return err + } + if len(scope.IDs) == 0 { + return fiber.NewError(fiber.StatusNotFound, "Stock log not found") + } + + var count int64 + q := db.WithContext(c.Context()). + Table("stock_logs sl"). + Joins("JOIN product_warehouses pw ON pw.id = sl.product_warehouse_id"). + Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). + Where("sl.id = ?", stockLogID) + q = ApplyScopeFilter(q, scope, "w.location_id") + if err := q.Count(&count).Error; err != nil { + return err + } + if count == 0 { + return fiber.NewError(fiber.StatusNotFound, "Stock log not found") + } + return nil +} + +func EnsureMarketingAccess(c *fiber.Ctx, db *gorm.DB, marketingID uint) error { + if marketingID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid marketing id") + } + if db == nil { + return fiber.NewError(fiber.StatusInternalServerError, "Database not configured") + } + + scope, err := ResolveLocationScope(c, db) + if err != nil || !scope.Restrict { + return err + } + if len(scope.IDs) == 0 { + return fiber.NewError(fiber.StatusNotFound, "Marketing not found") + } + + var count int64 + q := db.WithContext(c.Context()). + Table("marketings m"). + Joins("JOIN marketing_products mp ON mp.marketing_id = m.id"). + Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id"). + Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). + Where("m.id = ?", marketingID) + q = ApplyScopeFilter(q, scope, "w.location_id") + if err := q.Count(&count).Error; err != nil { + return err + } + if count == 0 { + return fiber.NewError(fiber.StatusNotFound, "Marketing not found") + } + return nil +} + +func EnsureRecordingAccess(c *fiber.Ctx, db *gorm.DB, recordingID uint) error { + if recordingID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid recording id") + } + if db == nil { + return fiber.NewError(fiber.StatusInternalServerError, "Database not configured") + } + + scope, err := ResolveLocationScope(c, db) + if err != nil || !scope.Restrict { + return err + } + if len(scope.IDs) == 0 { + return fiber.NewError(fiber.StatusNotFound, "Recording not found") + } + + var count int64 + q := db.WithContext(c.Context()). + Table("recordings r"). + Joins("JOIN project_flock_kandangs pfk ON pfk.id = r.project_flock_kandangs_id"). + Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id"). + Where("r.id = ?", recordingID) + q = ApplyScopeFilter(q, scope, "pf.location_id") + if err := q.Count(&count).Error; err != nil { + return err + } + if count == 0 { + return fiber.NewError(fiber.StatusNotFound, "Recording not found") + } + return nil +} + +func EnsureUniformityAccess(c *fiber.Ctx, db *gorm.DB, uniformityID uint) error { + if uniformityID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid uniformity id") + } + if db == nil { + return fiber.NewError(fiber.StatusInternalServerError, "Database not configured") + } + + scope, err := ResolveLocationScope(c, db) + if err != nil || !scope.Restrict { + return err + } + if len(scope.IDs) == 0 { + return fiber.NewError(fiber.StatusNotFound, "Uniformity not found") + } + + var count int64 + q := db.WithContext(c.Context()). + Table("project_flock_kandang_uniformities u"). + Joins("JOIN project_flock_kandangs pfk ON pfk.id = u.project_flock_kandang_id"). + Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id"). + Where("u.id = ?", uniformityID) + q = ApplyScopeFilter(q, scope, "pf.location_id") + if err := q.Count(&count).Error; err != nil { + return err + } + if count == 0 { + return fiber.NewError(fiber.StatusNotFound, "Uniformity not found") + } + return nil +} + +func EnsureLayingTransferAccess(c *fiber.Ctx, db *gorm.DB, transferID uint) error { + if transferID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid transfer id") + } + if db == nil { + return fiber.NewError(fiber.StatusInternalServerError, "Database not configured") + } + + scope, err := ResolveLocationScope(c, db) + if err != nil || !scope.Restrict { + return err + } + if len(scope.IDs) == 0 { + return fiber.NewError(fiber.StatusNotFound, "Transfer not found") + } + + var count int64 + q := db.WithContext(c.Context()). + Table("laying_transfers lt"). + Joins("JOIN project_flocks pf_from ON pf_from.id = lt.from_project_flock_id"). + Joins("JOIN project_flocks pf_to ON pf_to.id = lt.to_project_flock_id"). + Where("lt.id = ?", transferID). + Where("(pf_from.location_id IN ? OR pf_to.location_id IN ?)", scope.IDs, scope.IDs) + if err := q.Count(&count).Error; err != nil { + return err + } + if count == 0 { + return fiber.NewError(fiber.StatusNotFound, "Transfer not found") + } + return nil +} + +func EnsureProjectFlockAccess(c *fiber.Ctx, db *gorm.DB, projectFlockID uint) error { + if projectFlockID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id") + } + if db == nil { + return fiber.NewError(fiber.StatusInternalServerError, "Database not configured") + } + + scope, err := ResolveLocationScope(c, db) + if err != nil || !scope.Restrict { + return err + } + if len(scope.IDs) == 0 { + return fiber.NewError(fiber.StatusNotFound, "Project Flock not found") + } + + var count int64 + if err := ApplyScopeFilter( + db.WithContext(c.Context()). + Model(&entity.ProjectFlock{}). + Where("id = ?", projectFlockID), + scope, + "project_flocks.location_id", + ).Count(&count).Error; err != nil { + return err + } + if count == 0 { + return fiber.NewError(fiber.StatusNotFound, "Project Flock not found") + } + return nil +} + +func EnsureProjectFlockKandangAccess(c *fiber.Ctx, db *gorm.DB, projectFlockID, projectFlockKandangID uint) error { + if projectFlockKandangID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid project flock kandang id") + } + if db == nil { + return fiber.NewError(fiber.StatusInternalServerError, "Database not configured") + } + + scope, err := ResolveLocationScope(c, db) + if err != nil || !scope.Restrict { + return err + } + if len(scope.IDs) == 0 { + return fiber.NewError(fiber.StatusNotFound, "Project Flock Kandang not found") + } + + var count int64 + q := db.WithContext(c.Context()). + Table("project_flock_kandangs"). + Joins("JOIN project_flocks ON project_flocks.id = project_flock_kandangs.project_flock_id"). + Where("project_flock_kandangs.id = ?", projectFlockKandangID) + if projectFlockID > 0 { + q = q.Where("project_flock_kandangs.project_flock_id = ?", projectFlockID) + } + q = ApplyScopeFilter(q, scope, "project_flocks.location_id") + if err := q.Count(&count).Error; err != nil { + return err + } + if count == 0 { + return fiber.NewError(fiber.StatusNotFound, "Project Flock Kandang not found") + } + return nil +} diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 38529b0d..16a889b5 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -12,6 +12,7 @@ import ( commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/dto" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/validations" @@ -98,10 +99,16 @@ func (s closingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]dto.Cl return nil, 0, err } + scope, err := m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, 0, err + } + offset := (params.Page - 1) * params.Limit closings, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withClosingRelations(db) + db = m.ApplyScopeFilter(db, scope, "project_flocks.location_id") if params.Search != "" { return db.Where("flock_name ILIKE ?", "%"+params.Search+"%") } @@ -128,6 +135,10 @@ func (s closingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]dto.Cl } func (s closingService) GetProjectFlockByID(c *fiber.Ctx, id uint) (*entity.ProjectFlock, error) { + if err := m.EnsureProjectFlockAccess(c, s.Repository.DB(), id); err != nil { + return nil, err + } + projectFlock, err := s.ProjectFlockRepo.GetByID(c.Context(), id, s.withRelations) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Project Flock not found") @@ -139,6 +150,13 @@ func (s closingService) GetProjectFlockByID(c *fiber.Ctx, id uint) (*entity.Proj } func (s closingService) GetPenjualan(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error) { + if projectFlockKandangID != nil { + if err := m.EnsureProjectFlockKandangAccess(c, s.Repository.DB(), projectFlockID, *projectFlockKandangID); err != nil { + return nil, err + } + } else if err := m.EnsureProjectFlockAccess(c, s.Repository.DB(), projectFlockID); err != nil { + return nil, err + } realisasi, err := s.MarketingDeliveryProductRepo.GetClosingPenjualan(c.Context(), projectFlockID, projectFlockKandangID) if err != nil { @@ -152,8 +170,8 @@ func (s closingService) GetPenjualan(c *fiber.Ctx, projectFlockID uint, projectF } func (s closingService) GetClosingSummary(c *fiber.Ctx, projectFlockID uint, kandangID *uint) (any, error) { - if projectFlockID == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id") + if err := m.EnsureProjectFlockAccess(c, s.Repository.DB(), projectFlockID); err != nil { + return nil, err } if kandangID != nil { @@ -299,8 +317,8 @@ func (s closingService) getClosingSummaryByKandang(ctx context.Context, projectF } func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, params *validation.ClosingSapronakQuery) ([]dto.ClosingSapronakItemDTO, int64, error) { - if projectFlockID == 0 { - return nil, 0, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id") + if err := m.EnsureProjectFlockAccess(c, s.Repository.DB(), projectFlockID); err != nil { + return nil, 0, err } if params == nil { @@ -322,14 +340,6 @@ func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, pa return nil, 0, fiber.NewError(fiber.StatusBadRequest, "type must be either incoming or outgoing") } - if _, err := s.Repository.GetByID(c.Context(), projectFlockID, nil); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, 0, fiber.NewError(fiber.StatusNotFound, "Project flock tidak ditemukan") - } - s.Log.Errorf("Failed get project flock %d for sapronak closing: %+v", projectFlockID, err) - return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") - } - warehouseIDs, err := s.getWarehouseIDsByProjectFlock(c.Context(), projectFlockID) if err != nil { s.Log.Errorf("Failed to fetch warehouses for project flock %d: %+v", projectFlockID, err) @@ -490,6 +500,14 @@ func (s closingService) getApprovalStatuses(ctx context.Context, projectFlockID } func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.OverheadListDTO, error) { + if projectFlockKandangID != nil { + if err := m.EnsureProjectFlockKandangAccess(c, s.Repository.DB(), projectFlockID, *projectFlockKandangID); err != nil { + return nil, err + } + } else if err := m.EnsureProjectFlockAccess(c, s.Repository.DB(), projectFlockID); err != nil { + return nil, err + } + budgets, err := s.ProjectBudgetRepo.GetByProjectFlockID(c.Context(), projectFlockID) if err != nil { return nil, err @@ -578,6 +596,9 @@ func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint, projectFl } func (s closingService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (*dto.ReportResponse, error) { + if err := m.EnsureProjectFlockAccess(c, s.Repository.DB(), projectFlockID); err != nil { + return nil, err + } if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "Project Flock", ID: &projectFlockID, Exists: s.ProjectFlockRepo.IdExists}, @@ -654,8 +675,12 @@ func (s closingService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (* } func (s closingService) GetExpeditionHPP(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.ExpeditionHPPDTO, error) { - if projectFlockID == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id") + if projectFlockKandangID != nil { + if err := m.EnsureProjectFlockKandangAccess(c, s.Repository.DB(), projectFlockID, *projectFlockKandangID); err != nil { + return nil, err + } + } else if err := m.EnsureProjectFlockAccess(c, s.Repository.DB(), projectFlockID); err != nil { + return nil, err } rows, err := s.Repository.GetExpeditionHPP(c.Context(), projectFlockID, projectFlockKandangID) @@ -686,8 +711,8 @@ func (s closingService) GetExpeditionHPP(c *fiber.Ctx, projectFlockID uint, proj } func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint, kandangID *uint) (*dto.ClosingProductionReportDTO, error) { - if projectFlockID == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id") + if err := m.EnsureProjectFlockAccess(c, s.Repository.DB(), projectFlockID); err != nil { + return nil, err } projectFlockKandangIDs, err := s.getProjectFlockKandangIDs(c.Context(), projectFlockID, kandangID) diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index 71b985c2..4347fd6c 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -74,6 +74,10 @@ func (s *adjustmentService) withRelations(db *gorm.DB) *gorm.DB { } func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.StockLog, error) { + if err := m.EnsureStockLogAccess(c, s.StockLogsRepository.DB(), id); err != nil { + return nil, err + } + stockLog, err := s.StockLogsRepository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { return s.withRelations(db).Preload("ProductWarehouse.Product.ProductCategory") }) @@ -101,6 +105,11 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e if err != nil { return nil, err } + + if err := m.EnsureWarehouseAccess(c, s.WarehouseRepo.DB(), uint(req.WarehouseID)); err != nil { + return nil, err + } + if err := common.EnsureRelations(c.Context(), common.RelationCheck{Name: "Product", ID: &req.ProductID, Exists: s.ProductRepo.IdExists}, common.RelationCheck{Name: "Warehouse", ID: &req.WarehouseID, Exists: s.WarehouseRepo.IdExists}, @@ -279,6 +288,11 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu if query.WarehouseID > 0 && !isWarehousesExist { return nil, 0, fiber.NewError(fiber.StatusNotFound, "Warehouse not found") } + if query.WarehouseID > 0 { + if err := m.EnsureWarehouseAccess(c, s.WarehouseRepo.DB(), uint(query.WarehouseID)); err != nil { + return nil, 0, err + } + } isProductsExist, err := s.ProductRepo.IdExists(c.Context(), uint(query.ProductID)) if err != nil { @@ -290,7 +304,19 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu } stockLogs, total, err := s.StockLogsRepository.GetAll(c.Context(), offset, query.Limit, func(db *gorm.DB) *gorm.DB { - + scope, err := m.ResolveLocationScope(c, s.StockLogsRepository.DB()) + if err != nil { + return db.Where("1 = 0") + } + if scope.Restrict { + if len(scope.IDs) == 0 { + return db.Where("1 = 0") + } + db = db. + Joins("JOIN product_warehouses pw ON pw.id = stock_logs.product_warehouse_id"). + Joins("JOIN warehouses w ON w.id = pw.warehouse_id") + db = m.ApplyScopeFilter(db, scope, "w.location_id") + } db = s.withRelations(db) db = db.Where("loggable_type = ?", string(utils.StockLogTypeAdjustment)) diff --git a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go index f14988b1..4998f82b 100644 --- a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go +++ b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go @@ -140,6 +140,15 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C Joins("JOIN marketings ON marketings.id = marketing_products.marketing_id"). Where("marketing_delivery_products.delivery_date IS NOT NULL") + if len(filters.AllowedLocationIDs) > 0 { + if !containsJoin(db, "product_warehouses") { + db = db.Joins("JOIN product_warehouses ON product_warehouses.id = marketing_products.product_warehouse_id") + } + db = db.Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = product_warehouses.project_flock_kandang_id"). + Joins("JOIN project_flocks ON project_flocks.id = project_flock_kandangs.project_flock_id"). + Where("project_flocks.location_id IN ?", filters.AllowedLocationIDs) + } + if filters.ProductId > 0 || filters.WarehouseId > 0 || filters.Search != "" || filters.MarketingType != "" { db = db.Joins("LEFT JOIN product_warehouses ON product_warehouses.id = marketing_products.product_warehouse_id") } diff --git a/internal/modules/marketing/services/deliveryorder.service.go b/internal/modules/marketing/services/deliveryorder.service.go index a1f4e1dd..9c197234 100644 --- a/internal/modules/marketing/services/deliveryorder.service.go +++ b/internal/modules/marketing/services/deliveryorder.service.go @@ -67,6 +67,10 @@ func (s deliveryOrdersService) withRelations(db *gorm.DB) *gorm.DB { } func (s deliveryOrdersService) getMarketingWithDeliveries(c *fiber.Ctx, marketingId uint) (*dto.MarketingDetailDTO, error) { + if err := m.EnsureMarketingAccess(c, s.MarketingRepo.DB(), marketingId); err != nil { + return nil, err + } + marketing, err := s.MarketingRepo.GetByID(c.Context(), marketingId, s.withRelations) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch marketing") @@ -91,6 +95,11 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO return nil, 0, err } + scope, err := m.ResolveLocationScope(c, s.MarketingRepo.DB()) + if err != nil { + return nil, 0, err + } + offset := (params.Page - 1) * params.Limit marketings, total, err := s.MarketingRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { @@ -102,6 +111,18 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO Preload("Products.ProductWarehouse.Warehouse"). Preload("Products.DeliveryProduct") + if scope.Restrict { + if len(scope.IDs) == 0 { + return db.Where("1 = 0") + } + db = db. + Joins("JOIN marketing_products mp ON mp.marketing_id = marketings.id"). + Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id"). + Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). + Where("w.location_id IN ?", scope.IDs). + Distinct("marketings.*") + } + if params.MarketingId != 0 { return db.Where("id = ?", params.MarketingId) } @@ -130,6 +151,9 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO } func (s deliveryOrdersService) GetOne(c *fiber.Ctx, id uint) (*dto.MarketingDetailDTO, error) { + if err := m.EnsureMarketingAccess(c, s.MarketingRepo.DB(), id); err != nil { + return nil, err + } marketing, err := s.MarketingRepo.GetByID(c.Context(), id, s.withRelations) if errors.Is(err, gorm.ErrRecordNotFound) { @@ -169,6 +193,10 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery return nil, err } + if err := m.EnsureMarketingAccess(c, s.MarketingRepo.DB(), req.MarketingId); err != nil { + return nil, err + } + if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "Marketing", ID: &req.MarketingId, Exists: s.MarketingRepo.IdExists}, ); err != nil { @@ -303,6 +331,10 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO return nil, err } + if err := m.EnsureMarketingAccess(c, s.MarketingRepo.DB(), id); err != nil { + return nil, err + } + if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "Marketing", ID: &id, Exists: s.MarketingRepo.IdExists}, ); err != nil { diff --git a/internal/modules/marketing/services/salesorder.service.go b/internal/modules/marketing/services/salesorder.service.go index d57b323e..e65fa9bb 100644 --- a/internal/modules/marketing/services/salesorder.service.go +++ b/internal/modules/marketing/services/salesorder.service.go @@ -70,6 +70,10 @@ func (s salesOrdersService) withRelations(db *gorm.DB) *gorm.DB { } func (s salesOrdersService) getOne(c *fiber.Ctx, id uint) (*entity.Marketing, error) { + if err := m.EnsureMarketingAccess(c, s.MarketingRepo.DB(), id); err != nil { + return nil, err + } + marketing, err := s.MarketingRepo.GetByID(c.Context(), id, s.withRelations) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "SalesOrders not found") @@ -108,6 +112,9 @@ func (s *salesOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) (*e } for _, item := range req.MarketingProducts { + if err := m.EnsureProductWarehouseAccess(c, s.MarketingRepo.DB(), item.ProductWarehouseId); err != nil { + return nil, err + } if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "ProductWarehouse", ID: &item.ProductWarehouseId, Exists: s.ProductWarehouseRepo.IdExists}, ); err != nil { @@ -197,6 +204,10 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u return nil, err } + if err := m.EnsureMarketingAccess(c, s.MarketingRepo.DB(), id); err != nil { + return nil, err + } + actorID, err := m.ActorIDFromContext(c) if err != nil { return nil, err @@ -219,11 +230,14 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u } if len(req.MarketingProducts) > 0 { - for _, item := range req.MarketingProducts { - if err := commonSvc.EnsureRelations(c.Context(), - commonSvc.RelationCheck{Name: "ProductWarehouse", ID: &item.ProductWarehouseId, Exists: s.ProductWarehouseRepo.IdExists}, - ); err != nil { - return nil, err + for _, item := range req.MarketingProducts { + if err := m.EnsureProductWarehouseAccess(c, s.MarketingRepo.DB(), item.ProductWarehouseId); err != nil { + return nil, err + } + if err := commonSvc.EnsureRelations(c.Context(), + commonSvc.RelationCheck{Name: "ProductWarehouse", ID: &item.ProductWarehouseId, Exists: s.ProductWarehouseRepo.IdExists}, + ); err != nil { + return nil, err } } } @@ -391,6 +405,10 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u } func (s salesOrdersService) DeleteOne(c *fiber.Ctx, id uint) error { + if err := m.EnsureMarketingAccess(c, s.MarketingRepo.DB(), id); err != nil { + return err + } + marketing, err := s.MarketingRepo.GetByID(c.Context(), id, s.withRelations) if errors.Is(err, gorm.ErrRecordNotFound) { @@ -455,6 +473,12 @@ func (s salesOrdersService) Approval(c *fiber.Ctx, req *validation.Approve) ([]e return nil, err } + for _, id := range req.ApprovableIds { + if err := m.EnsureMarketingAccess(c, s.MarketingRepo.DB(), id); err != nil { + return nil, err + } + } + actorID, err := m.ActorIDFromContext(c) if err != nil { return nil, err diff --git a/internal/modules/master/kandangs/services/kandang.service.go b/internal/modules/master/kandangs/services/kandang.service.go index b70577d6..d5b297e5 100644 --- a/internal/modules/master/kandangs/services/kandang.service.go +++ b/internal/modules/master/kandangs/services/kandang.service.go @@ -103,6 +103,9 @@ func (s *kandangService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit if err := s.Validate.Struct(req); err != nil { return nil, err } + if err := m.EnsureLocationAccess(c, s.Repository.DB(), req.LocationId); err != nil { + return nil, err + } if exists, err := s.Repository.NameExists(c.Context(), req.Name, nil); err != nil { s.Log.Errorf("Failed to check kandang name: %+v", err) @@ -177,6 +180,14 @@ func (s kandangService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) if err := s.Validate.Struct(req); err != nil { return nil, err } + if err := m.EnsureKandangAccess(c, s.Repository.DB(), id); err != nil { + return nil, err + } + if req.LocationId != nil { + if err := m.EnsureLocationAccess(c, s.Repository.DB(), *req.LocationId); err != nil { + return nil, err + } + } existing, err := s.Repository.GetByID(c.Context(), id, nil) if errors.Is(err, gorm.ErrRecordNotFound) { @@ -268,6 +279,10 @@ func (s kandangService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) } func (s kandangService) DeleteOne(c *fiber.Ctx, id uint) error { + if err := m.EnsureKandangAccess(c, s.Repository.DB(), id); err != nil { + return err + } + if err := s.Repository.DeleteOne(c.Context(), id); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, "Kandang not found") diff --git a/internal/modules/master/warehouses/services/warehouse.service.go b/internal/modules/master/warehouses/services/warehouse.service.go index aaa5ca7e..29cf402f 100644 --- a/internal/modules/master/warehouses/services/warehouse.service.go +++ b/internal/modules/master/warehouses/services/warehouse.service.go @@ -128,6 +128,19 @@ func (s *warehouseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent if err := validateWarehouseTypeRequirements(typ, &req.AreaId, req.LocationId, req.KandangId); err != nil { return nil, err } + if err := m.EnsureAreaAccess(c, s.Repository.DB(), req.AreaId); err != nil { + return nil, err + } + if req.LocationId != nil { + if err := m.EnsureLocationAccess(c, s.Repository.DB(), *req.LocationId); err != nil { + return nil, err + } + } + if req.KandangId != nil { + if err := m.EnsureKandangAccess(c, s.Repository.DB(), *req.KandangId); err != nil { + return nil, err + } + } //? Check relation area, location, and kandang if err := common.EnsureRelations(c.Context(), @@ -166,6 +179,21 @@ func (s warehouseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin if err := s.Validate.Struct(req); err != nil { return nil, err } + if req.AreaId != nil { + if err := m.EnsureAreaAccess(c, s.Repository.DB(), *req.AreaId); err != nil { + return nil, err + } + } + if req.LocationId != nil { + if err := m.EnsureLocationAccess(c, s.Repository.DB(), *req.LocationId); err != nil { + return nil, err + } + } + if req.KandangId != nil { + if err := m.EnsureKandangAccess(c, s.Repository.DB(), *req.KandangId); err != nil { + return nil, err + } + } existing, err := s.GetOne(c, id) if err != nil { @@ -243,6 +271,10 @@ func (s warehouseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } func (s warehouseService) DeleteOne(c *fiber.Ctx, id uint) error { + if err := m.EnsureWarehouseAccess(c, s.Repository.DB(), id); err != nil { + return err + } + if err := s.Repository.DeleteOne(c.Context(), id); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, "Warehouse not found") diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 88ed4cf7..e52aaddc 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -101,6 +101,16 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti return nil, 0, err } + scope, err := m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, 0, err + } + if params.ProjectFlockKandangId != 0 { + if err := m.EnsureProjectFlockKandangAccess(c, s.Repository.DB(), 0, params.ProjectFlockKandangId); err != nil { + return nil, 0, err + } + } + limit := params.Limit if limit == 0 { limit = 10 @@ -113,6 +123,15 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti recordings, total, err := s.Repository.GetAll(c.Context(), offset, limit, func(db *gorm.DB) *gorm.DB { db = s.Repository.WithRelations(db) + if scope.Restrict { + if len(scope.IDs) == 0 { + return db.Where("1 = 0") + } + db = db. + Joins("JOIN project_flock_kandangs pfk ON pfk.id = recordings.project_flock_kandangs_id"). + Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id") + db = m.ApplyScopeFilter(db, scope, "pf.location_id") + } if params.ProjectFlockKandangId != 0 { db = db.Where("project_flock_kandangs_id = ?", params.ProjectFlockKandangId) } @@ -133,6 +152,10 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti } func (s recordingService) GetOne(c *fiber.Ctx, id uint) (*entity.Recording, error) { + if err := m.EnsureRecordingAccess(c, s.Repository.DB(), id); err != nil { + return nil, err + } + recording, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { return s.Repository.WithRelations(db) }) @@ -156,6 +179,9 @@ func (s recordingService) GetNextDay(c *fiber.Ctx, projectFlockKandangId uint) ( if projectFlockKandangId == 0 { return 0, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required") } + if err := m.EnsureProjectFlockKandangAccess(c, s.Repository.DB(), 0, projectFlockKandangId); err != nil { + return 0, err + } db := s.Repository.DB().WithContext(c.Context()) next, err := s.Repository.GenerateNextDay(db, projectFlockKandangId) @@ -171,6 +197,9 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent if err := s.Validate.Struct(req); err != nil { return nil, err } + if err := m.EnsureProjectFlockKandangAccess(c, s.Repository.DB(), 0, req.ProjectFlockKandangId); err != nil { + return nil, err + } ctx := c.Context() recordTime := time.Now().UTC() @@ -320,6 +349,9 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin if err := s.Validate.Struct(req); err != nil { return nil, err } + if err := m.EnsureRecordingAccess(c, s.Repository.DB(), id); err != nil { + return nil, err + } if req.Stocks == nil && req.Depletions == nil && req.Eggs == nil { return s.GetOne(c, id) @@ -535,6 +567,11 @@ func (s recordingService) Approval(c *fiber.Ctx, req *validation.Approve) ([]ent if err := s.Validate.Struct(req); err != nil { return nil, err } + for _, id := range req.ApprovableIds { + if err := m.EnsureRecordingAccess(c, s.Repository.DB(), id); err != nil { + return nil, err + } + } actionValue := strings.ToUpper(strings.TrimSpace(req.Action)) var action entity.ApprovalAction @@ -612,6 +649,9 @@ func (s recordingService) Approval(c *fiber.Ctx, req *validation.Approve) ([]ent } func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { + if err := m.EnsureRecordingAccess(c, s.Repository.DB(), id); err != nil { + return err + } ctx := c.Context() return s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { diff --git a/internal/modules/production/transfer_layings/services/transfer_laying.service.go b/internal/modules/production/transfer_layings/services/transfer_laying.service.go index 9732ad75..bcafd489 100644 --- a/internal/modules/production/transfer_layings/services/transfer_laying.service.go +++ b/internal/modules/production/transfer_layings/services/transfer_laying.service.go @@ -107,10 +107,25 @@ func (s transferLayingService) GetAll(c *fiber.Ctx, params *validation.Query) ([ return nil, 0, err } + scope, err := m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, 0, err + } + offset := (params.Page - 1) * params.Limit transferLayings, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) + if scope.Restrict { + if len(scope.IDs) == 0 { + return db.Where("1 = 0") + } + db = db. + Joins("JOIN project_flocks pf_from ON pf_from.id = laying_transfers.from_project_flock_id"). + Joins("JOIN project_flocks pf_to ON pf_to.id = laying_transfers.to_project_flock_id"). + Where("(pf_from.location_id IN ? OR pf_to.location_id IN ?)", scope.IDs, scope.IDs). + Distinct("laying_transfers.*") + } db = db.Order("created_at DESC") return db }) @@ -134,6 +149,10 @@ func (s transferLayingService) GetAll(c *fiber.Ctx, params *validation.Query) ([ } func (s transferLayingService) GetOne(c *fiber.Ctx, id uint) (*entity.LayingTransfer, error) { + if err := m.EnsureLayingTransferAccess(c, s.Repository.DB(), id); err != nil { + return nil, err + } + transferLaying, err := s.Repository.GetByID(c.Context(), id, s.withRelations) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "TransferLaying not found") @@ -167,6 +186,12 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) if err := s.Validate.Struct(req); err != nil { return nil, err } + if err := m.EnsureProjectFlockAccess(c, s.Repository.DB(), req.SourceProjectFlockId); err != nil { + return nil, err + } + if err := m.EnsureProjectFlockAccess(c, s.Repository.DB(), req.TargetProjectFlockId); err != nil { + return nil, err + } actorID, err := m.ActorIDFromContext(c) if err != nil { @@ -375,6 +400,15 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, if err := s.Validate.Struct(req); err != nil { return nil, err } + if err := m.EnsureLayingTransferAccess(c, s.Repository.DB(), id); err != nil { + return nil, err + } + if err := m.EnsureProjectFlockAccess(c, s.Repository.DB(), req.SourceProjectFlockId); err != nil { + return nil, err + } + if err := m.EnsureProjectFlockAccess(c, s.Repository.DB(), req.TargetProjectFlockId); err != nil { + return nil, err + } existingTransfer, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { return db.Preload("Sources.ProductWarehouse").Preload("Targets") diff --git a/internal/modules/production/uniformities/repositories/uniformity.repository.go b/internal/modules/production/uniformities/repositories/uniformity.repository.go index 9641c650..8453bf84 100644 --- a/internal/modules/production/uniformities/repositories/uniformity.repository.go +++ b/internal/modules/production/uniformities/repositories/uniformity.repository.go @@ -12,7 +12,7 @@ import ( type UniformityRepository interface { repository.BaseRepository[entity.ProjectFlockKandangUniformity] - GetAllWithFilters(ctx context.Context, offset, limit int, params *validation.Query) ([]entity.ProjectFlockKandangUniformity, int64, error) + GetAllWithFilters(ctx context.Context, offset, limit int, params *validation.Query, modifiers ...func(*gorm.DB) *gorm.DB) ([]entity.ProjectFlockKandangUniformity, int64, error) WithDefaultRelations() func(*gorm.DB) *gorm.DB DeleteByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) error } @@ -27,9 +27,15 @@ func NewUniformityRepository(db *gorm.DB) UniformityRepository { } } -func (r *UniformityRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, params *validation.Query) ([]entity.ProjectFlockKandangUniformity, int64, error) { +func (r *UniformityRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, params *validation.Query, modifiers ...func(*gorm.DB) *gorm.DB) ([]entity.ProjectFlockKandangUniformity, int64, error) { return r.GetAll(ctx, offset, limit, func(db *gorm.DB) *gorm.DB { - return r.applyQueryFilters(r.WithDefaultRelations()(db), params) + db = r.applyQueryFilters(r.WithDefaultRelations()(db), params) + for _, modifier := range modifiers { + if modifier != nil { + db = modifier(db) + } + } + return db }) } diff --git a/internal/modules/production/uniformities/services/uniformity.service.go b/internal/modules/production/uniformities/services/uniformity.service.go index 92db84a3..51ea29a7 100644 --- a/internal/modules/production/uniformities/services/uniformity.service.go +++ b/internal/modules/production/uniformities/services/uniformity.service.go @@ -87,8 +87,24 @@ func (s uniformityService) GetAll(c *fiber.Ctx, params *validation.Query) ([]ent return nil, 0, err } + scope, err := m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, 0, err + } + offset := (params.Page - 1) * params.Limit - uniformitys, total, err := s.Repository.GetAllWithFilters(c.Context(), offset, params.Limit, params) + uniformitys, total, err := s.Repository.GetAllWithFilters(c.Context(), offset, params.Limit, params, func(db *gorm.DB) *gorm.DB { + if scope.Restrict { + if len(scope.IDs) == 0 { + return db.Where("1 = 0") + } + db = db. + Joins("JOIN project_flock_kandangs pfk ON pfk.id = project_flock_kandang_uniformities.project_flock_kandang_id"). + Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id") + db = m.ApplyScopeFilter(db, scope, "pf.location_id") + } + return db + }) if err != nil { s.Log.Errorf("Failed to get uniformitys: %+v", err) @@ -101,6 +117,10 @@ func (s uniformityService) GetAll(c *fiber.Ctx, params *validation.Query) ([]ent } func (s uniformityService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlockKandangUniformity, error) { + if err := m.EnsureUniformityAccess(c, s.Repository.DB(), id); err != nil { + return nil, err + } + uniformity, err := s.Repository.GetByID(c.Context(), id, s.Repository.WithDefaultRelations()) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Uniformity not found") @@ -326,6 +346,9 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file if err := s.Validate.Struct(req); err != nil { return nil, err } + if err := m.EnsureProjectFlockKandangAccess(c, s.Repository.DB(), 0, req.ProjectFlockKandangId); err != nil { + return nil, err + } if s.ProjectFlockKandangRepo == nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Project flock kandang repository not available") } @@ -439,6 +462,9 @@ func (s uniformityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id ui if err := s.Validate.Struct(req); err != nil { return nil, err } + if err := m.EnsureUniformityAccess(c, s.Repository.DB(), id); err != nil { + return nil, err + } updateBody := make(map[string]any) var uniformDate *time.Time @@ -627,6 +653,10 @@ func (s *uniformityService) ensureUniqueUniformity(ctx context.Context, id uint, } func (s uniformityService) DeleteOne(c *fiber.Ctx, id uint) error { + if err := m.EnsureUniformityAccess(c, s.Repository.DB(), id); err != nil { + return err + } + if err := s.Repository.DeleteOne(c.Context(), id); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, "Uniformity not found") @@ -657,6 +687,11 @@ func (s uniformityService) Approval(c *fiber.Ctx, req *validation.Approve) ([]en if len(ids) == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "approvable_ids must contain at least one id") } + for _, id := range ids { + if err := m.EnsureUniformityAccess(c, s.Repository.DB(), id); err != nil { + return nil, err + } + } step := utils.UniformityStepPengajuan if action == entity.ApprovalActionApproved { diff --git a/internal/modules/repports/controllers/repport.controller.go b/internal/modules/repports/controllers/repport.controller.go index 4ed7a855..3d0468d4 100644 --- a/internal/modules/repports/controllers/repport.controller.go +++ b/internal/modules/repports/controllers/repport.controller.go @@ -106,6 +106,18 @@ func (c *RepportController) GetMarketing(ctx *fiber.Ctx) error { SortOrder: ctx.Query("sort_order", ""), } + locationScope, err := m.ResolveLocationScope(ctx, c.RepportService.DB()) + if err != nil { + return err + } + if locationScope.Restrict { + allowed := toInt64Slice(locationScope.IDs) + if len(allowed) == 0 { + allowed = []int64{-1} + } + query.AllowedLocationIDs = allowed + } + if query.Page < 1 || query.Limit < 1 { return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") } @@ -283,6 +295,10 @@ func (c *RepportController) GetProductionResult(ctx *fiber.Ctx) error { ProjectFlockKandangID: uint(projectFlockKandangID), } + if err := m.EnsureProjectFlockKandangAccess(ctx, c.RepportService.DB(), 0, query.ProjectFlockKandangID); err != nil { + return err + } + if query.Page < 1 || query.Limit < 1 { return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") } diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index 4c1d7356..c5572edb 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -31,6 +31,7 @@ type MarketingQuery struct { EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"` SortBy string `query:"sort_by" validate:"omitempty,oneof=so_date realization_date customer warehouse product sales_person vehicle_number sales_amount hpp_amount qty average_weight total_weight sales_price hpp_price aging_days"` SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` + AllowedLocationIDs []int64 `query:"-"` } type PurchaseSupplierQuery struct { diff --git a/internal/modules/sso/verifier/profile.go b/internal/modules/sso/verifier/profile.go index 52b7ef5a..e3cd40ca 100644 --- a/internal/modules/sso/verifier/profile.go +++ b/internal/modules/sso/verifier/profile.go @@ -39,6 +39,10 @@ type UserProfile struct { UserID uint Roles []Role Permissions []Permission + AreaIDs []uint + LocationIDs []uint + AllArea bool + AllLocation bool } // Role describes a role assignment from the SSO profile response. @@ -49,10 +53,6 @@ type Role struct { ClientID uint ClientAlias string ClientName string - AllArea bool - AllLocation bool - AreaIDs []uint - LocationIDs []uint Permissions []Permission RawReference json.RawMessage `json:"-"` } @@ -149,6 +149,10 @@ func fetchProfileFromSSO(ctx context.Context, token string) (*UserProfile, error } roles := envelope.getRoles() + areaIDs := envelope.getAreaIDs() + locationIDs := envelope.getLocationIDs() + allArea := envelope.getAllArea() + allLocation := envelope.getAllLocation() profile := &UserProfile{} // Attempt to infer user id if provided. @@ -166,10 +170,6 @@ func fetchProfileFromSSO(ctx context.Context, token string) (*UserProfile, error ClientAlias: strings.TrimSpace(r.Client.Alias), ClientName: strings.TrimSpace(r.Client.Name), ClientID: uint(r.Client.ID), - AllArea: r.AllArea, - AllLocation: r.AllLocation, - AreaIDs: r.AreaIDs, - LocationIDs: r.LocationIDs, } rolePerms := make([]Permission, 0, len(r.Permissions)) for _, p := range r.Permissions { @@ -191,6 +191,10 @@ func fetchProfileFromSSO(ctx context.Context, token string) (*UserProfile, error } profile.Roles = convertedRoles profile.Permissions = perms + profile.AreaIDs = areaIDs + profile.LocationIDs = locationIDs + profile.AllArea = allArea + profile.AllLocation = allLocation return profile, nil } @@ -268,9 +272,17 @@ func canonicalPermissionName(name string) string { // userInfoEnvelope handles the varying shapes returned by the SSO userinfo endpoint. type userInfoEnvelope struct { Roles []userInfoRole `json:"roles"` + AreaIDs []uint `json:"area_ids"` + LocationIDs []uint `json:"location_ids"` + AllArea bool `json:"all_area"` + AllLocation bool `json:"all_location"` Data *struct { ID int64 `json:"id"` Roles []userInfoRole `json:"roles"` + AreaIDs []uint `json:"area_ids"` + LocationIDs []uint `json:"location_ids"` + AllArea bool `json:"all_area"` + AllLocation bool `json:"all_location"` } `json:"data"` User *struct { ID int64 `json:"id"` @@ -292,14 +304,50 @@ func (e *userInfoEnvelope) getRoles() []userInfoRole { return nil } +func (e *userInfoEnvelope) getAreaIDs() []uint { + if len(e.AreaIDs) > 0 { + return e.AreaIDs + } + if e.Data != nil && len(e.Data.AreaIDs) > 0 { + return e.Data.AreaIDs + } + return nil +} + +func (e *userInfoEnvelope) getLocationIDs() []uint { + if len(e.LocationIDs) > 0 { + return e.LocationIDs + } + if e.Data != nil && len(e.Data.LocationIDs) > 0 { + return e.Data.LocationIDs + } + return nil +} + +func (e *userInfoEnvelope) getAllArea() bool { + if e.AllArea { + return true + } + if e.Data != nil && e.Data.AllArea { + return true + } + return false +} + +func (e *userInfoEnvelope) getAllLocation() bool { + if e.AllLocation { + return true + } + if e.Data != nil && e.Data.AllLocation { + return true + } + return false +} + type userInfoRole struct { ID int64 `json:"id"` Key string `json:"key"` Name string `json:"name"` - AllArea bool `json:"all_area"` - AllLocation bool `json:"all_location"` - AreaIDs []uint `json:"area_ids"` - LocationIDs []uint `json:"location_ids"` Client userInfoClient `json:"client"` Permissions []userInfoPermRaw `json:"permissions"` } From 3a457ceee53feedc0302d075c71b388bd1e30632 Mon Sep 17 00:00:00 2001 From: ragilap Date: Thu, 22 Jan 2026 09:27:11 +0700 Subject: [PATCH 05/37] [FIX/BE-US] adjustment sso-location --- internal/middleware/role_scope.go | 26 ++++++++++++-- .../expenses/services/expense.service.go | 20 +++++------ .../services/adjustment.service.go | 21 +++++------ .../master/areas/services/area.service.go | 30 ++++++---------- .../kandangs/services/kandang.service.go | 20 +++++------ .../locations/services/location.service.go | 30 ++++++---------- .../warehouses/services/warehouse.service.go | 20 +++++------ .../controllers/projectflock.controller.go | 19 +++++++--- .../dto/projectflock_kandang.dto.go | 36 ++++++++++--------- .../services/projectflock.service.go | 26 ++++++++++++++ .../recordings/services/recording.service.go | 22 +++++------- .../services/uniformity.service.go | 21 +++++------ 12 files changed, 158 insertions(+), 133 deletions(-) diff --git a/internal/middleware/role_scope.go b/internal/middleware/role_scope.go index 155d2e6a..c46a7fab 100644 --- a/internal/middleware/role_scope.go +++ b/internal/middleware/role_scope.go @@ -178,8 +178,6 @@ func uniqueUint(ids []uint) []uint { return result } - - func ApplyScopeFilter(db *gorm.DB, scope ScopeFilter, column string) *gorm.DB { if db == nil || !scope.Restrict { return db @@ -190,6 +188,30 @@ func ApplyScopeFilter(db *gorm.DB, scope ScopeFilter, column string) *gorm.DB { return db.Where(column+" IN ?", scope.IDs) } +func ApplyLocationScope(c *fiber.Ctx, db *gorm.DB, column string) (*gorm.DB, error) { + scopeDB := db + if db != nil { + scopeDB = db.Session(&gorm.Session{NewDB: true}) + } + scope, err := ResolveLocationScope(c, scopeDB) + if err != nil { + return db, err + } + return ApplyScopeFilter(db, scope, column), nil +} + +func ApplyAreaScope(c *fiber.Ctx, db *gorm.DB, column string) (*gorm.DB, error) { + scopeDB := db + if db != nil { + scopeDB = db.Session(&gorm.Session{NewDB: true}) + } + scope, err := ResolveAreaScope(c, scopeDB) + if err != nil { + return db, err + } + return ApplyScopeFilter(db, scope, column), nil +} + func EnsureWarehouseAccess(c *fiber.Ctx, db *gorm.DB, warehouseID uint) error { if warehouseID == 0 { return fiber.NewError(fiber.StatusBadRequest, "Invalid warehouse id") diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index 27b4a07f..61a7db50 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -87,22 +87,22 @@ func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expens return nil, 0, err } - scope, err := middleware.ResolveLocationScope(c, s.Repository.DB()) - if err != nil { - return nil, 0, err - } + var scopeErr error offset := (params.Page - 1) * params.Limit expenses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) - db = middleware.ApplyScopeFilter(db, scope, "location_id") + db, scopeErr = middleware.ApplyLocationScope(c, db, "expenses.location_id") if params.Search != "" { return db.Where("category ILIKE ?", "%"+params.Search+"%") } return db.Order("created_at DESC").Order("updated_at DESC") }) + if scopeErr != nil { + return nil, 0, scopeErr + } if err != nil { return nil, 0, err @@ -123,16 +123,16 @@ func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expens } func (s expenseService) GetOne(c *fiber.Ctx, id uint) (*expenseDto.ExpenseDetailDTO, error) { - scope, err := middleware.ResolveLocationScope(c, s.Repository.DB()) - if err != nil { - return nil, err - } + var scopeErr error expense, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) - db = middleware.ApplyScopeFilter(db, scope, "location_id") + db, scopeErr = middleware.ApplyLocationScope(c, db, "expenses.location_id") return db }) + if scopeErr != nil { + return nil, scopeErr + } if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index 4347fd6c..7bb9fb6a 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -303,20 +303,12 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu return nil, 0, fiber.NewError(fiber.StatusNotFound, "Product not found") } + var scopeErr error stockLogs, total, err := s.StockLogsRepository.GetAll(c.Context(), offset, query.Limit, func(db *gorm.DB) *gorm.DB { - scope, err := m.ResolveLocationScope(c, s.StockLogsRepository.DB()) - if err != nil { - return db.Where("1 = 0") - } - if scope.Restrict { - if len(scope.IDs) == 0 { - return db.Where("1 = 0") - } - db = db. - Joins("JOIN product_warehouses pw ON pw.id = stock_logs.product_warehouse_id"). - Joins("JOIN warehouses w ON w.id = pw.warehouse_id") - db = m.ApplyScopeFilter(db, scope, "w.location_id") - } + db = db. + Joins("JOIN product_warehouses pw ON pw.id = stock_logs.product_warehouse_id"). + Joins("JOIN warehouses w ON w.id = pw.warehouse_id") + db, scopeErr = m.ApplyLocationScope(c, db, "w.location_id") db = s.withRelations(db) db = db.Where("loggable_type = ?", string(utils.StockLogTypeAdjustment)) @@ -329,6 +321,9 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu return db.Order("created_at DESC") }) + if scopeErr != nil { + return nil, 0, scopeErr + } if err != nil { s.Log.Errorf("Failed to get adjustments: %+v", err) return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to get adjustment history") diff --git a/internal/modules/master/areas/services/area.service.go b/internal/modules/master/areas/services/area.service.go index 1ec30d8d..6110aaef 100644 --- a/internal/modules/master/areas/services/area.service.go +++ b/internal/modules/master/areas/services/area.service.go @@ -47,27 +47,22 @@ func (s areaService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Ar return nil, 0, err } - scope, err := m.ResolveAreaScope(c, s.Repository.DB()) - if err != nil { - return nil, 0, err - } + var scopeErr error offset := (params.Page - 1) * params.Limit areas, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) - if scope.Restrict { - if len(scope.IDs) == 0 { - return db.Where("1 = 0") - } - db = db.Where("id IN ?", scope.IDs) - } + db, scopeErr = m.ApplyAreaScope(c, db, "id") if params.Search != "" { return db.Where("name ILIKE ?", "%"+params.Search+"%") } return db.Order("created_at DESC").Order("updated_at DESC") }) + if scopeErr != nil { + return nil, 0, scopeErr + } if err != nil { s.Log.Errorf("Failed to get areas: %+v", err) return nil, 0, err @@ -76,21 +71,16 @@ func (s areaService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Ar } func (s areaService) GetOne(c *fiber.Ctx, id uint) (*entity.Area, error) { - scope, err := m.ResolveAreaScope(c, s.Repository.DB()) - if err != nil { - return nil, err - } + var scopeErr error area, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) - if scope.Restrict { - if len(scope.IDs) == 0 { - return db.Where("1 = 0") - } - db = db.Where("id IN ?", scope.IDs) - } + db, scopeErr = m.ApplyAreaScope(c, db, "id") return db }) + if scopeErr != nil { + return nil, scopeErr + } if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Area not found") } diff --git a/internal/modules/master/kandangs/services/kandang.service.go b/internal/modules/master/kandangs/services/kandang.service.go index d5b297e5..159bc410 100644 --- a/internal/modules/master/kandangs/services/kandang.service.go +++ b/internal/modules/master/kandangs/services/kandang.service.go @@ -49,16 +49,13 @@ func (s kandangService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity return nil, 0, err } - scope, err := m.ResolveLocationScope(c, s.Repository.DB()) - if err != nil { - return nil, 0, err - } + var scopeErr error offset := (params.Page - 1) * params.Limit kandangs, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) - db = m.ApplyScopeFilter(db, scope, "location_id") + db, scopeErr = m.ApplyLocationScope(c, db, "kandangs.location_id") if params.Search != "" { return db.Where("name ILIKE ?", "%"+params.Search+"%") } @@ -71,6 +68,9 @@ func (s kandangService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity return db.Order("created_at DESC").Order("updated_at DESC") }) + if scopeErr != nil { + return nil, 0, scopeErr + } if err != nil { s.Log.Errorf("Failed to get kandangs: %+v", err) return nil, 0, err @@ -79,16 +79,16 @@ func (s kandangService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity } func (s kandangService) GetOne(c *fiber.Ctx, id uint) (*entity.Kandang, error) { - scope, err := m.ResolveLocationScope(c, s.Repository.DB()) - if err != nil { - return nil, err - } + var scopeErr error kandang, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) - db = m.ApplyScopeFilter(db, scope, "location_id") + db, scopeErr = m.ApplyLocationScope(c, db, "kandangs.location_id") return db }) + if scopeErr != nil { + return nil, scopeErr + } if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Kandang not found") } diff --git a/internal/modules/master/locations/services/location.service.go b/internal/modules/master/locations/services/location.service.go index 633d7419..03f6cf45 100644 --- a/internal/modules/master/locations/services/location.service.go +++ b/internal/modules/master/locations/services/location.service.go @@ -47,21 +47,13 @@ func (s locationService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit return nil, 0, err } - scope, err := m.ResolveLocationScope(c, s.Repository.DB()) - if err != nil { - return nil, 0, err - } + var scopeErr error offset := (params.Page - 1) * params.Limit locations, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) - if scope.Restrict { - if len(scope.IDs) == 0 { - return db.Where("1 = 0") - } - db = db.Where("id IN ?", scope.IDs) - } + db, scopeErr = m.ApplyLocationScope(c, db, "locations.id") if params.Search != "" { db = db.Where("name ILIKE ?", "%"+params.Search+"%") } @@ -71,6 +63,9 @@ func (s locationService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit return db.Order("created_at DESC").Order("updated_at DESC") }) + if scopeErr != nil { + return nil, 0, scopeErr + } if err != nil { s.Log.Errorf("Failed to get locations: %+v", err) return nil, 0, err @@ -79,21 +74,16 @@ func (s locationService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit } func (s locationService) GetOne(c *fiber.Ctx, id uint) (*entity.Location, error) { - scope, err := m.ResolveLocationScope(c, s.Repository.DB()) - if err != nil { - return nil, err - } + var scopeErr error location, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) - if scope.Restrict { - if len(scope.IDs) == 0 { - return db.Where("1 = 0") - } - db = db.Where("id IN ?", scope.IDs) - } + db, scopeErr = m.ApplyLocationScope(c, db, "locations.id") return db }) + if scopeErr != nil { + return nil, scopeErr + } if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Location not found") } diff --git a/internal/modules/master/warehouses/services/warehouse.service.go b/internal/modules/master/warehouses/services/warehouse.service.go index 29cf402f..89e4467d 100644 --- a/internal/modules/master/warehouses/services/warehouse.service.go +++ b/internal/modules/master/warehouses/services/warehouse.service.go @@ -48,16 +48,13 @@ func (s warehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti return nil, 0, err } - scope, err := m.ResolveAreaScope(c, s.Repository.DB()) - if err != nil { - return nil, 0, err - } + var scopeErr error offset := (params.Page - 1) * params.Limit warehouses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) - db = m.ApplyScopeFilter(db, scope, "area_id") + db, scopeErr = m.ApplyAreaScope(c, db, "warehouses.area_id") if params.Search != "" { db = db.Where("warehouses.name ILIKE ?", "%"+params.Search+"%") } @@ -84,6 +81,9 @@ func (s warehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti return db.Order("created_at DESC").Order("updated_at DESC") }) + if scopeErr != nil { + return nil, 0, scopeErr + } if err != nil { s.Log.Errorf("Failed to get warehouses: %+v", err) return nil, 0, err @@ -92,16 +92,16 @@ func (s warehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti } func (s warehouseService) GetOne(c *fiber.Ctx, id uint) (*entity.Warehouse, error) { - scope, err := m.ResolveAreaScope(c, s.Repository.DB()) - if err != nil { - return nil, err - } + var scopeErr error warehouse, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) - db = m.ApplyScopeFilter(db, scope, "area_id") + db, scopeErr = m.ApplyAreaScope(c, db, "warehouses.area_id") return db }) + if scopeErr != nil { + return nil, scopeErr + } if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Warehouse not found") } diff --git a/internal/modules/production/project_flocks/controllers/projectflock.controller.go b/internal/modules/production/project_flocks/controllers/projectflock.controller.go index 4315b948..e82d3af5 100644 --- a/internal/modules/production/project_flocks/controllers/projectflock.controller.go +++ b/internal/modules/production/project_flocks/controllers/projectflock.controller.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" + warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/dto" service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations" @@ -278,14 +279,22 @@ func (u *ProjectflockController) LookupProjectFlockKandang(c *fiber.Ctx) error { if err != nil { return err } + _ = availableStock dtoResult := dto.ToProjectFlockKandangDTO(*result) - dtoResult.AvailableQuantity = float64(availableStock) + if population, err := u.ProjectflockService.GetProjectFlockKandangPopulation(c, result.Id); err != nil { + return err + } else { + dtoResult.AvailableQuantity = population + } + if warehouse, werr := u.ProjectflockService.GetWarehouseByKandangID(c, result.KandangId); werr != nil { + return werr + } else if warehouse != nil { + mapped := warehouseDTO.ToWarehouseRelationDTO(*warehouse) + dtoResult.Warehouse = &mapped + } if withPopulation { - population, err := u.ProjectflockService.GetProjectFlockKandangPopulation(c, result.Id) - if err != nil { - return err - } + population := dtoResult.AvailableQuantity dtoResult.Population = &population } diff --git a/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go b/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go index 8dedaf15..c18f3f65 100644 --- a/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go +++ b/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go @@ -7,6 +7,7 @@ import ( kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto" productionStandardDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/dto" + warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) @@ -17,24 +18,26 @@ type KandangWithPivotDTO struct { type ProjectFlockWithPivotDTO struct { ProjectFlockRelationDTO - Area *areaDTO.AreaRelationDTO `json:"area,omitempty"` - Category string `json:"category"` - Fcr *fcrDTO.FcrRelationDTO `json:"fcr,omitempty"` - ProductionStandard *productionStandardDTO.ProductionStandardRelationDTO `json:"production_standard,omitempty"` - Location *locationDTO.LocationRelationDTO `json:"location,omitempty"` - Kandangs []KandangWithPivotDTO `json:"kandangs,omitempty"` - CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` + Area *areaDTO.AreaRelationDTO `json:"area,omitempty"` + Category string `json:"category"` + Fcr *fcrDTO.FcrRelationDTO `json:"fcr,omitempty"` + ProductionStandard *productionStandardDTO.ProductionStandardRelationDTO `json:"production_standard,omitempty"` + ProductionStandardId uint `json:"production_standard_id"` + Location *locationDTO.LocationRelationDTO `json:"location,omitempty"` + Kandangs []KandangWithPivotDTO `json:"kandangs,omitempty"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` } type ProjectFlockKandangDTO struct { - Id uint `json:"id"` - ProjectFlockKandangId uint `json:"project_flock_kandang_id"` - ProjectFlockId uint `json:"project_flock_id"` - KandangId uint `json:"kandang_id"` - Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"` - ProjectFlock *ProjectFlockWithPivotDTO `json:"project_flock,omitempty"` - AvailableQuantity float64 `json:"available_quantity"` - Population *float64 `json:"population,omitempty"` + Id uint `json:"id"` + ProjectFlockKandangId uint `json:"project_flock_kandang_id"` + ProjectFlockId uint `json:"project_flock_id"` + KandangId uint `json:"kandang_id"` + Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"` + Warehouse *warehouseDTO.WarehouseRelationDTO `json:"warehouse,omitempty"` + ProjectFlock *ProjectFlockWithPivotDTO `json:"project_flock,omitempty"` + AvailableQuantity float64 `json:"available_quantity"` + Population *float64 `json:"population,omitempty"` } func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangDTO { @@ -53,7 +56,8 @@ func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangD Period: e.Period, FlockName: e.ProjectFlock.FlockName, }, - Category: e.ProjectFlock.Category, + Category: e.ProjectFlock.Category, + ProductionStandardId: e.ProjectFlock.ProductionStandardId, } if e.ProjectFlock.Area.Id != 0 { diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index 5bccfbf9..166c7e0b 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -38,6 +38,7 @@ type ProjectflockService interface { GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, *flockDTO.FlockRelationDTO, error) CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.ProjectFlock, error) GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID uint) (float64, error) + GetWarehouseByKandangID(ctx *fiber.Ctx, kandangID uint) (*entity.Warehouse, error) DeleteOne(ctx *fiber.Ctx, id uint) error GetProjectFlockKandangByProjectAndKandang(ctx *fiber.Ctx, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, float64, error) GetProjectFlockKandangPopulation(ctx *fiber.Ctx, projectFlockKandangID uint) (float64, error) @@ -532,6 +533,31 @@ func (s projectflockService) GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID u return total, nil } +func (s projectflockService) GetWarehouseByKandangID(ctx *fiber.Ctx, kandangID uint) (*entity.Warehouse, error) { + if kandangID == 0 || s.WarehouseRepo == nil { + return nil, nil + } + + var warehouse entity.Warehouse + err := s.WarehouseRepo.DB().WithContext(ctx.Context()). + Preload("Area"). + Preload("Location"). + Preload("Kandang"). + Where("kandang_id = ?", kandangID). + Where("deleted_at IS NULL"). + Order("id DESC"). + First(&warehouse).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + s.Log.Errorf("Failed to fetch warehouse for kandang %d: %+v", kandangID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch warehouse") + } + + return &warehouse, nil +} + func (s projectflockService) GetProjectPeriods(c *fiber.Ctx, projectIDs []uint) (map[uint]int, error) { if len(projectIDs) == 0 { return map[uint]int{}, nil diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index e52aaddc..378e72df 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -101,10 +101,7 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti return nil, 0, err } - scope, err := m.ResolveLocationScope(c, s.Repository.DB()) - if err != nil { - return nil, 0, err - } + var scopeErr error if params.ProjectFlockKandangId != 0 { if err := m.EnsureProjectFlockKandangAccess(c, s.Repository.DB(), 0, params.ProjectFlockKandangId); err != nil { return nil, 0, err @@ -123,21 +120,19 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti recordings, total, err := s.Repository.GetAll(c.Context(), offset, limit, func(db *gorm.DB) *gorm.DB { db = s.Repository.WithRelations(db) - if scope.Restrict { - if len(scope.IDs) == 0 { - return db.Where("1 = 0") - } - db = db. - Joins("JOIN project_flock_kandangs pfk ON pfk.id = recordings.project_flock_kandangs_id"). - Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id") - db = m.ApplyScopeFilter(db, scope, "pf.location_id") - } + db = db. + Joins("JOIN project_flock_kandangs pfk ON pfk.id = recordings.project_flock_kandangs_id"). + Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id") + db, scopeErr = m.ApplyLocationScope(c, db, "pf.location_id") if params.ProjectFlockKandangId != 0 { db = db.Where("project_flock_kandangs_id = ?", params.ProjectFlockKandangId) } return db.Order("record_datetime DESC").Order("created_at DESC") }) + if scopeErr != nil { + return nil, 0, scopeErr + } if err != nil { s.Log.Errorf("Failed to get recordings: %+v", err) return nil, 0, err @@ -962,7 +957,6 @@ type eggTotals struct { Weight float64 } - func stocksMatch(existing []entity.RecordingStock, incoming []validation.Stock) bool { hasPending := false for _, item := range incoming { diff --git a/internal/modules/production/uniformities/services/uniformity.service.go b/internal/modules/production/uniformities/services/uniformity.service.go index 51ea29a7..f10ae25c 100644 --- a/internal/modules/production/uniformities/services/uniformity.service.go +++ b/internal/modules/production/uniformities/services/uniformity.service.go @@ -87,25 +87,20 @@ func (s uniformityService) GetAll(c *fiber.Ctx, params *validation.Query) ([]ent return nil, 0, err } - scope, err := m.ResolveLocationScope(c, s.Repository.DB()) - if err != nil { - return nil, 0, err - } + var scopeErr error offset := (params.Page - 1) * params.Limit uniformitys, total, err := s.Repository.GetAllWithFilters(c.Context(), offset, params.Limit, params, func(db *gorm.DB) *gorm.DB { - if scope.Restrict { - if len(scope.IDs) == 0 { - return db.Where("1 = 0") - } - db = db. - Joins("JOIN project_flock_kandangs pfk ON pfk.id = project_flock_kandang_uniformities.project_flock_kandang_id"). - Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id") - db = m.ApplyScopeFilter(db, scope, "pf.location_id") - } + db = db. + Joins("JOIN project_flock_kandangs pfk ON pfk.id = project_flock_kandang_uniformities.project_flock_kandang_id"). + Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id") + db, scopeErr = m.ApplyLocationScope(c, db, "pf.location_id") return db }) + if scopeErr != nil { + return nil, 0, scopeErr + } if err != nil { s.Log.Errorf("Failed to get uniformitys: %+v", err) return nil, 0, err From 8b1831fc73b611b2dc189ad2fa08901e31e16b7a Mon Sep 17 00:00:00 2001 From: giovanni Date: Sat, 24 Jan 2026 12:03:09 +0700 Subject: [PATCH 06/37] adjust take weight remaining --- .../repports/services/repport.service.go | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 9c976138..98905b66 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -1576,18 +1576,18 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes return nil, nil, err } - eggMap, err := s.HppPerKandangRepo.GetWeightRemainingByProjectFlockKandangIDs(ctx.Context(), startOfDay, endOfDay, validPfkIDs) - if err != nil { - return nil, nil, err - } - for pfkID, egg := range eggMap { - if rowIdx, ok := pfkIndex[pfkID]; ok { - repoRows[rowIdx].EggProductionWeightKgRemaining = egg.EggProductionWeightKgRemaining - // repoRows[rowIdx].EggProductionPiecesRemaining = egg.EggProductionPiecesRemaining - // repoRows[rowIdx].EggProductionTotalWeightKg = egg.EggProductionTotalWeightKg - // repoRows[rowIdx].EggProductionTotalPieces = egg.EggProductionTotalPieces - } - } + // eggMap, err := s.HppPerKandangRepo.GetWeightRemainingByProjectFlockKandangIDs(ctx.Context(), startOfDay, endOfDay, validPfkIDs) + // if err != nil { + // return nil, nil, err + // } + // for pfkID, egg := range eggMap { + // if rowIdx, ok := pfkIndex[pfkID]; ok { + // repoRows[rowIdx].EggProductionWeightKgRemaining = egg.EggProductionWeightKgRemaining + // // repoRows[rowIdx].EggProductionPiecesRemaining = egg.EggProductionPiecesRemaining + // // repoRows[rowIdx].EggProductionTotalWeightKg = egg.EggProductionTotalWeightKg + // // repoRows[rowIdx].EggProductionTotalPieces = egg.EggProductionTotalPieces + // } + // } } costMap := make(map[uint]HppCostAggregate, len(costRows)) @@ -1673,7 +1673,7 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes } var eggPiecesFloatRemaining float64 - eggRemainingWeightFloatRemaining := row.EggProductionWeightKgRemaining + var eggRemainingWeightFloatRemaining float64 var eggTotalPiecesFloat float64 var eggWeightFloat float64 eggHpp := 0.0 @@ -1683,7 +1683,7 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes return nil, nil, err } if hppCost != nil { - // eggRemainingWeightFloatRemaining = hppCost.Estimation.Kg - hppCost.Real.Kg + eggRemainingWeightFloatRemaining = hppCost.Estimation.Kg - hppCost.Real.Kg eggPiecesFloatRemaining = hppCost.Estimation.Butir - hppCost.Real.Butir eggHpp = hppCost.Estimation.HargaKg eggTotalPiecesFloat = hppCost.Estimation.Butir From 4646bf557726c790a0fd35e4efa0b6712b290487 Mon Sep 17 00:00:00 2001 From: giovanni Date: Sat, 24 Jan 2026 13:34:21 +0700 Subject: [PATCH 07/37] add filter location id --- .../master/warehouses/controllers/warehouse.controller.go | 1 + .../modules/master/warehouses/services/warehouse.service.go | 3 +++ .../master/warehouses/validations/warehouse.validation.go | 1 + 3 files changed, 5 insertions(+) diff --git a/internal/modules/master/warehouses/controllers/warehouse.controller.go b/internal/modules/master/warehouses/controllers/warehouse.controller.go index a7cfac94..4e93cb52 100644 --- a/internal/modules/master/warehouses/controllers/warehouse.controller.go +++ b/internal/modules/master/warehouses/controllers/warehouse.controller.go @@ -28,6 +28,7 @@ func (u *WarehouseController) GetAll(c *fiber.Ctx) error { Limit: c.QueryInt("limit", 10), Search: c.Query("search", ""), AreaId: c.QueryInt("area_id", 0), + LocationId: c.QueryInt("location_id", 0), ActiveProjectFlockOnly: c.QueryBool("active_project_flock", false), } diff --git a/internal/modules/master/warehouses/services/warehouse.service.go b/internal/modules/master/warehouses/services/warehouse.service.go index 0b9dfc18..0730bc48 100644 --- a/internal/modules/master/warehouses/services/warehouse.service.go +++ b/internal/modules/master/warehouses/services/warehouse.service.go @@ -58,6 +58,9 @@ func (s warehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti if params.AreaId != 0 { db = db.Where("area_id = ?", params.AreaId) } + if params.LocationId != 0 { + db = db.Where("location_id = ?", params.LocationId) + } if params.ActiveProjectFlockOnly { db = db.Where(` EXISTS ( diff --git a/internal/modules/master/warehouses/validations/warehouse.validation.go b/internal/modules/master/warehouses/validations/warehouse.validation.go index 1e305520..be796082 100644 --- a/internal/modules/master/warehouses/validations/warehouse.validation.go +++ b/internal/modules/master/warehouses/validations/warehouse.validation.go @@ -21,5 +21,6 @@ type Query struct { Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` Search string `query:"search" validate:"omitempty,max=50"` AreaId int `query:"area_id" validate:"omitempty,number,gt=0"` + LocationId int `query:"location_id" validate:"omitempty,number,gt=0"` ActiveProjectFlockOnly bool `query:"active_project_flock"` } From 458c8e0a9128952909fdc7caa9811d695507f514 Mon Sep 17 00:00:00 2001 From: "Hafizh A. Y" Date: Sat, 24 Jan 2026 13:35:13 +0700 Subject: [PATCH 08/37] fix(BE): edit customer, finance: bank optional, nominal minus, and filter --- ...3_alter_payments_bank_id_nullable.down.sql | 6 ++ ...853_alter_payments_bank_id_nullable.up.sql | 6 ++ .../finance/initials/dto/initial.dto.go | 11 +-- .../initials/services/initial.service.go | 58 +++++++++---- .../validations/initial.validation.go | 2 +- .../injections/services/injection.service.go | 10 ++- .../validations/injection.validation.go | 4 +- .../controllers/transaction.controller.go | 43 +++++++++- .../transactions/dto/transaction.dto.go | 11 +-- .../services/transaction.service.go | 83 ++++++++++++++++++- .../validations/transaction.validation.go | 13 ++- .../customers/services/customer.service.go | 16 ++++ 12 files changed, 226 insertions(+), 37 deletions(-) create mode 100644 internal/database/migrations/20260124050853_alter_payments_bank_id_nullable.down.sql create mode 100644 internal/database/migrations/20260124050853_alter_payments_bank_id_nullable.up.sql diff --git a/internal/database/migrations/20260124050853_alter_payments_bank_id_nullable.down.sql b/internal/database/migrations/20260124050853_alter_payments_bank_id_nullable.down.sql new file mode 100644 index 00000000..1b1c7f6f --- /dev/null +++ b/internal/database/migrations/20260124050853_alter_payments_bank_id_nullable.down.sql @@ -0,0 +1,6 @@ +BEGIN; + +ALTER TABLE payments + ALTER COLUMN bank_id SET NOT NULL; + +COMMIT; diff --git a/internal/database/migrations/20260124050853_alter_payments_bank_id_nullable.up.sql b/internal/database/migrations/20260124050853_alter_payments_bank_id_nullable.up.sql new file mode 100644 index 00000000..b95dcbf0 --- /dev/null +++ b/internal/database/migrations/20260124050853_alter_payments_bank_id_nullable.up.sql @@ -0,0 +1,6 @@ +BEGIN; + +ALTER TABLE payments + ALTER COLUMN bank_id DROP NOT NULL; + +COMMIT; diff --git a/internal/modules/finance/initials/dto/initial.dto.go b/internal/modules/finance/initials/dto/initial.dto.go index 1311024f..454422fa 100644 --- a/internal/modules/finance/initials/dto/initial.dto.go +++ b/internal/modules/finance/initials/dto/initial.dto.go @@ -20,7 +20,7 @@ type InitialRelationDTO struct { InitialBalanceType string `json:"initial_balance_type"` InitialBalanceTypeLabel string `json:"initial_balance_type_label"` Party Party `json:"party"` - Bank bankDTO.BankRelationDTO `json:"bank,omitempty"` + Bank *bankDTO.BankRelationDTO `json:"bank"` Direction string `json:"direction"` Nominal float64 `json:"nominal"` Notes string `json:"notes"` @@ -128,11 +128,12 @@ func partyFromInitial(e entity.Payment) Party { return party } -func bankFromInitial(e entity.Payment) bankDTO.BankRelationDTO { +func bankFromInitial(e entity.Payment) *bankDTO.BankRelationDTO { if e.BankWarehouse.Id == 0 { - return bankDTO.BankRelationDTO{} + return nil } - return bankDTO.ToBankRelationDTO(e.BankWarehouse) + bank := bankDTO.ToBankRelationDTO(e.BankWarehouse) + return &bank } func userFromInitial(e entity.Payment) userDTO.UserRelationDTO { @@ -161,7 +162,7 @@ func initialBalanceLabel(balanceType string) string { } func initialBalanceTypeFromPayment(e entity.Payment) string { - if strings.EqualFold(e.Direction, "OUT") || e.Nominal < 0 { + if e.Nominal < 0 { return "NEGATIVE" } return "POSITIVE" diff --git a/internal/modules/finance/initials/services/initial.service.go b/internal/modules/finance/initials/services/initial.service.go index e06e99dd..14ab1f54 100644 --- a/internal/modules/finance/initials/services/initial.service.go +++ b/internal/modules/finance/initials/services/initial.service.go @@ -82,6 +82,7 @@ func (s initialService) GetOne(c *fiber.Ctx, id uint) (*entity.Payment, error) { } func (s *initialService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Payment, error) { + normalizeOptionalBankId(&req.BankId) if err := s.Validate.Struct(req); err != nil { return nil, err } @@ -124,7 +125,7 @@ func (s *initialService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit PaymentDate: time.Now(), PaymentMethod: string(utils.PaymentMethodSaldo), BankId: req.BankId, - Direction: directionForInitialType(balanceType), + Direction: directionForInitialType(party, balanceType), Nominal: signedNominal(balanceType, req.Nominal), Notes: req.Note, CreatedBy: actorID, @@ -164,6 +165,7 @@ func (s *initialService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit } func (s initialService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Payment, error) { + normalizeOptionalBankId(&req.BankId) if err := s.Validate.Struct(req); err != nil { return nil, err } @@ -186,6 +188,8 @@ func (s initialService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) requiresExisting := req.PartyType != nil || req.PartyId != nil || req.InitialBalanceType != nil || req.Nominal != nil requiresVerification := requiresExisting || req.ReferenceNumber != nil || req.Note != nil || req.BankId != nil var existing *entity.Payment + var resolvedPartyType string + var resolvedPartyId uint if requiresVerification { current, err := s.Repository.GetByID(c.Context(), id, nil) if errors.Is(err, gorm.ErrRecordNotFound) { @@ -199,26 +203,25 @@ func (s initialService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) return nil, fiber.NewError(fiber.StatusNotFound, "Initial not found") } existing = current + resolvedPartyType = existing.PartyType + resolvedPartyId = existing.PartyId } if req.PartyType != nil || req.PartyId != nil { - partyType := existing.PartyType - partyId := existing.PartyId - if req.PartyType != nil { normalized, err := normalizePartyType(*req.PartyType) if err != nil { return nil, err } - partyType = normalized - updateBody["party_type"] = partyType + resolvedPartyType = normalized + updateBody["party_type"] = resolvedPartyType } if req.PartyId != nil { - partyId = *req.PartyId - updateBody["party_id"] = partyId + resolvedPartyId = *req.PartyId + updateBody["party_id"] = resolvedPartyId } - if err := s.ensurePartyExists(c.Context(), partyType, partyId); err != nil { + if err := s.ensurePartyExists(c.Context(), resolvedPartyType, resolvedPartyId); err != nil { return nil, err } } @@ -238,8 +241,11 @@ func (s initialService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) nominal = *req.Nominal } - updateBody["direction"] = directionForInitialType(balanceType) + updateBody["direction"] = directionForInitialType(resolvedPartyType, balanceType) updateBody["nominal"] = signedNominal(balanceType, nominal) + } else if req.PartyType != nil { + balanceType := balanceTypeFromPayment(existing) + updateBody["direction"] = directionForInitialType(resolvedPartyType, balanceType) } if len(updateBody) == 0 { @@ -262,7 +268,7 @@ func isInitialTransaction(transactionType string) bool { } func balanceTypeFromPayment(payment *entity.Payment) string { - if strings.EqualFold(payment.Direction, "OUT") || payment.Nominal < 0 { + if payment.Nominal < 0 { return "NEGATIVE" } return "POSITIVE" @@ -286,11 +292,24 @@ func normalizeInitialBalanceType(balanceType string) (string, error) { } } -func directionForInitialType(balanceType string) string { - if strings.EqualFold(balanceType, "NEGATIVE") { - return "OUT" +func directionForInitialType(partyType string, balanceType string) string { + switch utils.PaymentParty(strings.ToUpper(strings.TrimSpace(partyType))) { + case utils.PaymentPartySupplier: + if strings.EqualFold(balanceType, "POSITIVE") { + return "OUT" + } + return "IN" + case utils.PaymentPartyCustomer: + if strings.EqualFold(balanceType, "NEGATIVE") { + return "OUT" + } + return "IN" + default: + if strings.EqualFold(balanceType, "NEGATIVE") { + return "OUT" + } + return "IN" } - return "IN" } func signedNominal(balanceType string, nominal float64) float64 { @@ -335,3 +354,12 @@ func (s initialService) ensureBankExists(ctx context.Context, bankId *uint) erro commonSvc.RelationCheck{Name: "Bank", ID: bankId, Exists: s.Repository.BankExists}, ) } + +func normalizeOptionalBankId(bankId **uint) { + if bankId == nil || *bankId == nil { + return + } + if **bankId == 0 { + *bankId = nil + } +} diff --git a/internal/modules/finance/initials/validations/initial.validation.go b/internal/modules/finance/initials/validations/initial.validation.go index 27df2eea..d3e1df54 100644 --- a/internal/modules/finance/initials/validations/initial.validation.go +++ b/internal/modules/finance/initials/validations/initial.validation.go @@ -3,7 +3,7 @@ package validation type Create struct { PartyType string `json:"party_type" validate:"required_strict,max=50"` PartyId uint `json:"party_id" validate:"required_strict,number,gt=0"` - BankId *uint `json:"bank_id" validate:"required_strict,number,gt=0"` + BankId *uint `json:"bank_id,omitempty" validate:"omitempty,number,gt=0"` ReferenceNumber string `json:"reference_number" validate:"required_strict,max=100"` InitialBalanceType string `json:"initial_balance_type" validate:"required_strict,oneof=NEGATIVE POSITIVE"` Nominal float64 `json:"nominal" validate:"required_strict,gt=0"` diff --git a/internal/modules/finance/injections/services/injection.service.go b/internal/modules/finance/injections/services/injection.service.go index 8cb80e1c..99b0e488 100644 --- a/internal/modules/finance/injections/services/injection.service.go +++ b/internal/modules/finance/injections/services/injection.service.go @@ -110,7 +110,7 @@ func (s *injectionService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent PaymentDate: adjustmentDate, PaymentMethod: string(utils.PaymentMethodSaldo), BankId: req.BankId, - Direction: "IN", + Direction: directionForInjectionNominal(req.Nominal), Nominal: req.Nominal, Notes: req.Notes, CreatedBy: actorID, @@ -186,6 +186,7 @@ func (s injectionService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } if req.Nominal != nil { updateBody["nominal"] = *req.Nominal + updateBody["direction"] = directionForInjectionNominal(*req.Nominal) } if req.Notes != nil { updateBody["notes"] = *req.Notes @@ -210,6 +211,13 @@ func isInjectionTransaction(transactionType string) bool { return strings.EqualFold(transactionType, string(utils.TransactionTypeInjection)) } +func directionForInjectionNominal(nominal float64) string { + if nominal < 0 { + return "OUT" + } + return "IN" +} + func (s injectionService) generateInjectionCode(ctx context.Context) (string, error) { sequence, err := s.Repository.NextPaymentSequence(ctx) if err != nil { diff --git a/internal/modules/finance/injections/validations/injection.validation.go b/internal/modules/finance/injections/validations/injection.validation.go index b5b75087..744256a1 100644 --- a/internal/modules/finance/injections/validations/injection.validation.go +++ b/internal/modules/finance/injections/validations/injection.validation.go @@ -3,14 +3,14 @@ package validation type Create struct { BankId *uint `json:"bank_id" validate:"required_strict,number,gt=0"` AdjustmentDate string `json:"adjustment_date" validate:"required_strict"` - Nominal float64 `json:"nominal" validate:"required_strict,gt=0"` + Nominal float64 `json:"nominal" validate:"required_strict"` Notes string `json:"notes" validate:"required_strict,max=500"` } type Update struct { BankId *uint `json:"bank_id,omitempty" validate:"omitempty,number,gt=0"` AdjustmentDate *string `json:"adjustment_date,omitempty" validate:"omitempty"` - Nominal *float64 `json:"nominal,omitempty" validate:"omitempty,gt=0"` + Nominal *float64 `json:"nominal,omitempty"` Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` } diff --git a/internal/modules/finance/transactions/controllers/transaction.controller.go b/internal/modules/finance/transactions/controllers/transaction.controller.go index fa3e1369..5c25cbcd 100644 --- a/internal/modules/finance/transactions/controllers/transaction.controller.go +++ b/internal/modules/finance/transactions/controllers/transaction.controller.go @@ -3,6 +3,7 @@ package controller import ( "math" "strconv" + "strings" "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/dto" service "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/services" @@ -23,10 +24,46 @@ func NewTransactionController(transactionService service.TransactionService) *Tr } func (u *TransactionController) GetAll(c *fiber.Ctx) error { + parseOptionalUint := func(key string) (*uint, error) { + raw := strings.TrimSpace(c.Query(key, "")) + if raw == "" { + return nil, nil + } + parsed, err := strconv.ParseUint(raw, 10, 64) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid "+key) + } + if parsed == 0 { + return nil, nil + } + value := uint(parsed) + return &value, nil + } + + bankId, err := parseOptionalUint("bank_id") + if err != nil { + return err + } + customerId, err := parseOptionalUint("customer_id") + if err != nil { + return err + } + supplierId, err := parseOptionalUint("supplier_id") + if err != nil { + return err + } + 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", ""), + TransactionType: c.Query("transaction_type", ""), + BankId: bankId, + CustomerId: customerId, + SupplierId: supplierId, + SortDate: c.Query("sort_date", ""), + StartDate: c.Query("start_date", ""), + EndDate: c.Query("end_date", ""), } if query.Page < 1 || query.Limit < 1 { diff --git a/internal/modules/finance/transactions/dto/transaction.dto.go b/internal/modules/finance/transactions/dto/transaction.dto.go index 07703fce..89b63ecf 100644 --- a/internal/modules/finance/transactions/dto/transaction.dto.go +++ b/internal/modules/finance/transactions/dto/transaction.dto.go @@ -21,7 +21,7 @@ type TransactionRelationDTO struct { Party Party `json:"party"` PaymentDate time.Time `json:"payment_date"` PaymentMethod string `json:"payment_method"` - Bank bankDTO.BankRelationDTO `json:"bank,omitempty"` + Bank *bankDTO.BankRelationDTO `json:"bank"` ExpenseAmount float64 `json:"expense_amount"` IncomeAmount float64 `json:"income_amount"` Nominal float64 `json:"nominal"` @@ -37,7 +37,7 @@ type TransactionListDTO struct { Party Party `json:"party"` PaymentDate time.Time `json:"payment_date"` PaymentMethod string `json:"payment_method"` - Bank bankDTO.BankRelationDTO `json:"bank"` + Bank *bankDTO.BankRelationDTO `json:"bank"` ExpenseAmount float64 `json:"expense_amount"` IncomeAmount float64 `json:"income_amount"` Nominal float64 `json:"nominal"` @@ -151,11 +151,12 @@ func partyFromPayment(e entity.Payment) Party { return party } -func bankFromPayment(e entity.Payment) bankDTO.BankRelationDTO { +func bankFromPayment(e entity.Payment) *bankDTO.BankRelationDTO { if e.BankWarehouse.Id == 0 { - return bankDTO.BankRelationDTO{} + return nil } - return bankDTO.ToBankRelationDTO(e.BankWarehouse) + bank := bankDTO.ToBankRelationDTO(e.BankWarehouse) + return &bank } func userFromPayment(e entity.Payment) userDTO.UserRelationDTO { diff --git a/internal/modules/finance/transactions/services/transaction.service.go b/internal/modules/finance/transactions/services/transaction.service.go index f7398d43..f422320f 100644 --- a/internal/modules/finance/transactions/services/transaction.service.go +++ b/internal/modules/finance/transactions/services/transaction.service.go @@ -4,6 +4,7 @@ import ( "context" "errors" "strings" + "time" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -61,13 +62,19 @@ func (s transactionService) GetAll(c *fiber.Ctx, params *validation.Query) ([]en return nil, 0, err } + startDate, endDate, err := parseTransactionDateRange(params.StartDate, params.EndDate) + if err != nil { + return nil, 0, err + } + offset := (params.Page - 1) * params.Limit transactions, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) + if params.Search != "" { like := "%" + strings.ToLower(strings.TrimSpace(params.Search)) + "%" - return db.Where( + db = db.Where( `LOWER(payment_code) LIKE ? OR LOWER(COALESCE(reference_number, '')) LIKE ? OR LOWER(COALESCE(transaction_type, '')) LIKE ? OR @@ -75,7 +82,35 @@ func (s transactionService) GetAll(c *fiber.Ctx, params *validation.Query) ([]en like, like, like, like, ) } - return db.Order("payment_date DESC").Order("created_at DESC") + + if strings.TrimSpace(params.TransactionType) != "" { + db = db.Where("transaction_type = ?", strings.ToUpper(strings.TrimSpace(params.TransactionType))) + } + + if params.BankId != nil { + db = db.Where("bank_id = ?", *params.BankId) + } + + if params.CustomerId != nil && params.SupplierId != nil { + db = db.Where( + "(party_type = ? AND party_id = ?) OR (party_type = ? AND party_id = ?)", + string(utils.PaymentPartyCustomer), *params.CustomerId, + string(utils.PaymentPartySupplier), *params.SupplierId, + ) + } else if params.CustomerId != nil { + db = db.Where("party_type = ? AND party_id = ?", string(utils.PaymentPartyCustomer), *params.CustomerId) + } else if params.SupplierId != nil { + db = db.Where("party_type = ? AND party_id = ?", string(utils.PaymentPartySupplier), *params.SupplierId) + } + + if startDate != nil { + db = db.Where("payment_date >= ?", *startDate) + } + if endDate != nil { + db = db.Where("payment_date < ?", *endDate) + } + + return applyTransactionSort(db, params.SortDate) }) if err != nil { @@ -173,3 +208,47 @@ func (s transactionService) approvalQueryModifier() func(*gorm.DB) *gorm.DB { return db.Preload("ActionUser") } } + +func parseTransactionDateRange(startDate, endDate string) (*time.Time, *time.Time, error) { + start := strings.TrimSpace(startDate) + end := strings.TrimSpace(endDate) + + var startPtr *time.Time + var endPtr *time.Time + var endValue *time.Time + + if start != "" { + parsed, err := utils.ParseDateString(start) + if err != nil { + return nil, nil, utils.BadRequest("start_date must use format YYYY-MM-DD") + } + startPtr = &parsed + } + + if end != "" { + parsed, err := utils.ParseDateString(end) + if err != nil { + return nil, nil, utils.BadRequest("end_date must use format YYYY-MM-DD") + } + endValue = &parsed + nextDay := parsed.AddDate(0, 0, 1) + endPtr = &nextDay + } + + if startPtr != nil && endValue != nil && startPtr.After(*endValue) { + return nil, nil, utils.BadRequest("start_date must be earlier than end_date") + } + + return startPtr, endPtr, nil +} + +func applyTransactionSort(db *gorm.DB, sortDate string) *gorm.DB { + switch strings.ToLower(strings.TrimSpace(sortDate)) { + case "created_at": + return db.Order("created_at DESC").Order("payment_date DESC") + case "payment_date": + return db.Order("payment_date DESC").Order("created_at DESC") + default: + return db.Order("payment_date DESC").Order("created_at DESC") + } +} diff --git a/internal/modules/finance/transactions/validations/transaction.validation.go b/internal/modules/finance/transactions/validations/transaction.validation.go index 7d16d3ee..f367dda1 100644 --- a/internal/modules/finance/transactions/validations/transaction.validation.go +++ b/internal/modules/finance/transactions/validations/transaction.validation.go @@ -9,7 +9,14 @@ type Update struct { } 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"` + 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"` + TransactionType string `query:"transaction_type" validate:"omitempty,max=50"` + BankId *uint `query:"bank_id" validate:"omitempty,number,gt=0"` + CustomerId *uint `query:"customer_id" validate:"omitempty,number,gt=0"` + SupplierId *uint `query:"supplier_id" validate:"omitempty,number,gt=0"` + SortDate string `query:"sort_date" validate:"omitempty,oneof=created_at payment_date"` + StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"` + EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"` } diff --git a/internal/modules/master/customers/services/customer.service.go b/internal/modules/master/customers/services/customer.service.go index fe4cb41e..6156dc8c 100644 --- a/internal/modules/master/customers/services/customer.service.go +++ b/internal/modules/master/customers/services/customer.service.go @@ -156,6 +156,22 @@ func (s customerService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint updateBody["type"] = typ } + if req.Address != nil { + updateBody["address"] = *req.Address + } + + if req.Phone != nil { + updateBody["phone"] = *req.Phone + } + + if req.Email != nil { + updateBody["email"] = *req.Email + } + + if req.AccountNumber != nil { + updateBody["account_number"] = *req.AccountNumber + } + if len(updateBody) == 0 { return s.GetOne(c, id) } From 8c849818129911ff4855498ee8dac97243bd8518 Mon Sep 17 00:00:00 2001 From: giovanni Date: Sat, 24 Jan 2026 14:17:50 +0700 Subject: [PATCH 09/37] adjust get weight remaining --- .../repositories/hpp_per_kandang.repository.go | 11 ++++++----- .../modules/repports/services/repport.service.go | 12 ++++-------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/internal/modules/repports/repositories/hpp_per_kandang.repository.go b/internal/modules/repports/repositories/hpp_per_kandang.repository.go index 03d56fc6..eeb09e92 100644 --- a/internal/modules/repports/repositories/hpp_per_kandang.repository.go +++ b/internal/modules/repports/repositories/hpp_per_kandang.repository.go @@ -23,6 +23,7 @@ type HppPerKandangRow struct { // RemainingChickenBirds float64 // RemainingChickenWeight float64 EggProductionWeightKgRemaining float64 + // AverageWeightEggPerPiece float64 // EggProductionPiecesRemaining float64 // EggProductionTotalWeightKg float64 // EggProductionTotalPieces float64 @@ -229,8 +230,8 @@ func (r *hppPerKandangRepository) GetWeightRemainingByProjectFlockKandangIDs(ctx ) type eggRow struct { - ProjectFlockKandangID uint - EggProductionWeightKgRemaining float64 + ProjectFlockKandangID uint + AverageWeightEggPerPiece float64 // EggProductionPiecesRemaining float64 // EggProductionTotalWeightKg float64 // EggProductionTotalPieces float64 @@ -241,7 +242,7 @@ func (r *hppPerKandangRepository) GetWeightRemainingByProjectFlockKandangIDs(ctx Table("recordings AS r"). Select(` r.project_flock_kandangs_id AS project_flock_kandang_id, - COALESCE((SUM(re.weight) / NULLIF(SUM(re.total_qty), 0)) * SUM(re.total_qty - re.total_used), 0) AS egg_production_weight_kg_remaining`). + COALESCE(SUM(re.weight) / NULLIF(SUM(re.total_qty), 0), 0) AS average_weight_egg_per_piece`). Joins("LEFT JOIN (?) AS la ON la.approvable_id = r.id", latestApproval). Joins("LEFT JOIN recording_eggs AS re ON re.recording_id = r.id"). Where("r.project_flock_kandangs_id IN ?", projectFlockKandangIDs). @@ -257,8 +258,8 @@ func (r *hppPerKandangRepository) GetWeightRemainingByProjectFlockKandangIDs(ctx result := make(map[uint]HppPerKandangRow, len(eggRows)) for _, row := range eggRows { result[row.ProjectFlockKandangID] = HppPerKandangRow{ - ProjectFlockKandangID: row.ProjectFlockKandangID, - EggProductionWeightKgRemaining: row.EggProductionWeightKgRemaining, + ProjectFlockKandangID: row.ProjectFlockKandangID, + // AverageWeightEggPerPiece: row.AverageWeightEggPerPiece, // EggProductionPiecesRemaining: row.EggProductionPiecesRemaining, // EggProductionTotalWeightKg: row.EggProductionTotalWeightKg, // EggProductionTotalPieces: row.EggProductionTotalPieces, diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 98905b66..67bf7339 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -1583,9 +1583,7 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes // for pfkID, egg := range eggMap { // if rowIdx, ok := pfkIndex[pfkID]; ok { // repoRows[rowIdx].EggProductionWeightKgRemaining = egg.EggProductionWeightKgRemaining - // // repoRows[rowIdx].EggProductionPiecesRemaining = egg.EggProductionPiecesRemaining - // // repoRows[rowIdx].EggProductionTotalWeightKg = egg.EggProductionTotalWeightKg - // // repoRows[rowIdx].EggProductionTotalPieces = egg.EggProductionTotalPieces + // repoRows[rowIdx].AverageWeightEggPerPiece = egg.AverageWeightEggPerPiece // } // } } @@ -1676,6 +1674,7 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes var eggRemainingWeightFloatRemaining float64 var eggTotalPiecesFloat float64 var eggWeightFloat float64 + var avgWeight float64 eggHpp := 0.0 if s.HppSvc != nil { hppCost, err := s.HppSvc.CalculateHppCost(row.ProjectFlockKandangID, &endOfDay) @@ -1683,11 +1682,12 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes return nil, nil, err } if hppCost != nil { - eggRemainingWeightFloatRemaining = hppCost.Estimation.Kg - hppCost.Real.Kg eggPiecesFloatRemaining = hppCost.Estimation.Butir - hppCost.Real.Butir eggHpp = hppCost.Estimation.HargaKg eggTotalPiecesFloat = hppCost.Estimation.Butir eggWeightFloat = hppCost.Estimation.Kg + avgWeight = eggWeightFloat / eggTotalPiecesFloat + eggRemainingWeightFloatRemaining = avgWeight * eggPiecesFloatRemaining } } if math.IsNaN(eggPiecesFloatRemaining) || math.IsInf(eggPiecesFloatRemaining, 0) { @@ -1703,10 +1703,6 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes eggWeightFloat = 0 } - avgWeight := 0.0 - if eggTotalPiecesFloat > 0 { - avgWeight = eggWeightFloat / eggTotalPiecesFloat - } if params.WeightMin != nil && avgWeight < *params.WeightMin { continue } From f4b2408698b7034b4e16492ec390bb0969e2ab03 Mon Sep 17 00:00:00 2001 From: ragilap Date: Sat, 24 Jan 2026 14:20:14 +0700 Subject: [PATCH 10/37] [FIX/BE-US] fix recording stock and dashboard filtering --- internal/modules/dashboards/module.go | 7 ++- .../repositories/dashboard.repository.go | 1 + .../dashboard_stats.repository.go | 26 +++++++- .../dashboards/services/dashboard.service.go | 59 +++++++++++++++++-- .../recordings/services/recording.service.go | 42 +++++++++++++ 5 files changed, 125 insertions(+), 10 deletions(-) diff --git a/internal/modules/dashboards/module.go b/internal/modules/dashboards/module.go index 24574dc7..d7d0d477 100644 --- a/internal/modules/dashboards/module.go +++ b/internal/modules/dashboards/module.go @@ -5,6 +5,8 @@ import ( "github.com/gofiber/fiber/v2" "gorm.io/gorm" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonService "gitlab.com/mbugroup/lti-api.git/internal/common/service" rDashboard "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/repositories" sDashboard "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/services" @@ -16,11 +18,12 @@ type DashboardModule struct{} func (DashboardModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { dashboardRepo := rDashboard.NewDashboardRepository(db) + hppCostRepo := commonRepo.NewHppCostRepository(db) userRepo := rUser.NewUserRepository(db) - dashboardService := sDashboard.NewDashboardService(dashboardRepo, validate) + hppSvc := commonService.NewHppService(hppCostRepo) + dashboardService := sDashboard.NewDashboardService(dashboardRepo, validate, hppSvc) userService := sUser.NewUserService(userRepo, validate) DashboardRoutes(router, userService, dashboardService) } - diff --git a/internal/modules/dashboards/repositories/dashboard.repository.go b/internal/modules/dashboards/repositories/dashboard.repository.go index 90ee3bf8..eb9f6060 100644 --- a/internal/modules/dashboards/repositories/dashboard.repository.go +++ b/internal/modules/dashboards/repositories/dashboard.repository.go @@ -21,6 +21,7 @@ type DashboardRepository interface { SumSellingPrice(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (SellingPriceAggregate, error) SumEggProductionWeightGrams(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error) SumEggProductionWeightKg(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error) + ListProjectFlockKandangIDsByEggProduction(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]uint, error) GetRecordingWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]RecordingWeeklyMetric, error) GetUniformityWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]UniformityWeeklyMetric, error) GetStandardWeeklyMetrics(ctx context.Context, weeks []int, filters *validation.DashboardFilter) ([]StandardWeeklyMetric, error) diff --git a/internal/modules/dashboards/repositories/dashboard_stats.repository.go b/internal/modules/dashboards/repositories/dashboard_stats.repository.go index 828dd96c..493851e5 100644 --- a/internal/modules/dashboards/repositories/dashboard_stats.repository.go +++ b/internal/modules/dashboards/repositories/dashboard_stats.repository.go @@ -309,6 +309,27 @@ func (r *DashboardRepositoryImpl) SumEggProductionWeightKg(ctx context.Context, return grams / 1000, nil } +func (r *DashboardRepositoryImpl) ListProjectFlockKandangIDsByEggProduction(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]uint, error) { + var ids []uint + + db := r.DB().WithContext(ctx). + Table("recording_eggs AS re"). + Select("DISTINCT r.project_flock_kandangs_id"). + Joins("JOIN recordings AS r ON r.id = re.recording_id"). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL") + + db = applyDashboardFilters(db, filters) + + if err := db.Scan(&ids).Error; err != nil { + return nil, err + } + + return ids, nil +} + func (r *DashboardRepositoryImpl) GetFeedUsageByUom(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]FeedUsageByUom, error) { var rows []FeedUsageByUom @@ -553,7 +574,7 @@ func (r *DashboardRepositoryImpl) GetComparisonWeeklyMetrics(ctx context.Context var rows []ComparisonWeeklyMetric db := r.DB().WithContext(ctx). Table("recordings AS r"). - Select(fmt.Sprintf(`((r.day - 1) / 7 + 1) AS week, + Select(fmt.Sprintf(`(CASE WHEN r.day IS NULL OR r.day <= 0 THEN 1 ELSE ((r.day - 1) / 7 + 1) END) AS week, %s AS series_id, COALESCE(AVG(%s), 0) AS value`, seriesExpr, metricExpr)). Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). @@ -561,8 +582,7 @@ func (r *DashboardRepositoryImpl) GetComparisonWeeklyMetrics(ctx context.Context Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id"). Joins("JOIN locations AS loc ON loc.id = k.location_id"). Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). - Where("r.deleted_at IS NULL"). - Where("r.day IS NOT NULL AND r.day > 0") + Where("r.deleted_at IS NULL") db = applyDashboardFilters(db, filters) diff --git a/internal/modules/dashboards/services/dashboard.service.go b/internal/modules/dashboards/services/dashboard.service.go index b4635b2e..ce2cc0f8 100644 --- a/internal/modules/dashboards/services/dashboard.service.go +++ b/internal/modules/dashboards/services/dashboard.service.go @@ -10,6 +10,7 @@ import ( "strings" "time" + commonService "gitlab.com/mbugroup/lti-api.git/internal/common/service" "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/dto" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/validations" @@ -27,13 +28,15 @@ type dashboardService struct { Log *logrus.Logger Validate *validator.Validate Repository repository.DashboardRepository + HppSvc commonService.HppService } -func NewDashboardService(repo repository.DashboardRepository, validate *validator.Validate) DashboardService { +func NewDashboardService(repo repository.DashboardRepository, validate *validator.Validate, hppSvc commonService.HppService) DashboardService { return &dashboardService{ Log: utils.Log, Validate: validate, Repository: repo, + HppSvc: hppSvc, } } @@ -592,13 +595,13 @@ func buildAggregateComparisonPercent(weeks []int, seriesRows []repository.Compar count++ } - if count == 0 { - continue - } - if result[week] == nil { result[week] = map[uint]float64{} } + if count == 0 { + result[week][series.Id] = 0 + continue + } result[week][series.Id] = sum / count } } @@ -846,6 +849,21 @@ func percentDelta(current, last float64) float64 { } func (s dashboardService) calculateHppGlobal(ctx context.Context, startDate, endExclusive, endDate time.Time, location *time.Location) (float64, float64, error) { + if s.HppSvc != nil { + currentHpp, err := s.hppGlobalForPeriod(ctx, startDate, endExclusive) + if err != nil { + return 0, 0, err + } + + lastMonthStart, lastMonthEndExclusive := monthRange(endDate.AddDate(0, -1, 0), location) + lastHpp, err := s.hppGlobalForPeriod(ctx, lastMonthStart, lastMonthEndExclusive) + if err != nil { + return 0, 0, err + } + + return currentHpp, lastHpp, nil + } + totalEggKg, err := s.Repository.SumEggProductionWeightKg(ctx, startDate, endExclusive, nil) if err != nil { return 0, 0, err @@ -878,6 +896,37 @@ func (s dashboardService) calculateHppGlobal(ctx context.Context, startDate, end return hppCurrent, hppLast, nil } +func (s dashboardService) hppGlobalForPeriod(ctx context.Context, startDate, endExclusive time.Time) (float64, error) { + kandangIDs, err := s.Repository.ListProjectFlockKandangIDsByEggProduction(ctx, startDate, endExclusive, nil) + if err != nil { + return 0, err + } + if len(kandangIDs) == 0 { + return 0, nil + } + + endOfPeriod := endExclusive.Add(-time.Nanosecond) + totalCost := 0.0 + totalWeightKg := 0.0 + for _, kandangID := range kandangIDs { + hppCost, err := s.HppSvc.CalculateHppCost(kandangID, &endOfPeriod) + if err != nil { + return 0, err + } + if hppCost == nil { + continue + } + totalCost += hppCost.Estimation.Total + totalWeightKg += hppCost.Estimation.Kg + } + + if totalWeightKg <= 0 { + return 0, nil + } + + return totalCost / totalWeightKg, nil +} + func (s dashboardService) calculateSellingPrice(ctx context.Context, endDate time.Time, location *time.Location) (float64, float64, error) { startPrevMonth, endPrevMonthExclusive := monthRange(endDate.AddDate(0, -1, 0), location) currentEndExclusive := endDate.AddDate(0, 0, 1) diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index d490185d..25103c2f 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -287,6 +287,7 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent } mappedDepletions := recordingutil.MapDepletions(createdRecording.Id, req.Depletions) + depletionDesired := resetDepletionQuantitiesForFIFO(mappedDepletions, s.FifoSvc != nil) if s.FifoSvc != nil && len(mappedDepletions) > 0 { sourceWarehouseID, err := s.resolvePopulationWarehouseID(ctx, req.ProjectFlockKandangId) if err != nil { @@ -301,6 +302,7 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent return err } if s.FifoSvc != nil { + applyDepletionDesiredQuantities(mappedDepletions, depletionDesired, true) note := fmt.Sprintf("Recording-Create#%d", createdRecording.Id) if err := s.consumeRecordingDepletions(ctx, tx, mappedDepletions, note, actorID); err != nil { return err @@ -465,6 +467,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } mappedDepletions := recordingutil.MapDepletions(recordingEntity.Id, req.Depletions) + depletionDesired := resetDepletionQuantitiesForFIFO(mappedDepletions, s.FifoSvc != nil) if s.FifoSvc != nil && len(mappedDepletions) > 0 { sourceWarehouseID, err := s.resolvePopulationWarehouseID(ctx, recordingEntity.ProjectFlockKandangId) if err != nil { @@ -480,6 +483,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } if s.FifoSvc != nil { + applyDepletionDesiredQuantities(mappedDepletions, depletionDesired, true) note := fmt.Sprintf("Recording-Edit#%d", recordingEntity.Id) if err := s.consumeRecordingDepletions(ctx, tx, mappedDepletions, note, actorID); err != nil { return err @@ -929,6 +933,9 @@ func (s *recordingService) consumeRecordingDepletions( destDelta := depletion.Qty + depletion.PendingQty if depletion.ProductWarehouseId != 0 && destDelta > 0 && strings.TrimSpace(note) != "" && actorID != 0 { + if depletion.ProductWarehouseId == sourceWarehouseID { + continue + } log := &entity.StockLog{ ProductWarehouseId: depletion.ProductWarehouseId, CreatedBy: actorID, @@ -1066,6 +1073,9 @@ func (s *recordingService) releaseRecordingDepletions( destDelta := depletion.Qty + depletion.PendingQty if depletion.ProductWarehouseId != 0 && destDelta > 0 && strings.TrimSpace(note) != "" && actorID != 0 { + if depletion.ProductWarehouseId == sourceWarehouseID { + continue + } log := &entity.StockLog{ ProductWarehouseId: depletion.ProductWarehouseId, CreatedBy: actorID, @@ -1235,6 +1245,11 @@ type desiredStock struct { Pending float64 } +type desiredDepletion struct { + Qty float64 + Pending float64 +} + func resetStockQuantitiesForFIFO(stocks []entity.RecordingStock, enabled bool) []desiredStock { desired := make([]desiredStock, len(stocks)) for i := range stocks { @@ -1269,6 +1284,33 @@ func applyStockDesiredQuantities(stocks []entity.RecordingStock, desired []desir } } +func resetDepletionQuantitiesForFIFO(depletions []entity.RecordingDepletion, enabled bool) []desiredDepletion { + desired := make([]desiredDepletion, len(depletions)) + for i := range depletions { + desired[i].Qty = depletions[i].Qty + desired[i].Pending = depletions[i].PendingQty + if !enabled { + continue + } + depletions[i].Qty = 0 + depletions[i].PendingQty = 0 + } + return desired +} + +func applyDepletionDesiredQuantities(depletions []entity.RecordingDepletion, desired []desiredDepletion, enabled bool) { + if !enabled { + return + } + for i := range depletions { + if i >= len(desired) { + break + } + depletions[i].Qty = desired[i].Qty + depletions[i].PendingQty = desired[i].Pending + } +} + func (s *recordingService) syncRecordingStocks( ctx context.Context, tx *gorm.DB, From 7a8f813e1f7d34098a9c3138f4b80bff59c388d3 Mon Sep 17 00:00:00 2001 From: giovanni Date: Mon, 26 Jan 2026 11:44:22 +0700 Subject: [PATCH 11/37] adjust calculate hpp filter delivery date marketing delivery --- .../repository/common.hpp.repository.go | 20 +++--- internal/common/service/common.hpp.service.go | 72 ++++++++++--------- .../repports/services/repport.service.go | 9 ++- 3 files changed, 56 insertions(+), 45 deletions(-) diff --git a/internal/common/repository/common.hpp.repository.go b/internal/common/repository/common.hpp.repository.go index c005e24e..da4b1908 100644 --- a/internal/common/repository/common.hpp.repository.go +++ b/internal/common/repository/common.hpp.repository.go @@ -20,7 +20,7 @@ type HppCostRepository interface { GetTotalPopulation(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) GetPulletCost(ctx context.Context, projectFlockKandangId uint) (float64, error) GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error) - GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error) + GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, startDate *time.Time, endDate *time.Time) (float64, float64, error) GetProjectFlockIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (uint, error) GetTransferSourceSummary(ctx context.Context, projectFlockKandangId uint) (uint, float64, error) } @@ -196,10 +196,10 @@ func (r *HppRepositoryImpl) GetPulletCost(ctx context.Context, projectFlockKanda } func (r *HppRepositoryImpl) GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error) { - if date == nil { - now := time.Now() - date = &now - } + // if date == nil { + // now := time.Now() + // date = &now + // } var totals struct { TotalPieces float64 @@ -222,12 +222,13 @@ func (r *HppRepositoryImpl) GetEggProduksiPiecesAndWeightKgByProjectFlockKandang func (r *HppRepositoryImpl) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds( ctx context.Context, projectFlockKandangIDs []uint, - date *time.Time, + startDate *time.Time, + endDate *time.Time, ) (float64, float64, error) { - if date == nil { + if endDate == nil { now := time.Now() - date = &now + endDate = &now } type subResult struct { @@ -251,7 +252,8 @@ func (r *HppRepositoryImpl) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangI ). Joins("JOIN marketing_delivery_products mdp ON mdp.id = sa.usable_id"). Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). - Where("r.record_datetime <= ?", *date) + Where("r.record_datetime <= ?", *endDate). + Where("mdp.delivery_date = ?", *startDate) var totals struct { TotalPieces float64 diff --git a/internal/common/service/common.hpp.service.go b/internal/common/service/common.hpp.service.go index 44f2dd5f..b1f1a1b1 100644 --- a/internal/common/service/common.hpp.service.go +++ b/internal/common/service/common.hpp.service.go @@ -11,10 +11,10 @@ import ( type HppService interface { CalculateHppCost(projectFlockKandangId uint, date *time.Time) (*HppCostResponse, error) GetTotalDepresiasiFlockGrowing(sourceProjectFlockID uint, date *time.Time) (float64, error) - GetTotalProductionCost(projectFlockKandangId uint, date *time.Time, totalDepresiasiGrowing float64) (float64, error) - GetBudgetKandangLaying(projectFlockKandangId uint, date *time.Time) (float64, error) + GetTotalProductionCost(projectFlockKandangId uint, endDate *time.Time, depresiasiTransfer float64) (float64, error) + GetBudgetKandangLaying(projectFlockKandangId uint, endDate *time.Time) (float64, error) GetDepresiasiTransfer(projectFlockKandangId uint, date *time.Time) (float64, error) - GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, date *time.Time) (*HppCostResponse, error) + GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, startDate *time.Time, endDate *time.Time) (*HppCostResponse, error) } type HppCostResponse struct { @@ -44,17 +44,25 @@ func (s *hppService) CalculateHppCost(projectFlockKandangId uint, date *time.Tim date = &now } - depresiasiTransfer, err := s.GetDepresiasiTransfer(projectFlockKandangId, date) + location, err := time.LoadLocation("Asia/Jakarta") if err != nil { return nil, err } - totalProductionCost, err := s.GetTotalProductionCost(projectFlockKandangId, date, depresiasiTransfer) + startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, location) + endOfDay := startOfDay.Add(24 * time.Hour) + + depresiasiTransfer, err := s.GetDepresiasiTransfer(projectFlockKandangId, &endOfDay) if err != nil { return nil, err } - return s.GetHppEstimationDanRealisasi(totalProductionCost, projectFlockKandangId, date) + totalProductionCost, err := s.GetTotalProductionCost(projectFlockKandangId, &endOfDay, depresiasiTransfer) + if err != nil { + return nil, err + } + + return s.GetHppEstimationDanRealisasi(totalProductionCost, projectFlockKandangId, &startOfDay, &endOfDay) } @@ -101,23 +109,23 @@ func (s *hppService) GetTotalDepresiasiFlockGrowing(sourceProjectFlockID uint, d return docCost + budgetCost + expedisionCost + feedCost + ovkCost, nil } -func (s *hppService) GetTotalProductionCost(projectFlockKandangId uint, date *time.Time, depresiasiTransfer float64) (float64, error) { - if date == nil { - now := time.Now() - date = &now - } +func (s *hppService) GetTotalProductionCost(projectFlockKandangId uint, endDate *time.Time, depresiasiTransfer float64) (float64, error) { + // if date == nil { + // now := time.Now() + // date = &now + // } costPullet, err := s.hppRepo.GetPulletCost(context.Background(), projectFlockKandangId) if err != nil { return 0, err } - costFeed, err := s.hppRepo.GetFeedUsageCost(context.Background(), []uint{projectFlockKandangId}, date) + costFeed, err := s.hppRepo.GetFeedUsageCost(context.Background(), []uint{projectFlockKandangId}, endDate) if err != nil { return 0, err } - costOvk, err := s.hppRepo.GetOvkUsageCost(context.Background(), []uint{projectFlockKandangId}, date) + costOvk, err := s.hppRepo.GetOvkUsageCost(context.Background(), []uint{projectFlockKandangId}, endDate) if err != nil { return 0, err } @@ -127,7 +135,7 @@ func (s *hppService) GetTotalProductionCost(projectFlockKandangId uint, date *ti return 0, err } - costBudget, err := s.GetBudgetKandangLaying(projectFlockKandangId, date) + costBudget, err := s.GetBudgetKandangLaying(projectFlockKandangId, endDate) if err != nil { return 0, err } @@ -135,11 +143,11 @@ func (s *hppService) GetTotalProductionCost(projectFlockKandangId uint, date *ti return depresiasiTransfer + costPullet + costFeed + costOvk + costExpedision + costBudget, nil } -func (s *hppService) GetBudgetKandangLaying(projectFlockKandangId uint, date *time.Time) (float64, error) { - if date == nil { - now := time.Now() - date = &now - } +func (s *hppService) GetBudgetKandangLaying(projectFlockKandangId uint, endDate *time.Time) (float64, error) { + // if date == nil { + // now := time.Now() + // date = &now + // } if s.hppRepo == nil { return 0, nil @@ -155,12 +163,12 @@ func (s *hppService) GetBudgetKandangLaying(projectFlockKandangId uint, date *ti return 0, err } - eggProduksiPiecesFlock, _, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), projectFlockKandangIds, date) + eggProduksiPiecesFlock, _, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), projectFlockKandangIds, endDate) if err != nil { return 0, err } - eggProduksiPiecesKandang, _, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, date) + eggProduksiPiecesKandang, _, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate) if err != nil { return 0, err } @@ -177,11 +185,11 @@ func (s *hppService) GetBudgetKandangLaying(projectFlockKandangId uint, date *ti return (totalBudgetCost * eggProduksiPiecesKandang) / eggProduksiPiecesFlock, nil } -func (s *hppService) GetDepresiasiTransfer(projectFlockKandangId uint, date *time.Time) (float64, error) { - if date == nil { - now := time.Now() - date = &now - } +func (s *hppService) GetDepresiasiTransfer(projectFlockKandangId uint, endDate *time.Time) (float64, error) { + // if endDate == nil { + // now := time.Now() + // endDate = &now + // } if s.hppRepo == nil { return 0, nil @@ -205,7 +213,7 @@ func (s *hppService) GetDepresiasiTransfer(projectFlockKandangId uint, date *tim return 0, nil } - totalDepresiasiFlockGrowing, err := s.GetTotalDepresiasiFlockGrowing(sourceProjectFlockID, date) + totalDepresiasiFlockGrowing, err := s.GetTotalDepresiasiFlockGrowing(sourceProjectFlockID, endDate) if err != nil { return 0, err } @@ -213,22 +221,18 @@ func (s *hppService) GetDepresiasiTransfer(projectFlockKandangId uint, date *tim return (totalDepresiasiFlockGrowing * transferTotalQty) / totalPopulationFlockGrowing, nil } -func (s *hppService) GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, date *time.Time) (*HppCostResponse, error) { - if date == nil { - now := time.Now() - date = &now - } +func (s *hppService) GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, startDate *time.Time, endDate *time.Time) (*HppCostResponse, error) { if s.hppRepo == nil { return &HppCostResponse{}, nil } - estimPieces, estimWeightKg, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, date) + estimPieces, estimWeightKg, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate) if err != nil { return nil, err } - realPieces, realWeightKg, err := s.hppRepo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, date) + realPieces, realWeightKg, err := s.hppRepo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, startDate, endDate) if err != nil { return nil, err } diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 9c5b8faf..fb5d72ba 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -1617,7 +1617,7 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes var avgWeight float64 eggHpp := 0.0 if s.HppSvc != nil { - hppCost, err := s.HppSvc.CalculateHppCost(row.ProjectFlockKandangID, &endOfDay) + hppCost, err := s.HppSvc.CalculateHppCost(row.ProjectFlockKandangID, &periodDate) if err != nil { return nil, nil, err } @@ -1626,7 +1626,9 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes eggHpp = hppCost.Estimation.HargaKg eggTotalPiecesFloat = hppCost.Estimation.Butir eggWeightFloat = hppCost.Estimation.Kg - avgWeight = eggWeightFloat / eggTotalPiecesFloat + if eggTotalPiecesFloat > 0 { + avgWeight = eggWeightFloat / eggTotalPiecesFloat + } eggRemainingWeightFloatRemaining = avgWeight * eggPiecesFloatRemaining } } @@ -1642,6 +1644,9 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes if math.IsNaN(eggWeightFloat) || math.IsInf(eggWeightFloat, 0) { eggWeightFloat = 0 } + if math.IsNaN(avgWeight) || math.IsInf(avgWeight, 0) { + avgWeight = 0 + } if params.WeightMin != nil && avgWeight < *params.WeightMin { continue From 798dd7f9a39502cdd5f68bdcda44065a6f97845b Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Mon, 26 Jan 2026 13:00:30 +0700 Subject: [PATCH 12/37] Fix[BE]: fixing and refactoring closing keuangan to use hpp service and repo for some getter data --- .../closings/dto/closingKeuangan.dto.go | 136 ++-- internal/modules/closings/module.go | 5 +- .../closingKeuangan.repository.go | 365 ---------- .../closings/services/closing.service.go | 7 +- .../services/closingKeuangan.service.go | 672 +++++++----------- .../salesorder_delivery_product.repository.go | 25 +- .../repositories/project_budget.repository.go | 1 + .../repositories/recording.repository.go | 33 + 8 files changed, 423 insertions(+), 821 deletions(-) delete mode 100644 internal/modules/closings/repositories/closingKeuangan.repository.go diff --git a/internal/modules/closings/dto/closingKeuangan.dto.go b/internal/modules/closings/dto/closingKeuangan.dto.go index 6ca19d5c..a7238b17 100644 --- a/internal/modules/closings/dto/closingKeuangan.dto.go +++ b/internal/modules/closings/dto/closingKeuangan.dto.go @@ -1,8 +1,12 @@ package dto -// === CLOSING KEUANGAN CODES === +import ( + "strings" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) -// Closing HPP Codes type ClosingHPPCode string const ( @@ -14,36 +18,30 @@ const ( HPPCodeEkspedisi ClosingHPPCode = "EKSPEDISI" ) -// Closing Profit Loss Codes type ClosingProfitLossCode string const ( - PLCodeSales ClosingProfitLossCode = "SALES" - PLCodeSapronak ClosingProfitLossCode = "SAPRONAK" - PLCodeOverhead ClosingProfitLossCode = "OVERHEAD" + PLCodeSales ClosingProfitLossCode = "SALES" + PLCodeSapronak ClosingProfitLossCode = "SAPRONAK" + PLCodeOverhead ClosingProfitLossCode = "OVERHEAD" PLCodeEkspedisi ClosingProfitLossCode = "EKSPEDISI" ) -// === NEW CLOSING KEUANGAN DTO === - -// FinancialMetrics represents financial metrics with per unit and total amounts type FinancialMetrics struct { RpPerBird float64 `json:"rp_per_bird"` RpPerKg float64 `json:"rp_per_kg"` Amount float64 `json:"amount"` } -// HPPItem represents an item in HPP section type HPPItem struct { ID uint `json:"id"` - Category string `json:"category"` // "purchase" or "overhead" - Code string `json:"code"` // "PAKAN", "OVK", "DOC", "EKSPEDISI" + Category string `json:"category"` + Code string `json:"code"` Label string `json:"label"` Budgeting FinancialMetrics `json:"budgeting"` Realization FinancialMetrics `json:"realization"` } -// HPPSummary represents summary for HPP section type HPPSummary struct { Label string `json:"label"` Budgeting FinancialMetrics `json:"budgeting"` @@ -52,52 +50,41 @@ type HPPSummary struct { EggRealization *FinancialMetrics `json:"egg_realization,omitempty"` } -// HPPSection represents HPP data section type HPPSection struct { - Items []HPPItem `json:"items"` + Items []HPPItem `json:"items"` Summary HPPSummary `json:"summary"` } -// ProfitLossItem represents an item in Profit & Loss section type ProfitLossItem struct { - Code string `json:"code"` // "SALES", "PURCHASE_DOC", "OVERHEAD", "EKSPEDISI" + Code string `json:"code"` Label string `json:"label"` - Type string `json:"type"` // "income", "purchase", "overhead" + Type string `json:"type"` RpPerBird float64 `json:"rp_per_bird"` RpPerKg float64 `json:"rp_per_kg"` Amount float64 `json:"amount"` } -// ProfitLossSummary represents summary for Profit & Loss section type ProfitLossSummary struct { GrossProfit FinancialMetrics `json:"gross_profit"` SubTotal FinancialMetrics `json:"sub_total"` NetProfit FinancialMetrics `json:"net_profit"` } -// ProfitLossSection represents Profit & Loss data section type ProfitLossSection struct { - Items []ProfitLossItem `json:"items"` - Summary ProfitLossSummary `json:"summary"` + Items []ProfitLossItem `json:"items"` + Summary ProfitLossSummary `json:"summary"` } -// ClosingKeuanganData represents the main data structure type ClosingKeuanganData struct { - HPP HPPSection `json:"hpp"` + HPP HPPSection `json:"hpp"` ProfitLoss ProfitLossSection `json:"profit_loss"` } - -// ClosingKeuanganResponse represents the full API response -type ClosingKeuanganResponse struct { - Code int `json:"code"` - Status string `json:"status"` - Message string `json:"message"` - Data ClosingKeuanganData `json:"data"` +type MetricsCalculator struct { + TotalPopulation float64 + ActualPopulation float64 + TotalWeightProduced float64 } -// === MAPPER FUNCTIONS === - -// ToFinancialMetrics creates FinancialMetrics from values func ToFinancialMetrics(rpPerBird, rpPerKg, amount float64) FinancialMetrics { return FinancialMetrics{ RpPerBird: rpPerBird, @@ -106,7 +93,6 @@ func ToFinancialMetrics(rpPerBird, rpPerKg, amount float64) FinancialMetrics { } } -// ToHPPItem creates HPP item func ToHPPItem(id uint, category, code, label string, budgeting, realization FinancialMetrics) HPPItem { return HPPItem{ ID: id, @@ -118,7 +104,6 @@ func ToHPPItem(id uint, category, code, label string, budgeting, realization Fin } } -// ToHPPSummary creates HPP summary func ToHPPSummary(label string, budgeting, realization FinancialMetrics, eggBudgeting, eggRealization *FinancialMetrics) HPPSummary { return HPPSummary{ Label: label, @@ -129,7 +114,6 @@ func ToHPPSummary(label string, budgeting, realization FinancialMetrics, eggBudg } } -// ToHPPSection creates HPP section func ToHPPSection(items []HPPItem, summary HPPSummary) HPPSection { return HPPSection{ Items: items, @@ -137,7 +121,6 @@ func ToHPPSection(items []HPPItem, summary HPPSummary) HPPSection { } } -// ToProfitLossItem creates Profit & Loss item func ToProfitLossItem(code, label, itemType string, rpPerBird, rpPerKg, amount float64) ProfitLossItem { return ProfitLossItem{ Code: code, @@ -149,7 +132,6 @@ func ToProfitLossItem(code, label, itemType string, rpPerBird, rpPerKg, amount f } } -// ToProfitLossSummary creates Profit & Loss summary func ToProfitLossSummary(grossProfit, subTotal, netProfit FinancialMetrics) ProfitLossSummary { return ProfitLossSummary{ GrossProfit: grossProfit, @@ -158,7 +140,6 @@ func ToProfitLossSummary(grossProfit, subTotal, netProfit FinancialMetrics) Prof } } -// ToProfitLossSection creates Profit & Loss section func ToProfitLossSection(items []ProfitLossItem, summary ProfitLossSummary) ProfitLossSection { return ProfitLossSection{ Items: items, @@ -166,7 +147,6 @@ func ToProfitLossSection(items []ProfitLossItem, summary ProfitLossSummary) Prof } } -// ToClosingKeuanganData creates complete closing keuangan data func ToClosingKeuanganData(hpp HPPSection, profitLoss ProfitLossSection) ClosingKeuanganData { return ClosingKeuanganData{ HPP: hpp, @@ -174,12 +154,72 @@ func ToClosingKeuanganData(hpp HPPSection, profitLoss ProfitLossSection) Closing } } -// ToSuccessClosingKeuanganResponse creates success response -func ToSuccessClosingKeuanganResponse(data ClosingKeuanganData) ClosingKeuanganResponse { - return ClosingKeuanganResponse{ - Code: 200, - Status: "success", - Message: "Get closing keuangan successfully", - Data: data, +func (mc *MetricsCalculator) CalculateMetrics(amount float64) (rpPerBird, rpPerKg float64) { + if mc.ActualPopulation > 0 { + rpPerBird = amount / mc.ActualPopulation } + if mc.TotalWeightProduced > 0 { + rpPerKg = amount / mc.TotalWeightProduced + } + return +} + +func (mc *MetricsCalculator) CalculateProfitLossMetrics(amount float64) (rpPerBird, rpPerKg float64) { + if mc.TotalPopulation > 0 { + rpPerBird = amount / mc.TotalPopulation + } + if mc.TotalWeightProduced > 0 { + rpPerKg = amount / mc.TotalWeightProduced + } + return +} + +type ProductFilter struct { + ProjectFlockCategory string +} + +func (pf *ProductFilter) IsEggProduct(product entity.Product) bool { + for _, flag := range product.Flags { + flagName := strings.ToUpper(flag.Name) + if flagName == string(utils.FlagTelur) || + flagName == string(utils.FlagTelurUtuh) || + flagName == string(utils.FlagTelurPecah) || + flagName == string(utils.FlagTelurPutih) || + flagName == string(utils.FlagTelurRetak) { + return true + } + } + return false +} + +func (pf *ProductFilter) IsChickenProduct(product entity.Product) bool { + for _, flag := range product.Flags { + flagName := strings.ToUpper(flag.Name) + if flagName == string(utils.FlagAyamAfkir) || + flagName == string(utils.FlagAyamCulling) || + flagName == string(utils.FlagAyamMati) { + return true + } + } + return false +} + +func (pf *ProductFilter) ShouldIncludeProduct(product entity.Product) bool { + if pf.ProjectFlockCategory == string(utils.ProjectFlockCategoryLaying) { + return pf.IsEggProduct(product) + } + return pf.IsChickenProduct(product) || (!pf.IsEggProduct(product) && !pf.IsChickenProduct(product)) +} + +func (pf *ProductFilter) FilterDeliveryProducts(deliveries []entity.MarketingDeliveryProduct) []entity.MarketingDeliveryProduct { + filtered := make([]entity.MarketingDeliveryProduct, 0) + for _, delivery := range deliveries { + if delivery.MarketingProduct.ProductWarehouse.Product.Id == 0 { + continue + } + if pf.ShouldIncludeProduct(delivery.MarketingProduct.ProductWarehouse.Product) { + filtered = append(filtered, delivery) + } + } + return filtered } diff --git a/internal/modules/closings/module.go b/internal/modules/closings/module.go index 1079663d..666c055d 100644 --- a/internal/modules/closings/module.go +++ b/internal/modules/closings/module.go @@ -25,7 +25,6 @@ type ClosingModule struct{} func (ClosingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { closingRepo := rClosing.NewClosingRepository(db) - closingKeuanganRepo := rClosing.NewClosingKeuanganRepository(db) userRepo := rUser.NewUserRepository(db) projectFlockRepo := rProjectFlock.NewProjectflockRepository(db) projectFlockKandangRepo := rProjectFlock.NewProjectFlockKandangRepository(db) @@ -40,9 +39,11 @@ func (ClosingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * purchaseRepo := rPurchase.NewPurchaseRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) + hppCostRepo := commonRepo.NewHppCostRepository(db) + hppService := commonSvc.NewHppService(hppCostRepo) closingService := sClosing.NewClosingService(closingRepo, projectFlockRepo, projectFlockKandangRepo, marketingRepo, marketingDeliveryProductRepo, approvalService, expenseRealizationRepo, projectBudgetRepo, chickinRepo, purchaseRepo, recordingRepo, standardGrowthDetailRepo, productionStandardDetailRepo, validate) - closingKeuanganService := sClosing.NewClosingKeuanganService(closingKeuanganRepo, projectFlockRepo, projectFlockKandangRepo, marketingDeliveryProductRepo, expenseRealizationRepo, projectBudgetRepo, chickinRepo, recordingRepo) + closingKeuanganService := sClosing.NewClosingKeuanganService(projectFlockRepo, projectFlockKandangRepo, marketingDeliveryProductRepo, expenseRealizationRepo, projectBudgetRepo, chickinRepo, recordingRepo, hppService, hppCostRepo) sapronakService := sClosing.NewSapronakService(closingRepo, projectFlockKandangRepo, validate) userService := sUser.NewUserService(userRepo, validate) diff --git a/internal/modules/closings/repositories/closingKeuangan.repository.go b/internal/modules/closings/repositories/closingKeuangan.repository.go deleted file mode 100644 index dedea807..00000000 --- a/internal/modules/closings/repositories/closingKeuangan.repository.go +++ /dev/null @@ -1,365 +0,0 @@ -package repository - -import ( - "context" - "fmt" - "sort" - "strings" - - "gitlab.com/mbugroup/lti-api.git/internal/common/repository" - "gorm.io/gorm" -) - -// ClosingKeuanganRepository handles database operations for closing keuangan -type ClosingKeuanganRepository interface { - repository.BaseRepository[interface{}] - - // All Product Usage - GetAllProductUsageByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint, flagFilters []string) ([]ProductUsageRow, error) - - // Depletion per kandang - GetTotalDepletionByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) - - // Weight produced from uniformity per kandang - GetTotalWeightProducedFromUniformityByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) - - // DB returns the underlying GORM DB instance - DB() *gorm.DB -} - -type ClosingKeuanganRepositoryImpl struct { - *repository.BaseRepositoryImpl[interface{}] -} - -func NewClosingKeuanganRepository(db *gorm.DB) ClosingKeuanganRepository { - return &ClosingKeuanganRepositoryImpl{ - BaseRepositoryImpl: repository.NewBaseRepository[interface{}](db), - } -} - -// Result Rows - -type ProductUsageRow struct { - ProductID uint `gorm:"column:product_id"` - ProductName string `gorm:"column:product_name"` - FlagNames string `gorm:"column:flag_names"` - TotalQty float64 `gorm:"column:total_qty"` - Price float64 `gorm:"column:price"` - TotalPengeluaran float64 `gorm:"column:total_pengeluaran"` -} - -// GetAllProductUsageByProjectFlockKandangID gets all product usage for a project flock kandang -// Combines data from all usable types: recordings, chickins, marketing, transfers, adjustments -// flagFilters: optional filter to get only specific flags (e.g., ["PAKAN", "OVK"]), empty means get all -func (r *ClosingKeuanganRepositoryImpl) GetAllProductUsageByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint, flagFilters []string) ([]ProductUsageRow, error) { - if projectFlockKandangID == 0 { - return []ProductUsageRow{}, nil - } - - type SubQueryResult struct { - ProductID uint `gorm:"column:product_id"` - ProductName string `gorm:"column:product_name"` - TotalQty float64 `gorm:"column:total_qty"` - Price float64 `gorm:"column:price"` - } - - type AggregatedResult struct { - ProductID uint `gorm:"column:product_id"` - ProductName string `gorm:"column:product_name"` - TotalQty float64 `gorm:"column:total_qty"` - Price float64 `gorm:"column:price"` - PriceCount int `gorm:"-"` // For calculating average price - } - - type FlagResult struct { - ProductID uint `gorm:"column:product_id"` - FlagNames string `gorm:"column:flag_names"` - } - - var allResults []SubQueryResult - - // Subquery 1: Recordings - var recordingsResults []SubQueryResult - err := r.DB().WithContext(ctx). - Table("recordings r"). - Select("pw.product_id, p.name as product_name, "+ - "COALESCE(SUM(CASE "+ - "WHEN sa.stockable_type = 'PURCHASE_ITEMS' THEN COALESCE(sa.qty, 0) "+ - "WHEN sa.stockable_type = 'STOCK_TRANSFER_IN' THEN COALESCE(std.usage_qty, 0) "+ - "WHEN sa.stockable_type = 'TRANSFERTOLAYING_IN' THEN COALESCE(ltt.total_used, 0) "+ - "WHEN sa.stockable_type = 'ADJUSTMENT_IN' THEN COALESCE(adjs.total_used, 0) "+ - "WHEN sa.stockable_type = 'PROJECT_FLOCK_POPULATION' THEN COALESCE(pfp.total_used_qty, 0) "+ - "ELSE 0 END), 0) as total_qty, "+ - "COALESCE(AVG(CASE WHEN sa.stockable_type = 'PURCHASE_ITEMS' THEN COALESCE(pi.price, 0) END), 0) as price"). - Joins("JOIN recording_stocks rs ON rs.recording_id = r.id"). - Joins("JOIN product_warehouses pw ON pw.id = rs.product_warehouse_id"). - Joins("JOIN products p ON p.id = pw.product_id"). - Joins("LEFT JOIN stock_allocations sa ON sa.usable_type = 'RECORDING_STOCK' AND sa.usable_id = rs.id AND sa.status = 'ACTIVE'"). - Joins("LEFT JOIN purchase_items pi ON pi.id = sa.stockable_id AND sa.stockable_type = 'PURCHASE_ITEMS'"). - Joins("LEFT JOIN stock_transfer_details std ON std.id = sa.stockable_id AND sa.stockable_type = 'STOCK_TRANSFER_IN'"). - Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.id = sa.stockable_id AND sa.stockable_type = 'TRANSFERTOLAYING_IN'"). - Joins("LEFT JOIN adjustment_stocks adjs ON adjs.id = sa.stockable_id AND sa.stockable_type = 'ADJUSTMENT_IN'"). - Joins("LEFT JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id AND sa.stockable_type = 'PROJECT_FLOCK_POPULATION'"). - Where("r.project_flock_kandangs_id = ?", projectFlockKandangID). - Where("r.deleted_at IS NULL"). - Group("pw.product_id, p.name"). - Scan(&recordingsResults).Error - - if err != nil { - return nil, fmt.Errorf("failed to get recordings product usage: %w", err) - } - fmt.Printf("[REPO] Recordings query: %d results for projectFlockKandangID=%d\n", len(recordingsResults), projectFlockKandangID) - allResults = append(allResults, recordingsResults...) - - // Subquery 2: Chickins - var chickinsResults []SubQueryResult - err = r.DB().WithContext(ctx). - Table("project_chickins pc"). - Select("pw.product_id, p.name as product_name, "+ - "COALESCE(SUM(pc.usage_qty), 0) as total_qty, "+ - "COALESCE(AVG(COALESCE(pi.price, 0)), 0) as price"). - Joins("JOIN product_warehouses pw ON pw.id = pc.product_warehouse_id"). - Joins("JOIN products p ON p.id = pw.product_id"). - Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id"). - Where("pc.project_flock_kandang_id = ?", projectFlockKandangID). - Where("pc.usage_qty > 0"). - Group("pw.product_id, p.name"). - Scan(&chickinsResults).Error - - if err != nil { - return nil, fmt.Errorf("failed to get chickins product usage: %w", err) - } - fmt.Printf("[REPO] Chickins query: %d results for projectFlockKandangID=%d\n", len(chickinsResults), projectFlockKandangID) - allResults = append(allResults, chickinsResults...) - - // Subquery 3: Marketing Delivery - var marketingResults []SubQueryResult - err = r.DB().WithContext(ctx). - Table("marketing_delivery_products mdp"). - Select("pw.product_id, p.name as product_name, "+ - "COALESCE(SUM(mdp.usage_qty), 0) as total_qty, "+ - "COALESCE(AVG(COALESCE(pi.price, 0)), 0) as price"). - Joins("JOIN marketing_products mp ON mp.id = mdp.marketing_product_id"). - Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id"). - Joins("JOIN products p ON p.id = pw.product_id"). - Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id"). - Where("pw.project_flock_kandang_id = ?", projectFlockKandangID). - Group("pw.product_id, p.name"). - Scan(&marketingResults).Error - - if err != nil { - return nil, fmt.Errorf("failed to get marketing product usage: %w", err) - } - fmt.Printf("[REPO] Marketing query: %d results for projectFlockKandangID=%d\n", len(marketingResults), projectFlockKandangID) - allResults = append(allResults, marketingResults...) - - // Subquery 4: Laying Transfer Sources - var layingTransferResults []SubQueryResult - err = r.DB().WithContext(ctx). - Table("laying_transfer_sources lts"). - Select("pw.product_id, p.name as product_name, "+ - "COALESCE(SUM(lts.usage_qty), 0) as total_qty, "+ - "COALESCE(AVG(COALESCE(pi.price, 0)), 0) as price"). - Joins("JOIN laying_transfers lt ON lt.id = lts.laying_transfer_id"). - Joins("JOIN product_warehouses pw ON pw.id = lts.product_warehouse_id"). - Joins("JOIN products p ON p.id = pw.product_id"). - Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id"). - Where("pw.project_flock_kandang_id = ?", projectFlockKandangID). - Group("pw.product_id, p.name"). - Scan(&layingTransferResults).Error - - if err != nil { - return nil, fmt.Errorf("failed to get laying transfer product usage: %w", err) - } - fmt.Printf("[REPO] Laying Transfer query: %d results for projectFlockKandangID=%d\n", len(layingTransferResults), projectFlockKandangID) - allResults = append(allResults, layingTransferResults...) - - // Subquery 5: Stock Transfer Details - var stockTransferResults []SubQueryResult - err = r.DB().WithContext(ctx). - Table("stock_transfer_details std"). - Select("pw.product_id, p.name as product_name, "+ - "COALESCE(SUM(std.usage_qty), 0) as total_qty, "+ - "COALESCE(AVG(COALESCE(pi.price, 0)), 0) as price"). - Joins("JOIN product_warehouses pw ON pw.id = std.source_product_warehouse_id"). - Joins("JOIN products p ON p.id = std.product_id"). - Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id"). - Where("pw.project_flock_kandang_id = ?", projectFlockKandangID). - Group("pw.product_id, p.name"). - Scan(&stockTransferResults).Error - - if err != nil { - return nil, fmt.Errorf("failed to get stock transfer product usage: %w", err) - } - fmt.Printf("[REPO] Stock Transfer query: %d results for projectFlockKandangID=%d\n", len(stockTransferResults), projectFlockKandangID) - allResults = append(allResults, stockTransferResults...) - - // Subquery 6: Adjustment Stocks - var adjustmentResults []SubQueryResult - err = r.DB().WithContext(ctx). - Table("adjustment_stocks ads"). - Select("pw.product_id, p.name as product_name, "+ - "COALESCE(SUM(ads.usage_qty), 0) as total_qty, "+ - "COALESCE(AVG(COALESCE(pi.price, 0)), 0) as price"). - Joins("JOIN product_warehouses pw ON pw.id = ads.product_warehouse_id"). - Joins("JOIN products p ON p.id = pw.product_id"). - Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id"). - Where("pw.project_flock_kandang_id = ?", projectFlockKandangID). - Where("ads.usage_qty > 0"). - Group("pw.product_id, p.name"). - Scan(&adjustmentResults).Error - - if err != nil { - return nil, fmt.Errorf("failed to get adjustment product usage: %w", err) - } - fmt.Printf("[REPO] Adjustment query: %d results for projectFlockKandangID=%d\n", len(adjustmentResults), projectFlockKandangID) - allResults = append(allResults, adjustmentResults...) - - fmt.Printf("[REPO] Total raw results before aggregation: %d items\n", len(allResults)) - - // Aggregate results by product_id - aggregatedMap := make(map[uint]*AggregatedResult) - for _, result := range allResults { - key := result.ProductID - if existing, exists := aggregatedMap[key]; exists { - existing.TotalQty += result.TotalQty - existing.Price += result.Price - existing.PriceCount++ - } else { - aggregatedMap[key] = &AggregatedResult{ - ProductID: result.ProductID, - ProductName: result.ProductName, - TotalQty: result.TotalQty, - Price: result.Price, - PriceCount: 1, - } - } - } - - fmt.Printf("[REPO] Aggregated to %d unique products\n", len(aggregatedMap)) - - // Get flags for all products - productIDs := make([]uint, 0, len(aggregatedMap)) - for id := range aggregatedMap { - productIDs = append(productIDs, id) - } - - var flagResults []FlagResult - if len(productIDs) > 0 { - err = r.DB().WithContext(ctx). - Table("products p"). - Select("p.id as product_id, STRING_AGG(DISTINCT f.name, ', ') as flag_names"). - Joins("LEFT JOIN flags f ON f.flagable_type = 'products' AND f.flagable_id = p.id"). - Where("p.id IN ?", productIDs). - Group("p.id"). - Scan(&flagResults).Error - - if err != nil { - return nil, fmt.Errorf("failed to get product flags: %w", err) - } - } - fmt.Printf("[REPO] Fetched flags for %d products\n", len(flagResults)) - - // Build flag map - flagMap := make(map[uint]string) - for _, flag := range flagResults { - flagMap[flag.ProductID] = flag.FlagNames - } - - // Combine results and calculate average price - results := make([]ProductUsageRow, 0, len(aggregatedMap)) - for _, agg := range aggregatedMap { - avgPrice := float64(0) - if agg.PriceCount > 0 { - avgPrice = agg.Price / float64(agg.PriceCount) - } - - flagNames := flagMap[agg.ProductID] - - // Apply flag filters if provided - if len(flagFilters) > 0 { - // Check if any of the flagFilters exist in flagNames - matched := false - for _, filter := range flagFilters { - if containsIgnoreCase(flagNames, filter) { - matched = true - break - } - } - if !matched { - continue // Skip this product if no flag matches - } - } - - results = append(results, ProductUsageRow{ - ProductID: agg.ProductID, - ProductName: agg.ProductName, - FlagNames: flagNames, - TotalQty: agg.TotalQty, - Price: avgPrice, - TotalPengeluaran: agg.TotalQty * avgPrice, - }) - } - - fmt.Printf("[REPO] After filtering with flagFilters=%v: %d results\n", flagFilters, len(results)) - for i, r := range results { - fmt.Printf("[REPO] Result[%d]: ProductID=%d, ProductName=%s, FlagNames=%s, TotalQty=%.2f, Price=%.2f, TotalPengeluaran=%.2f\n", - i, r.ProductID, r.ProductName, r.FlagNames, r.TotalQty, r.Price, r.TotalPengeluaran) - } - - // Sort by product name - sort.Slice(results, func(i, j int) bool { - return results[i].ProductName < results[j].ProductName - }) - - fmt.Printf("[REPO] Final sorted results: %d items\n", len(results)) - return results, nil -} - -// GetTotalDepletionByProjectFlockKandangID gets total depletion for a specific kandang -func (r *ClosingKeuanganRepositoryImpl) GetTotalDepletionByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) { - var result float64 - err := r.DB().WithContext(ctx). - Table("recording_depletions"). - Select("COALESCE(SUM(recording_depletions.qty), 0)"). - Joins("JOIN recordings ON recordings.id = recording_depletions.recording_id"). - Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = recordings.project_flock_kandangs_id"). - Where("project_flock_kandangs.id = ?", projectFlockKandangID). - Scan(&result).Error - return result, err -} - -// GetTotalWeightProducedFromUniformityByProjectFlockKandangID calculates total weight produced from uniformity data for a specific kandang -// Formula: (mean_up / 1.10) * chick_qty_of_weight / 1000 -func (r *ClosingKeuanganRepositoryImpl) GetTotalWeightProducedFromUniformityByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) { - if projectFlockKandangID == 0 { - return 0, nil - } - - var uniformity struct { - MeanUp float64 - ChickQtyOfWeight float64 - } - - err := r.DB().WithContext(ctx). - Table("project_flock_kandang_uniformity"). - Select("mean_up, chick_qty_of_weight"). - Where("project_flock_kandang_id = ?", projectFlockKandangID). - Order("id DESC"). - Limit(1). - Scan(&uniformity).Error - - if err != nil { - return 0, err - } - - // Calculate weight: (mean_up / 1.10) * chick_qty_of_weight / 1000 - totalWeight := (uniformity.MeanUp / 1.10) * uniformity.ChickQtyOfWeight / 1000 - - return totalWeight, nil -} - -// containsIgnoreCase checks if a string contains a substring (case-insensitive) -func containsIgnoreCase(str, substr string) bool { - return strings.Contains(strings.ToUpper(str), strings.ToUpper(substr)) -} diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 02942f44..5494a835 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -162,7 +162,12 @@ func (s closingService) GetProjectFlockByID(c *fiber.Ctx, id uint) (*entity.Proj func (s closingService) GetPenjualan(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error) { - realisasi, err := s.MarketingDeliveryProductRepo.GetClosingPenjualan(c.Context(), projectFlockID, projectFlockKandangID) + projectFlock, err := s.ProjectFlockRepo.GetByID(c.Context(), projectFlockID, nil) + if err != nil { + return nil, err + } + + realisasi, err := s.MarketingDeliveryProductRepo.GetClosingPenjualan(c.Context(), projectFlockID, projectFlockKandangID, projectFlock.Category) if err != nil { return nil, err } diff --git a/internal/modules/closings/services/closingKeuangan.service.go b/internal/modules/closings/services/closingKeuangan.service.go index 0f3351f7..85aa5f1c 100644 --- a/internal/modules/closings/services/closingKeuangan.service.go +++ b/internal/modules/closings/services/closingKeuangan.service.go @@ -2,20 +2,19 @@ package service import ( "errors" - "strings" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/dto" - repository "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories" expenseRealizationRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" marketingDeliveryProductRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" chickinRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" projectflockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" recordingRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" - "github.com/gofiber/fiber/v2" - "github.com/sirupsen/logrus" "gorm.io/gorm" ) @@ -25,9 +24,28 @@ type ClosingKeuanganService interface { GetClosingKeuanganByKandang(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID uint) (*dto.ClosingKeuanganData, error) } +// CostData holds all cost-related information +type CostData struct { + FeedCost float64 + OvkCost float64 + ChickenCost float64 + ExpeditionCost float64 + BudgetOperational float64 + RealizationOperational float64 +} + +// ProductionData holds all production and sales related information +type ProductionData struct { + TotalPopulationIn float64 + TotalDepletion float64 + TotalWeightProduced float64 + TotalEggWeightKg float64 + TotalWeightSold float64 + TotalSalesAmount float64 +} + type closingKeuanganService struct { Log *logrus.Logger - ClosingKeuanganRepo repository.ClosingKeuanganRepository ProjectFlockRepo projectflockRepository.ProjectflockRepository ProjectFlockKandangRepo projectflockRepository.ProjectFlockKandangRepository MarketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository @@ -35,10 +53,11 @@ type closingKeuanganService struct { ProjectBudgetRepo projectflockRepository.ProjectBudgetRepository ChickinRepo chickinRepository.ProjectChickinRepository RecordingRepo recordingRepository.RecordingRepository + HppSvc commonSvc.HppService + HppRepo commonRepo.HppCostRepository } func NewClosingKeuanganService( - closingKeuanganRepo repository.ClosingKeuanganRepository, projectFlockRepo projectflockRepository.ProjectflockRepository, projectFlockKandangRepo projectflockRepository.ProjectFlockKandangRepository, marketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository, @@ -46,10 +65,11 @@ func NewClosingKeuanganService( projectBudgetRepo projectflockRepository.ProjectBudgetRepository, chickinRepo chickinRepository.ProjectChickinRepository, recordingRepo recordingRepository.RecordingRepository, + hppSvc commonSvc.HppService, + hppRepo commonRepo.HppCostRepository, ) ClosingKeuanganService { return &closingKeuanganService{ Log: utils.Log, - ClosingKeuanganRepo: closingKeuanganRepo, ProjectFlockRepo: projectFlockRepo, ProjectFlockKandangRepo: projectFlockKandangRepo, MarketingDeliveryProductRepo: marketingDeliveryProductRepo, @@ -57,6 +77,8 @@ func NewClosingKeuanganService( ProjectBudgetRepo: projectBudgetRepo, ChickinRepo: chickinRepo, RecordingRepo: recordingRepo, + HppSvc: hppSvc, + HppRepo: hppRepo, } } @@ -73,30 +95,12 @@ func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") } - budgets, err := s.ProjectBudgetRepo.GetByProjectFlockID(c.Context(), projectFlockID) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch budgets") - } - - // Preload Nonstock.Flags manually - var budgetIDs []uint - for _, b := range budgets { - budgetIDs = append(budgetIDs, b.Id) - } - if len(budgetIDs) > 0 { - err = s.ProjectBudgetRepo.DB().WithContext(c.Context()). - Preload("Nonstock.Flags"). - Where("id IN ?", budgetIDs). - Find(&budgets).Error - } - - // Get all kandang for this project flock - kandangs, err := s.ProjectFlockKandangRepo.GetByProjectFlockID(c.Context(), projectFlockID) + projectFlockKandangs, err := s.ProjectFlockKandangRepo.GetByProjectFlockID(c.Context(), projectFlockID) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch kandangs") } - return s.calculateClosingKeuangan(c, projectFlock, budgets, kandangs, projectFlockID) + return s.calculateClosingKeuangan(c, projectFlock, projectFlockKandangs) } func (s closingKeuanganService) GetClosingKeuanganByKandang(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID uint) (*dto.ClosingKeuanganData, error) { @@ -107,12 +111,11 @@ func (s closingKeuanganService) GetClosingKeuanganByKandang(c *fiber.Ctx, projec return nil, err } - // Validate and fetch project flock kandang - kandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), projectFlockKandangID) + projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), projectFlockKandangID) if err != nil { return nil, fiber.NewError(fiber.StatusNotFound, "Project flock kandang not found") } - if kandang.ProjectFlockId != projectFlockID { + if projectFlockKandang.ProjectFlockId != projectFlockID { return nil, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang does not belong to this project flock") } @@ -121,417 +124,249 @@ func (s closingKeuanganService) GetClosingKeuanganByKandang(c *fiber.Ctx, projec return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") } - budgets, err := s.ProjectBudgetRepo.GetByProjectFlockID(c.Context(), projectFlockID) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch budgets") - } + projectFlockKandangs := []entity.ProjectFlockKandang{*projectFlockKandang} - // Preload Nonstock.Flags manually - var budgetIDs []uint - for _, b := range budgets { - budgetIDs = append(budgetIDs, b.Id) - } - if len(budgetIDs) > 0 { - err = s.ProjectBudgetRepo.DB().WithContext(c.Context()). - Preload("Nonstock.Flags"). - Where("id IN ?", budgetIDs). - Find(&budgets).Error - } - - kandangs := []entity.ProjectFlockKandang{*kandang} - - return s.calculateClosingKeuangan(c, projectFlock, budgets, kandangs, projectFlockID) + return s.calculateClosingKeuangan(c, projectFlock, projectFlockKandangs) } -func (s closingKeuanganService) calculateClosingKeuangan(c *fiber.Ctx, projectFlock *entity.ProjectFlock, budgets []entity.ProjectBudget, kandangs []entity.ProjectFlockKandang, scopeID uint) (*dto.ClosingKeuanganData, error) { - // Define flag filters using constants - pakanFilters := []string{string(utils.FlagPakan), string(utils.FlagPreStarter), string(utils.FlagStarter), string(utils.FlagFinisher)} - ovkFilters := []string{string(utils.FlagOVK), string(utils.FlagObat), string(utils.FlagVitamin), string(utils.FlagKimia)} - ayamFilters := []string{string(utils.FlagDOC), string(utils.FlagPullet), string(utils.FlagLayer)} - allFilters := append(pakanFilters, ovkFilters...) - allFilters = append(allFilters, ayamFilters...) +func (s closingKeuanganService) calculateClosingKeuangan(c *fiber.Ctx, projectFlock *entity.ProjectFlock, projectFlockKandangs []entity.ProjectFlockKandang) (*dto.ClosingKeuanganData, error) { - var allProductUsageRows []repository.ProductUsageRow - - // Get ALL product usage - for _, kandang := range kandangs { - rows, err := s.ClosingKeuanganRepo.GetAllProductUsageByProjectFlockKandangID(c.Context(), kandang.Id, allFilters) - if err == nil { - allProductUsageRows = append(allProductUsageRows, rows...) - } + var projectFlockKandangIDs []uint + for _, projectFlockKandang := range projectFlockKandangs { + projectFlockKandangIDs = append(projectFlockKandangIDs, projectFlockKandang.Id) } - // Classify into categories based on flag priority - var pakanProductUsageRows []repository.ProductUsageRow - var ovkProductUsageRows []repository.ProductUsageRow - var ayamProductUsageRows []repository.ProductUsageRow - - for _, row := range allProductUsageRows { - // Parse flag names from comma-separated string - flagNames := strings.Split(row.FlagNames, ",") - - hasPakanFlag := false - hasOvkFlag := false - hasAyamFlag := false - - for _, flag := range flagNames { - flag = strings.TrimSpace(flag) - if containsItem(pakanFilters, flag) { - hasPakanFlag = true - } - if containsItem(ovkFilters, flag) { - hasOvkFlag = true - } - if containsItem(ayamFilters, flag) { - hasAyamFlag = true - } - } - - // Priority: PAKAN > OVK > AYAM - if hasPakanFlag { - pakanProductUsageRows = append(pakanProductUsageRows, row) - } else if hasOvkFlag { - ovkProductUsageRows = append(ovkProductUsageRows, row) - } else if hasAyamFlag { - ayamProductUsageRows = append(ayamProductUsageRows, row) - } else { - continue - } - } - - - // Calculate total price for each category - var totalPakanPrice, totalOvkPrice, totalAyamPrice float64 - for _, row := range pakanProductUsageRows { - totalPakanPrice += row.TotalPengeluaran - } - for _, row := range ovkProductUsageRows { - totalOvkPrice += row.TotalPengeluaran - } - for _, row := range ayamProductUsageRows { - totalAyamPrice += row.TotalPengeluaran - } - - // Determine if this is per-kandang or per-project-flock scope - isPerKandang := len(kandangs) == 1 + isPerKandang := len(projectFlockKandangs) == 1 var projectFlockKandangID *uint if isPerKandang { - kandangID := kandangs[0].Id + kandangID := projectFlockKandangs[0].Id projectFlockKandangID = &kandangID } + costs, err := s.calculateCosts(c, projectFlock, projectFlockKandangs, projectFlockKandangIDs, projectFlockKandangID) + if err != nil { + return nil, err + } + + productionData, err := s.calculateProductionData(c, projectFlock, projectFlockKandangIDs, projectFlockKandangID) + if err != nil { + return nil, err + } + + hppSection := s.buildHPPSection(c, projectFlock, projectFlockKandangs, costs, productionData) + + profitLossSection := s.buildProfitLossSection(projectFlock, costs, productionData) + + data := dto.ToClosingKeuanganData(hppSection, profitLossSection) + return &data, nil +} + +func (s closingKeuanganService) calculateCosts(c *fiber.Ctx, projectFlock *entity.ProjectFlock, projectFlockKandangs []entity.ProjectFlockKandang, projectFlockKandangIDs []uint, projectFlockKandangID *uint) (*CostData, error) { + costs := &CostData{} var err error - // Fetch realizations - var realizations []entity.ExpenseRealization - if isPerKandang && projectFlockKandangID != nil { - realizations, err = s.ExpenseRealizationRepo.GetClosingOverhead(c.Context(), projectFlock.Id, projectFlockKandangID) - } else { - realizations, err = s.ExpenseRealizationRepo.GetClosingOverhead(c.Context(), projectFlock.Id, nil) - } + costs.FeedCost, err = s.HppRepo.GetFeedUsageCost(c.Context(), projectFlockKandangIDs, nil) if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch realizations") + costs.FeedCost = 0 } - deliveryProducts, err := s.MarketingDeliveryProductRepo.GetDeliveryProductsByProjectFlockID(c.Context(), projectFlock.Id, func(db *gorm.DB) *gorm.DB { - db = db.Preload("MarketingProduct"). - Preload("MarketingProduct.ProductWarehouse"). - Preload("MarketingProduct.ProductWarehouse.Product") - return db - }) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch delivery products") - } - - // Filter by kandang if scope is per-kandang (manual filtering after fetch) - if isPerKandang && projectFlockKandangID != nil { - filteredProducts := make([]entity.MarketingDeliveryProduct, 0) - for _, dp := range deliveryProducts { - pfKandangID := dp.MarketingProduct.ProductWarehouse.ProjectFlockKandangId - if pfKandangID != nil && *pfKandangID == *projectFlockKandangID { - filteredProducts = append(filteredProducts, dp) - } - } - deliveryProducts = filteredProducts - } - - // Fetch chickins - var chickins []entity.ProjectChickin - if isPerKandang && projectFlockKandangID != nil { - chickins, err = s.ChickinRepo.GetByProjectFlockKandangID(c.Context(), *projectFlockKandangID) - } else { - chickins, err = s.ChickinRepo.GetByProjectFlockID(c.Context(), projectFlock.Id) - } + costs.OvkCost, err = s.HppRepo.GetOvkUsageCost(c.Context(), projectFlockKandangIDs, nil) if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch chickins") + costs.OvkCost = 0 } - // Get total depletion - var totalDepletion float64 - if isPerKandang && projectFlockKandangID != nil { - totalDepletion, err = s.ClosingKeuanganRepo.GetTotalDepletionByProjectFlockKandangID(c.Context(), *projectFlockKandangID) - } else { - totalDepletion, err = s.RecordingRepo.GetTotalDepletionByProjectFlockID(c.Context(), projectFlock.Id) - } - if err != nil { - totalDepletion = 0 - } - - totalWeightProduced, _, err := s.RecordingRepo.GetProductionWeightAndQtyByProjectFlockID(c.Context(), projectFlock.Id) - if err != nil { - } - - // Try to get actual weight from uniformity data - var totalWeightFromUniformity float64 - if isPerKandang && projectFlockKandangID != nil { - totalWeightFromUniformity, err = s.ClosingKeuanganRepo.GetTotalWeightProducedFromUniformityByProjectFlockKandangID(c.Context(), *projectFlockKandangID) - } else { - totalWeightFromUniformity, err = s.RecordingRepo.GetTotalWeightProducedFromUniformityByProjectFlockID(c.Context(), projectFlock.Id) - } - if err != nil { - } else if totalWeightFromUniformity > 0 { - totalWeightProduced = totalWeightFromUniformity - } - - // Fetch egg data only for Laying category - var totalEggWeightKg float64 if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) { - // TODO: Replace with actual method to get egg weight from RecordingRepo - // totalEggWeightKg, err = s.RecordingRepo.GetEggWeightByProjectFlockID(c.Context(), projectFlock.Id) - // For now, set to 0 as placeholder - totalEggWeightKg = 0 + for _, projectFlockKandang := range projectFlockKandangs { + depresiasiCost, err := s.HppSvc.GetDepresiasiTransfer(projectFlockKandang.Id, nil) + if err == nil { + costs.ChickenCost += depresiasiCost + } + pulletCost, err := s.HppRepo.GetPulletCost(c.Context(), projectFlockKandang.Id) + if err == nil { + costs.ChickenCost += pulletCost + } + } } else { - totalEggWeightKg = 0 + for _, projectFlockKandang := range projectFlockKandangs { + pulletCost, err := s.HppRepo.GetPulletCost(c.Context(), projectFlockKandang.Id) + if err == nil { + costs.ChickenCost += pulletCost + } + } } - // Build new DTO structure - - // Calculate totals - var totalPopulation float64 - for _, chickin := range chickins { - totalPopulation += chickin.UsageQty + costs.ExpeditionCost, err = s.HppRepo.GetExpedisionCost(c.Context(), projectFlockKandangIDs) + if err != nil { + costs.ExpeditionCost = 0 } - // Calculate actual population (total population - depletion) - actualPopulation := totalPopulation - totalDepletion - - // Calculate budget totals by category - calculateBudgetByFlag := func(flags []string) float64 { - var total float64 + if budgets, err := s.ProjectBudgetRepo.GetByProjectFlockID(c.Context(), projectFlock.Id); err == nil { + totalBudget := 0.0 for _, budget := range budgets { - if budget.Nonstock != nil { - for _, nonstockFlag := range budget.Nonstock.Flags { - flagName := strings.ToUpper(nonstockFlag.Name) - for _, targetFlag := range flags { - if flagName == strings.ToUpper(targetFlag) { - total += budget.Price * budget.Qty - break - } - } - } + totalBudget += budget.Price * budget.Qty + } + if projectFlockKandangID != nil { + allKandangs, errKandang := s.ProjectFlockKandangRepo.GetByProjectFlockID(c.Context(), projectFlock.Id) + if errKandang == nil && len(allKandangs) > 0 { + costs.BudgetOperational = totalBudget / float64(len(allKandangs)) + } + } else { + costs.BudgetOperational = totalBudget + } + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to fetch budgets for project_flock_id=%d: %+v", projectFlock.Id, err) + } + + if realizations, err := s.ExpenseRealizationRepo.GetClosingOverhead(c.Context(), projectFlock.Id, projectFlockKandangID); err == nil { + for _, realization := range realizations { + amount := realization.Price * realization.Qty + isEkspedisi := realization.ExpenseNonstock != nil && + realization.ExpenseNonstock.Nonstock != nil && + containsFlag(realization.ExpenseNonstock.Nonstock.Flags, "EKSPEDISI") + if !isEkspedisi { + costs.RealizationOperational += amount } } - return total + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to fetch realizations for project_flock_id=%d: %+v", projectFlock.Id, err) } - // Budget per category - budgetPakan := calculateBudgetByFlag([]string{"PAKAN", "PRE-STARTER", "STARTER", "FINISHER"}) - budgetOvk := calculateBudgetByFlag([]string{"OVK", "OBAT", "VITAMIN", "KIMIA"}) - budgetAyam := calculateBudgetByFlag([]string{"DOC", "PULLET", "LAYER"}) - budgetEkspedisi := calculateBudgetByFlag([]string{"EKSPEDISI"}) + return costs, nil +} - // Operational budget = total budget - pakan - ovk - ayam - ekspedisi - totalBudgetAmount := 0.0 - for _, budget := range budgets { - totalBudgetAmount += budget.Price * budget.Qty +func (s closingKeuanganService) calculateProductionData(c *fiber.Ctx, projectFlock *entity.ProjectFlock, projectFlockKandangIDs []uint, projectFlockKandangID *uint) (*ProductionData, error) { + data := &ProductionData{} + var err error + + data.TotalPopulationIn, err = s.HppRepo.GetTotalPopulation(c.Context(), projectFlockKandangIDs) + if err != nil { + data.TotalPopulationIn = 0 } - budgetOperational := totalBudgetAmount - budgetPakan - budgetOvk - budgetAyam - budgetEkspedisi + if projectFlockKandangID != nil { + data.TotalDepletion, err = s.RecordingRepo.GetTotalDepletionByProjectFlockKandangID(c.Context(), *projectFlockKandangID) + } else { + data.TotalDepletion, err = s.RecordingRepo.GetTotalDepletionByProjectFlockID(c.Context(), projectFlock.Id) + } + if err != nil { + data.TotalDepletion = 0 + } - // Calculate realization totals - var totalRealizationAmount float64 - var totalEkspedisiRealization float64 - for _, realization := range realizations { - amount := realization.Price * realization.Qty - totalRealizationAmount += amount + if projectFlockKandangID != nil { + data.TotalWeightProduced, err = s.RecordingRepo.GetTotalWeightProducedFromUniformityByProjectFlockKandangID(c.Context(), *projectFlockKandangID) + } else { + data.TotalWeightProduced, err = s.RecordingRepo.GetTotalWeightProducedFromUniformityByProjectFlockID(c.Context(), projectFlock.Id) + } + if err != nil { + data.TotalWeightProduced = 0 + } - // Check if this is ekspedisi (need to check nonstock flags) - if realization.ExpenseNonstock != nil && realization.ExpenseNonstock.Nonstock != nil { - for _, flag := range realization.ExpenseNonstock.Nonstock.Flags { - if flag.Name == "EKSPEDISI" { - totalEkspedisiRealization += amount - break - } - } + if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) { + _, data.TotalEggWeightKg, err = s.HppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(c.Context(), projectFlockKandangIDs, nil) + if err != nil { + data.TotalEggWeightKg = 0 } } - totalOperationalRealization := totalRealizationAmount - totalEkspedisiRealization + var deliveryProducts []entity.MarketingDeliveryProduct + if projectFlockKandangID != nil { + deliveryProducts, err = s.MarketingDeliveryProductRepo.GetClosingPenjualan(c.Context(), projectFlock.Id, projectFlockKandangID, projectFlock.Category) + } else { + deliveryProducts, err = s.MarketingDeliveryProductRepo.GetClosingPenjualan(c.Context(), projectFlock.Id, nil, projectFlock.Category) + } + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data penjualan") + } - // Filter delivery products based on category - var filteredDeliveryProducts []entity.MarketingDeliveryProduct for _, delivery := range deliveryProducts { - // Get product from delivery if delivery.MarketingProduct.ProductWarehouse.Product.Id == 0 { continue } - - product := delivery.MarketingProduct.ProductWarehouse.Product - isEggProduct := false - isChickenProduct := false - - // Check product flags - for _, flag := range product.Flags { - flagName := strings.ToUpper(flag.Name) - - // Egg product flags - if flagName == "TELUR" || flagName == "TELURUTUH" || flagName == "TELURPECAH" || - flagName == "TELURPUTIH" || flagName == "TELURRETAK" { - isEggProduct = true - } - - // Chicken product flags - if flagName == "AYAMAFKIR" || flagName == "AYAMCULLING" || flagName == "AYAMMATI" { - isChickenProduct = true - } - } - - // Filter based on project flock category - if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) { - // Laying: only egg products - if isEggProduct { - filteredDeliveryProducts = append(filteredDeliveryProducts, delivery) - } - } else { - // Growing/Contract Growing: only chicken products - if isChickenProduct || (!isEggProduct && !isChickenProduct) { - // Include if chicken product or if no specific flags (default to chicken) - filteredDeliveryProducts = append(filteredDeliveryProducts, delivery) - } - } + data.TotalWeightSold += delivery.TotalWeight + data.TotalSalesAmount += delivery.TotalPrice } + return data, nil +} - // Calculate total weight sold and sales amount from filtered products - var totalWeightSold float64 - var totalSalesAmount float64 - for _, delivery := range filteredDeliveryProducts { - totalWeightSold += delivery.TotalWeight - totalSalesAmount += delivery.TotalPrice +func (s closingKeuanganService) buildHPPSection(c *fiber.Ctx, projectFlock *entity.ProjectFlock, projectFlockKandangs []entity.ProjectFlockKandang, costs *CostData, production *ProductionData) dto.HPPSection { + + actualPopulation := production.TotalPopulationIn - production.TotalDepletion + totalWeightProduced := production.TotalWeightProduced + totalEggWeightKg := production.TotalEggWeightKg + + weightForCalculation := totalWeightProduced + if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) { + weightForCalculation = totalEggWeightKg } - - // Calculate metrics - always use kg ayam for rp_per_kg calculateMetrics := func(amount float64) (rpPerBird, rpPerKg float64) { if actualPopulation > 0 { - rpPerBird = amount / actualPopulation // Use actual population + rpPerBird = amount / actualPopulation } - if totalWeightProduced > 0 { - rpPerKg = amount / totalWeightProduced + if weightForCalculation > 0 { + rpPerKg = amount / weightForCalculation } return } - // Calculate metrics for profit loss (use total population and total weight produced) - calculateProfitLossMetrics := func(amount float64) (rpPerBird, rpPerKg float64) { - if totalPopulation > 0 { - rpPerBird = amount / totalPopulation - } - if totalWeightProduced > 0 { - rpPerKg = amount / totalWeightProduced - } - return + createHPPItem := func(id uint, category, code, label string, budgetAmount, realizationAmount float64) dto.HPPItem { + budgetRpPerBird, budgetRpPerKg := calculateMetrics(budgetAmount) + realizationRpPerBird, realizationRpPerKg := calculateMetrics(realizationAmount) + return dto.ToHPPItem( + id, + category, + code, + label, + dto.ToFinancialMetrics(budgetRpPerBird, budgetRpPerKg, budgetAmount), + dto.ToFinancialMetrics(realizationRpPerBird, realizationRpPerKg, realizationAmount), + ) } - // Build HPP Items using constants hppItems := []dto.HPPItem{} - // PAKAN item - pakanBudgetRpPerBird, pakanBudgetRpPerKg := calculateMetrics(budgetPakan) - pakanRealizationRpPerBird, pakanRealizationRpPerKg := calculateMetrics(totalPakanPrice) - hppItems = append(hppItems, dto.ToHPPItem( - 1, - "purchase", - string(dto.HPPCodePakan), - "Pembelian Pakan", - dto.ToFinancialMetrics(pakanBudgetRpPerBird, pakanBudgetRpPerKg, budgetPakan), - dto.ToFinancialMetrics(pakanRealizationRpPerBird, pakanRealizationRpPerKg, totalPakanPrice), - )) + hppItems = append(hppItems, createHPPItem(1, "purchase", string(dto.HPPCodePakan), "Pembelian Pakan", costs.FeedCost, costs.FeedCost)) + hppItems = append(hppItems, createHPPItem(2, "purchase", string(dto.HPPCodeOVK), "Pembelian OVK", costs.OvkCost, costs.OvkCost)) - // OVK item - ovkBudgetRpPerBird, ovkBudgetRpPerKg := calculateMetrics(budgetOvk) - ovkRealizationRpPerBird, ovkRealizationRpPerKg := calculateMetrics(totalOvkPrice) - hppItems = append(hppItems, dto.ToHPPItem( - 2, - "purchase", - string(dto.HPPCodeOVK), - "Pembelian OVK", - dto.ToFinancialMetrics(ovkBudgetRpPerBird, ovkBudgetRpPerKg, budgetOvk), - dto.ToFinancialMetrics(ovkRealizationRpPerBird, ovkRealizationRpPerKg, totalOvkPrice), - )) - - // DOC/DEPRESIASI item docCode := string(dto.HPPCodeDOC) docLabel := "Pembelian DOC" if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) { docCode = string(dto.HPPCodeDepresiasi) docLabel = "Depresiasi" } - docBudgetRpPerBird, docBudgetRpPerKg := calculateMetrics(budgetAyam) - docRealizationRpPerBird, docRealizationRpPerKg := calculateMetrics(totalAyamPrice) - hppItems = append(hppItems, dto.ToHPPItem( - 3, - "purchase", - docCode, - docLabel, - dto.ToFinancialMetrics(docBudgetRpPerBird, docBudgetRpPerKg, budgetAyam), - dto.ToFinancialMetrics(docRealizationRpPerBird, docRealizationRpPerKg, totalAyamPrice), - )) + hppItems = append(hppItems, createHPPItem(3, "purchase", docCode, docLabel, costs.ChickenCost, costs.ChickenCost)) + hppItems = append(hppItems, createHPPItem(4, "overhead", string(dto.HPPCodeOverhead), "Pengeluaran Overhead", costs.BudgetOperational, costs.RealizationOperational)) + hppItems = append(hppItems, createHPPItem(5, "overhead", string(dto.HPPCodeEkspedisi), "Beban Ekspedisi", costs.ExpeditionCost, costs.ExpeditionCost)) - // OVERHEAD item - overheadBudgetRpPerBird, overheadBudgetRpPerKg := calculateMetrics(budgetOperational) - overheadRealizationRpPerBird, overheadRealizationRpPerKg := calculateMetrics(totalOperationalRealization) - hppItems = append(hppItems, dto.ToHPPItem( - 4, - "overhead", - string(dto.HPPCodeOverhead), - "Pengeluaran Overhead", - dto.ToFinancialMetrics(overheadBudgetRpPerBird, overheadBudgetRpPerKg, budgetOperational), - dto.ToFinancialMetrics(overheadRealizationRpPerBird, overheadRealizationRpPerKg, totalOperationalRealization), - )) - - // EKSPEDISI item - ekspedisiBudgetRpPerBird, ekspedisiBudgetRpPerKg := calculateMetrics(budgetEkspedisi) - ekspedisiRealizationRpPerBird, ekspedisiRealizationRpPerKg := calculateMetrics(totalEkspedisiRealization) - hppItems = append(hppItems, dto.ToHPPItem( - 5, - "overhead", - string(dto.HPPCodeEkspedisi), - "Beban Ekspedisi", - dto.ToFinancialMetrics(ekspedisiBudgetRpPerBird, ekspedisiBudgetRpPerKg, budgetEkspedisi), - dto.ToFinancialMetrics(ekspedisiRealizationRpPerBird, ekspedisiRealizationRpPerKg, totalEkspedisiRealization), - )) - - // HPP Summary - totalBudgetHpp := budgetPakan + budgetOvk + budgetAyam + budgetOperational + budgetEkspedisi - totalRealizationHpp := totalPakanPrice + totalOvkPrice + totalAyamPrice + totalOperationalRealization + totalEkspedisiRealization + totalBudgetHpp := costs.FeedCost + costs.OvkCost + costs.ChickenCost + costs.BudgetOperational + costs.ExpeditionCost + totalRealizationHpp := costs.FeedCost + costs.OvkCost + costs.ChickenCost + costs.RealizationOperational + costs.ExpeditionCost hppBudgetRpPerBird, hppBudgetRpPerKg := calculateMetrics(totalBudgetHpp) hppRealizationRpPerBird, hppRealizationRpPerKg := calculateMetrics(totalRealizationHpp) var eggBudgeting, eggRealization *dto.FinancialMetrics - if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) && totalEggWeightKg > 0 { - eggBudgetRpPerKg := totalBudgetHpp / totalEggWeightKg - eggRealizationRpPerKg := totalRealizationHpp / totalEggWeightKg - eggBudgeting = &dto.FinancialMetrics{ - RpPerBird: 0, - RpPerKg: eggBudgetRpPerKg, - Amount: totalBudgetHpp, + if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) { + accumulateEggMetrics := func(metrics **dto.FinancialMetrics, amount, rpPerKg float64) { + if *metrics == nil { + *metrics = &dto.FinancialMetrics{ + RpPerBird: 0, + RpPerKg: rpPerKg, + Amount: amount, + } + } else { + (*metrics).Amount += amount + if totalEggWeightKg > 0 { + (*metrics).RpPerKg = (*metrics).Amount / totalEggWeightKg + } + } } - eggRealization = &dto.FinancialMetrics{ - RpPerBird: 0, - RpPerKg: eggRealizationRpPerKg, - Amount: totalRealizationHpp, + + for _, projectFlockKandang := range projectFlockKandangs { + hppResponse, err := s.HppSvc.CalculateHppCost(projectFlockKandang.Id, nil) + if err == nil { + accumulateEggMetrics(&eggBudgeting, hppResponse.Estimation.Total, hppResponse.Estimation.HargaKg) + accumulateEggMetrics(&eggRealization, hppResponse.Real.Total, hppResponse.Real.HargaKg) + } } } @@ -543,12 +378,48 @@ func (s closingKeuanganService) calculateClosingKeuangan(c *fiber.Ctx, projectFl eggRealization, ) - hppSection := dto.ToHPPSection(hppItems, hppSummary) + return dto.ToHPPSection(hppItems, hppSummary) +} + +func (s closingKeuanganService) buildProfitLossSection(projectFlock *entity.ProjectFlock, costs *CostData, production *ProductionData) dto.ProfitLossSection { + + totalPopulationIn := production.TotalPopulationIn + totalWeightProduced := production.TotalWeightProduced + totalEggWeightKg := production.TotalEggWeightKg + totalSalesAmount := production.TotalSalesAmount + totalWeightSold := production.TotalWeightSold + + weightForSales := totalWeightSold + weightForCalculation := totalWeightProduced + if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) { + weightForSales = totalWeightSold + weightForCalculation = totalEggWeightKg + } + + calculateProfitLossMetrics := func(amount float64) (rpPerBird, rpPerKg float64) { + if totalPopulationIn > 0 { + rpPerBird = amount / totalPopulationIn + } + if weightForSales > 0 { + rpPerKg = amount / weightForSales + } + return + } + + actualPopulation := production.TotalPopulationIn - production.TotalDepletion + + calculateMetrics := func(amount float64) (rpPerBird, rpPerKg float64) { + if actualPopulation > 0 { + rpPerBird = amount / actualPopulation + } + if weightForCalculation > 0 { + rpPerKg = amount / weightForCalculation + } + return + } - // Build Profit Loss Items using constants plItems := []dto.ProfitLossItem{} - // SALES item salesRpPerBird, salesRpPerKg := calculateProfitLossMetrics(totalSalesAmount) salesLabel := "Penjualan Ayam" if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) { @@ -563,10 +434,13 @@ func (s closingKeuanganService) calculateClosingKeuangan(c *fiber.Ctx, projectFl totalSalesAmount, )) - // SAPRONAK item - combines DOC/Depresiasi + PAKAN + OVK - totalSapronakAmount := totalAyamPrice + totalPakanPrice + totalOvkPrice - sapronakRpPerBird := docRealizationRpPerBird + pakanRealizationRpPerBird + ovkRealizationRpPerBird - sapronakRpPerKg := docRealizationRpPerKg + pakanRealizationRpPerKg + ovkRealizationRpPerKg + totalSapronakAmount := costs.ChickenCost + costs.FeedCost + costs.OvkCost + _, sapronakRpPerKg := calculateMetrics(totalSapronakAmount) + sapronakRpPerBird := 0.0 + for _, amount := range []float64{costs.ChickenCost, costs.FeedCost, costs.OvkCost} { + rpPerBird, _ := calculateMetrics(amount) + sapronakRpPerBird += rpPerBird + } sapronakLabel := "Pengeluaran Sapronak" plItems = append(plItems, dto.ToProfitLossItem( string(dto.PLCodeSapronak), @@ -577,62 +451,54 @@ func (s closingKeuanganService) calculateClosingKeuangan(c *fiber.Ctx, projectFl totalSapronakAmount, )) - // OVERHEAD item - overheadRpPerBird, overheadRpPerKg := calculateMetrics(totalOperationalRealization) + overheadRpPerBird, overheadRpPerKg := calculateProfitLossMetrics(costs.RealizationOperational) plItems = append(plItems, dto.ToProfitLossItem( string(dto.PLCodeOverhead), "Overhead", "overhead", overheadRpPerBird, overheadRpPerKg, - totalOperationalRealization, + costs.RealizationOperational, )) - // EKSPEDISI item + ekspedisiRpPerBird, ekspedisiRpPerKg := calculateProfitLossMetrics(costs.ExpeditionCost) plItems = append(plItems, dto.ToProfitLossItem( string(dto.PLCodeEkspedisi), "Ekspedisi", "overhead", - ekspedisiRealizationRpPerBird, - ekspedisiRealizationRpPerKg, - totalEkspedisiRealization, + ekspedisiRpPerBird, + ekspedisiRpPerKg, + costs.ExpeditionCost, )) - // Profit Loss Summary - // Gross Profit = Sales - (DOC + PAKAN + OVK) only - // Gross Profit should NOT include overhead and ekspedisi - costOfGoodsSold := totalAyamPrice + totalPakanPrice + totalOvkPrice + costOfGoodsSold := costs.ChickenCost + costs.FeedCost + costs.OvkCost costOfGoodsSoldRpPerBird := sapronakRpPerBird + costOfGoodsSoldRpPerKg := sapronakRpPerKg grossProfit := totalSalesAmount - costOfGoodsSold grossProfitRpPerBird := salesRpPerBird - costOfGoodsSoldRpPerBird + grossProfitRpPerKg := salesRpPerKg - costOfGoodsSoldRpPerKg - // Operating Expenses (Overhead + Ekspedisi) - totalOperatingExpenses := totalOperationalRealization + totalEkspedisiRealization - totalOperatingExpensesRpPerBird := overheadRpPerBird + ekspedisiRealizationRpPerBird + totalOperatingExpenses := costs.RealizationOperational + costs.ExpeditionCost + totalOperatingExpensesRpPerBird := overheadRpPerBird + ekspedisiRpPerBird + totalOperatingExpensesRpPerKg := overheadRpPerKg + ekspedisiRpPerKg - // Net Profit = Gross Profit - Operating Expenses netProfit := grossProfit - totalOperatingExpenses netProfitRpPerBird := grossProfitRpPerBird - totalOperatingExpensesRpPerBird + netProfitRpPerKg := grossProfitRpPerKg - totalOperatingExpensesRpPerKg plSummary := dto.ToProfitLossSummary( - dto.ToFinancialMetrics(grossProfitRpPerBird, 0, grossProfit), - dto.ToFinancialMetrics(totalOperatingExpensesRpPerBird, 0, totalOperatingExpenses), - dto.ToFinancialMetrics(netProfitRpPerBird, 0, netProfit), + dto.ToFinancialMetrics(grossProfitRpPerBird, grossProfitRpPerKg, grossProfit), + dto.ToFinancialMetrics(totalOperatingExpensesRpPerBird, totalOperatingExpensesRpPerKg, totalOperatingExpenses), + dto.ToFinancialMetrics(netProfitRpPerBird, netProfitRpPerKg, netProfit), ) - profitLossSection := dto.ToProfitLossSection(plItems, plSummary) - - // Build complete response - data := dto.ToClosingKeuanganData(hppSection, profitLossSection) - - return &data, nil + return dto.ToProfitLossSection(plItems, plSummary) } -// containsItem checks if a string exists in a slice -func containsItem(slice []string, item string) bool { - for _, s := range slice { - if strings.EqualFold(s, item) { +func containsFlag(flags []entity.Flag, name string) bool { + for _, flag := range flags { + if flag.Name == name { return true } } diff --git a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go index 1ec0bddf..bcd788cd 100644 --- a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go +++ b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go @@ -14,7 +14,7 @@ import ( type MarketingDeliveryProductRepository interface { repository.BaseRepository[entity.MarketingDeliveryProduct] GetDeliveryProductsByProjectFlockID(ctx context.Context, projectFlockID uint, callback func(*gorm.DB) *gorm.DB) ([]entity.MarketingDeliveryProduct, error) - GetClosingPenjualan(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error) + GetClosingPenjualan(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint, category string) ([]entity.MarketingDeliveryProduct, error) GetByMarketingId(ctx context.Context, marketingId uint) ([]entity.MarketingDeliveryProduct, error) GetByMarketingProductID(ctx context.Context, marketingProductID uint) (*entity.MarketingDeliveryProduct, error) GetAllWithFilters(ctx context.Context, offset, limit int, filters *validation.MarketingQuery) ([]entity.MarketingDeliveryProduct, int64, error) @@ -54,12 +54,14 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetDeliveryProductsByProjectFlo return deliveryProducts, nil } -func (r *MarketingDeliveryProductRepositoryImpl) GetClosingPenjualan(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error) { +func (r *MarketingDeliveryProductRepositoryImpl) GetClosingPenjualan(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint, category string) ([]entity.MarketingDeliveryProduct, error) { var deliveryProducts []entity.MarketingDeliveryProduct db := r.DB().WithContext(ctx). Joins("JOIN marketing_products ON marketing_products.id = marketing_delivery_products.marketing_product_id"). Joins("JOIN product_warehouses ON product_warehouses.id = marketing_products.product_warehouse_id"). + Joins("JOIN products ON products.id = product_warehouses.product_id"). + Joins("JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = 'products'"). Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = product_warehouses.project_flock_kandang_id"). Where("project_flock_kandangs.project_flock_id = ?", projectFlockID). Where("marketing_delivery_products.delivery_date IS NOT NULL"). @@ -69,6 +71,25 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetClosingPenjualan(ctx context db = db.Where("product_warehouses.project_flock_kandang_id = ?", *projectFlockKandangID) } + if category == string(utils.ProjectFlockCategoryLaying) { + db = db.Where("flags.name IN (?)", []string{ + string(utils.FlagTelur), + string(utils.FlagTelurUtuh), + string(utils.FlagTelurPecah), + string(utils.FlagTelurPutih), + string(utils.FlagTelurRetak), + }) + } else { + db = db.Where("flags.name IN (?)", []string{ + string(utils.FlagDOC), + string(utils.FlagPullet), + string(utils.FlagLayer), + string(utils.FlagAyamAfkir), + string(utils.FlagAyamCulling), + string(utils.FlagAyamMati), + }) + } + db = db. Preload("MarketingProduct"). Preload("MarketingProduct.ProductWarehouse"). diff --git a/internal/modules/production/project_flocks/repositories/project_budget.repository.go b/internal/modules/production/project_flocks/repositories/project_budget.repository.go index 720bfc40..06869795 100644 --- a/internal/modules/production/project_flocks/repositories/project_budget.repository.go +++ b/internal/modules/production/project_flocks/repositories/project_budget.repository.go @@ -31,6 +31,7 @@ func (r *ProjectBudgetRepositoryImpl) GetByProjectFlockID(ctx context.Context, p Where("project_flock_id = ?", projectFlockID). Preload("Nonstock"). Preload("Nonstock.Uom"). + Preload("Nonstock.Flags"). Find(&budgets).Error return budgets, err } diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index 6cb65c6c..27c399f4 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -47,8 +47,10 @@ type RecordingRepository interface { GetCumulativeEggQtyByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint, recordTime time.Time) (float64, error) GetFcrStandardNumber(tx *gorm.DB, fcrId uint, currentWeightKg float64) (float64, bool, error) GetTotalWeightProducedFromUniformityByProjectFlockID(ctx context.Context, projectFlockID uint) (float64, error) + GetTotalWeightProducedFromUniformityByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) GetProductionWeightAndQtyByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeight float64, totalQty float64, err error) GetTotalDepletionByProjectFlockID(ctx context.Context, projectFlockID uint) (totalDepletion float64, err error) + GetTotalDepletionByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (totalDepletion float64, err error) GetLatestAvgWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (avgWeight float64, err error) GetTotalEggProductionWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeightKg float64, err error) GetAverageTargetMetricsByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint, includeTargets bool) (RecordingTargetAverages, error) @@ -473,6 +475,17 @@ func (r *RecordingRepositoryImpl) GetTotalDepletionByProjectFlockID(ctx context. return result, err } +func (r *RecordingRepositoryImpl) GetTotalDepletionByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) { + var result float64 + err := r.DB().WithContext(ctx). + Table("recording_depletions"). + Select("COALESCE(SUM(recording_depletions.qty), 0)"). + Joins("JOIN recordings ON recordings.id = recording_depletions.recording_id"). + Where("recordings.project_flock_kandangs_id = ?", projectFlockKandangID). + Scan(&result).Error + return result, err +} + func (r *RecordingRepositoryImpl) GetLatestAvgWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (float64, error) { // Body-weight tracking is removed; keep stub for report compatibility. return 0, nil @@ -609,3 +622,23 @@ func (r *RecordingRepositoryImpl) GetTotalWeightProducedFromUniformityByProjectF return result.TotalWeight, err } + +func (r *RecordingRepositoryImpl) GetTotalWeightProducedFromUniformityByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) { + if projectFlockKandangID == 0 { + return 0, nil + } + + var result struct { + TotalWeight float64 + } + + err := r.DB().WithContext(ctx). + Table("project_flock_kandang_uniformity"). + Select("COALESCE((mean_up / 1.10) * chick_qty_of_weight / 1000, 0) as total_weight"). + Where("project_flock_kandang_id = ?", projectFlockKandangID). + Order("id DESC"). + Limit(1). + Scan(&result).Error + + return result.TotalWeight, err +} From f44ddef79bc8db7d9ef9cffbe81903eeba8f42a8 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Mon, 26 Jan 2026 13:39:56 +0700 Subject: [PATCH 13/37] Fix[BE]: update age calculation logic to include additional product flags and start day laying form week 18 --- .../closings/dto/closingMarketing.dto.go | 35 +++++++++++++++---- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/internal/modules/closings/dto/closingMarketing.dto.go b/internal/modules/closings/dto/closingMarketing.dto.go index 72523b69..a4bb5cb0 100644 --- a/internal/modules/closings/dto/closingMarketing.dto.go +++ b/internal/modules/closings/dto/closingMarketing.dto.go @@ -8,6 +8,7 @@ import ( customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto" kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" productDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/dto" + "gitlab.com/mbugroup/lti-api.git/internal/utils" ) // === Response DTO === @@ -49,7 +50,12 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO { productFlags[i] = f.Name } - ageInDay, ageInWeeks := calculateAgeFromChickin(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang, e.DeliveryDate, productFlags) + var category string + if e.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil { + category = e.MarketingProduct.ProductWarehouse.ProjectFlockKandang.ProjectFlock.Category + } + + ageInDay, ageInWeeks := calculateAgeFromChickin(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang, e.DeliveryDate, productFlags, category) var product *productDTO.ProductRelationDTO if e.MarketingProduct.ProductWarehouse.Product.Id != 0 { @@ -131,14 +137,27 @@ func ToPenjualanRealisasiResponseDTO(e []entity.MarketingDeliveryProduct) Penjua } } -func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, deliveryDate *time.Time, productFlags []string) (int, int) { +func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, deliveryDate *time.Time, productFlags []string, category string) (int, int) { if projectFlockKandang == nil || deliveryDate == nil || len(projectFlockKandang.Chickins) == 0 { return 0, 0 } for _, flag := range productFlags { - if flag == "OVK" || flag == "PAKAN" { - return 0, 0 // + if flag == string(utils.FlagOVK) || + flag == string(utils.FlagPakan) || + flag == string(utils.FlagPreStarter) || + flag == string(utils.FlagStarter) || + flag == string(utils.FlagFinisher) || + flag == string(utils.FlagObat) || + flag == string(utils.FlagVitamin) || + flag == string(utils.FlagKimia) || + flag == string(utils.FlagEkspedisi) || + flag == string(utils.FlagTelur) || + flag == string(utils.FlagTelurUtuh) || + flag == string(utils.FlagTelurPecah) || + flag == string(utils.FlagTelurPutih) || + flag == string(utils.FlagTelurRetak) { + return 0, 0 } } @@ -156,8 +175,12 @@ func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, de if ageInDays <= 0 { ageInWeeks = 0 } else { - - ageInWeeks = ((ageInDays - 1) / 7) + 1 + if category == string(utils.ProjectFlockCategoryLaying) { + ageInDays = ageInDays + 119 + ageInWeeks = ((ageInDays - 1) / 7) + 1 + } else { + ageInWeeks = ((ageInDays - 1) / 7) + 1 + } } return ageInDays, ageInWeeks From 1572dfd0b81f7d70a3f60189aaeb6e223db5b299 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Mon, 26 Jan 2026 16:26:20 +0700 Subject: [PATCH 14/37] Refactor[BE] adjustment stock handling: remove stock_log_id, update relations, and enhance transfer logging --- ...ock_log_id_from_adjustment_stocks.down.sql | 3 ++ ...stock_log_id_from_adjustment_stocks.up.sql | 1 + internal/entities/adjustment_stock.go | 3 +- .../adjustments/dto/adjustment.dto.go | 19 ++++------- .../adjustment_stock.repository.go | 20 ++++-------- .../services/adjustment.service.go | 32 +++++++++---------- 6 files changed, 33 insertions(+), 45 deletions(-) create mode 100644 internal/database/migrations/20260126075643_remove_stock_log_id_from_adjustment_stocks.down.sql create mode 100644 internal/database/migrations/20260126075643_remove_stock_log_id_from_adjustment_stocks.up.sql diff --git a/internal/database/migrations/20260126075643_remove_stock_log_id_from_adjustment_stocks.down.sql b/internal/database/migrations/20260126075643_remove_stock_log_id_from_adjustment_stocks.down.sql new file mode 100644 index 00000000..e8f203a8 --- /dev/null +++ b/internal/database/migrations/20260126075643_remove_stock_log_id_from_adjustment_stocks.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE adjustment_stocks ADD COLUMN stock_log_id INTEGER; + +CREATE INDEX idx_adjustment_stocks_stock_log_id ON adjustment_stocks (stock_log_id); \ No newline at end of file diff --git a/internal/database/migrations/20260126075643_remove_stock_log_id_from_adjustment_stocks.up.sql b/internal/database/migrations/20260126075643_remove_stock_log_id_from_adjustment_stocks.up.sql new file mode 100644 index 00000000..32f27161 --- /dev/null +++ b/internal/database/migrations/20260126075643_remove_stock_log_id_from_adjustment_stocks.up.sql @@ -0,0 +1 @@ +ALTER TABLE adjustment_stocks DROP COLUMN IF EXISTS stock_log_id; \ No newline at end of file diff --git a/internal/entities/adjustment_stock.go b/internal/entities/adjustment_stock.go index ef27d0c2..841e4820 100644 --- a/internal/entities/adjustment_stock.go +++ b/internal/entities/adjustment_stock.go @@ -4,7 +4,6 @@ import "time" type AdjustmentStock struct { Id uint `gorm:"primaryKey"` - StockLogId uint `gorm:"column:stock_log_id;not null;index"` ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` TotalQty float64 `gorm:"column:total_qty;default:0"` TotalUsed float64 `gorm:"column:total_used;default:0"` @@ -13,6 +12,6 @@ type AdjustmentStock struct { CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"` UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"` - StockLog *StockLog `gorm:"foreignKey:StockLogId;references:Id"` ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` + StockLog *StockLog `gorm:"polymorphic:Loggable;polymorphicType:LoggableType;polymorphicId:LoggableId;polymorphicValue:ADJUSTMENT"` } diff --git a/internal/modules/inventory/adjustments/dto/adjustment.dto.go b/internal/modules/inventory/adjustments/dto/adjustment.dto.go index 1ce3da1b..c07f84f9 100644 --- a/internal/modules/inventory/adjustments/dto/adjustment.dto.go +++ b/internal/modules/inventory/adjustments/dto/adjustment.dto.go @@ -103,7 +103,7 @@ func ToProductWarehouseDTO(e *entity.ProductWarehouse) *ProductWarehouseDTO { func ToAdjustmentRelationDTO(e *entity.AdjustmentStock) AdjustmentRelationDTO { return AdjustmentRelationDTO{ Id: e.Id, - Note: e.StockLog.Notes, + Note: "", Increase: e.TotalQty, Decrease: e.UsageQty, ProductWarehouseId: e.ProductWarehouseId, @@ -113,24 +113,17 @@ func ToAdjustmentRelationDTO(e *entity.AdjustmentStock) AdjustmentRelationDTO { func ToAdjustmentListDTO(e *entity.AdjustmentStock) AdjustmentListDTO { var createdUser *userDTO.UserRelationDTO - if e.StockLog != nil && e.StockLog.CreatedUser != nil { - createdUser = &userDTO.UserRelationDTO{ - Id: e.StockLog.CreatedUser.Id, - IdUser: e.StockLog.CreatedUser.IdUser, - Email: e.StockLog.CreatedUser.Email, - Name: e.StockLog.CreatedUser.Name, - } - } - createdAt := time.Time{} - if e.StockLog != nil { - createdAt = e.StockLog.CreatedAt + // Get created user from StockLog + if e.StockLog != nil && e.StockLog.CreatedUser != nil { + mapped := userDTO.ToUserRelationDTO(*e.StockLog.CreatedUser) + createdUser = &mapped } return AdjustmentListDTO{ AdjustmentRelationDTO: ToAdjustmentRelationDTO(e), CreatedUser: createdUser, - CreatedAt: createdAt, + CreatedAt: e.CreatedAt, } } diff --git a/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go b/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go index fa2685e7..f62738a3 100644 --- a/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go +++ b/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go @@ -9,7 +9,7 @@ import ( type AdjustmentStockRepository interface { CreateOne(ctx context.Context, data *entity.AdjustmentStock, modifier func(*gorm.DB) *gorm.DB) error - GetByStockLogID(ctx context.Context, stockLogID uint) (*entity.AdjustmentStock, error) + GetByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.AdjustmentStock, error) WithTx(tx *gorm.DB) AdjustmentStockRepository DB() *gorm.DB } @@ -30,19 +30,13 @@ func (r *adjustmentStockRepositoryImpl) CreateOne(ctx context.Context, data *ent return q.Create(data).Error } -func (r *adjustmentStockRepositoryImpl) GetByStockLogID(ctx context.Context, stockLogID uint) (*entity.AdjustmentStock, error) { +func (r *adjustmentStockRepositoryImpl) GetByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.AdjustmentStock, error) { var record entity.AdjustmentStock - err := r.db.WithContext(ctx). - Preload("StockLog"). - Preload("StockLog.ProductWarehouse"). - Preload("StockLog.ProductWarehouse.Product"). - Preload("StockLog.ProductWarehouse.Warehouse"). - Preload("StockLog.CreatedUser"). - Preload("ProductWarehouse"). - Preload("ProductWarehouse.Product"). - Preload("ProductWarehouse.Warehouse"). - Where("stock_log_id = ?", stockLogID). - First(&record).Error + q := r.db.WithContext(ctx) + if modifier != nil { + q = modifier(q) + } + err := q.First(&record, id).Error if err != nil { return nil, err } diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index bec0ef74..16bcf70a 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -70,11 +70,11 @@ func (s *adjustmentService) withRelations(db *gorm.DB) *gorm.DB { Preload("ProductWarehouse"). Preload("ProductWarehouse.Product"). Preload("ProductWarehouse.Warehouse"). - Preload("CreatedUser") + Preload("StockLog.CreatedUser") } func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.AdjustmentStock, error) { - adjustmentStock, err := s.AdjustmentStockRepository.GetByStockLogID(c.Context(), id) + adjustmentStock, err := s.AdjustmentStockRepository.GetByID(c.Context(), id, s.withRelations) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found") @@ -164,13 +164,13 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e if transactionType == string(utils.StockLogTransactionTypeIncrease) { afterQuantity += req.Quantity - newLog.Increase = afterQuantity + newLog.Increase = req.Quantity } else { if productWarehouse.Quantity < req.Quantity { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk pengurangan. Stok saat ini: %.2f, Jumlah yang akan dikurangi: %.2f", productWarehouse.Quantity, req.Quantity)) } afterQuantity -= req.Quantity - newLog.Decrease = afterQuantity + newLog.Decrease = req.Quantity } if err := s.StockLogsRepository.WithTx(tx).CreateOne(ctx, newLog, nil); err != nil { @@ -179,7 +179,6 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e } adjustmentStock := &entity.AdjustmentStock{ - StockLogId: newLog.Id, ProductWarehouseId: productWarehouse.Id, } if err := s.AdjustmentStockRepository.WithTx(tx).CreateOne(ctx, adjustmentStock, nil); err != nil { @@ -187,6 +186,12 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e return fiber.NewError(fiber.StatusInternalServerError, "Failed to create adjustment stock record") } + newLog.LoggableType = string(utils.StockLogTypeAdjustment) + newLog.LoggableId = adjustmentStock.Id + if err := s.StockLogsRepository.WithTx(tx).UpdateOne(ctx, newLog.Id, newLog, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to link stock log") + } + if transactionType == string(utils.StockLogTransactionTypeIncrease) { note := fmt.Sprintf("Stock Adjustment IN #%d", newLog.Id) @@ -216,7 +221,6 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e } } - // LEGACY: Update ProductWarehouse quantity (for backward compatibility/reporting) productWarehouse.Quantity = afterQuantity if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(ctx, productWarehouse.Id, productWarehouse, nil); err != nil { s.Log.Errorf("Failed to update product warehouse quantity: %+v", err) @@ -295,29 +299,23 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu var total int64 q := s.AdjustmentStockRepository.DB().WithContext(c.Context()).Model(&entity.AdjustmentStock{}). - Preload("StockLog"). - Preload("StockLog.ProductWarehouse"). - Preload("StockLog.ProductWarehouse.Product"). - Preload("StockLog.ProductWarehouse.Warehouse"). - Preload("StockLog.CreatedUser"). Preload("ProductWarehouse"). Preload("ProductWarehouse.Product"). - Preload("ProductWarehouse.Warehouse") + Preload("ProductWarehouse.Warehouse"). + Preload("StockLog.CreatedUser") if query.ProductID > 0 { - q = q.Joins("JOIN stock_logs ON stock_logs.id = adjustment_stocks.stock_log_id"). - Joins("JOIN product_warehouses ON product_warehouses.id = stock_logs.product_warehouse_id"). + q = q.Joins("JOIN product_warehouses ON product_warehouses.id = adjustment_stocks.product_warehouse_id"). Where("product_warehouses.product_id = ?", query.ProductID) } if query.WarehouseID > 0 { - q = q.Joins("JOIN stock_logs ON stock_logs.id = adjustment_stocks.stock_log_id"). - Joins("JOIN product_warehouses ON product_warehouses.id = stock_logs.product_warehouse_id"). + q = q.Joins("JOIN product_warehouses ON product_warehouses.id = adjustment_stocks.product_warehouse_id"). Where("product_warehouses.warehouse_id = ?", query.WarehouseID) } if query.TransactionType != "" { - q = q.Joins("JOIN stock_logs ON stock_logs.id = adjustment_stocks.stock_log_id"). + q = q.Joins("JOIN stock_logs ON stock_logs.loggable_type = ? AND stock_logs.loggable_id = adjustment_stocks.id", "ADJUSTMENT"). Where("stock_logs.transaction_type = ?", strings.ToUpper(query.TransactionType)) } From b83ebc0ff9f0936dc345881d5d97c86d287f6e73 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Mon, 26 Jan 2026 16:26:59 +0700 Subject: [PATCH 15/37] Feat[BE] : add stock log to transfer service --- .../transfers/services/transfer.service.go | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index ea1041ea..fe5f8f5a 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -235,6 +235,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques stockTransferDeliveryRepoTX := s.StockTransferDeliveryRepo.WithTx(tx) stockTransferDeliveryItemRepoTX := s.StockTransferDeliveryItemRepo.WithTx(tx) productWarehouseRepoTX := rProductWarehouse.NewProductWarehouseRepository(tx) + stocklogsRepoTx := s.StockLogsRepository.WithTx(tx) if err := stockTransferRepoTX.CreateOne(c.Context(), entityTransfer, nil); err != nil { return err @@ -405,6 +406,19 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return fiber.NewError(fiber.StatusInternalServerError, "Gagal memperbarui data tracking") } + stockLogDecrease := &entity.StockLog{ + ProductWarehouseId: uint(*detail.SourceProductWarehouseID), + CreatedBy: uint(actorID), + Increase: 0, + Decrease: product.ProductQty, + LoggableType: string(utils.StockLogTypeTransfer), + LoggableId: uint(detail.Id), + Notes: "", + } + if err := stocklogsRepoTx.CreateOne(c.Context(), stockLogDecrease, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar") + } + note := fmt.Sprintf("Transfer #%s", entityTransfer.MovementNumber) replenishResult, err := s.FifoSvc.Replenish(c.Context(), commonSvc.StockReplenishRequest{ StockableKey: fifo.StockableKeyStockTransferIn, @@ -427,6 +441,19 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques s.Log.Errorf("Failed to update tracking total for detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err) return fiber.NewError(fiber.StatusInternalServerError, "Gagal memperbarui data tracking") } + + stockLogIncrease := &entity.StockLog{ + ProductWarehouseId: uint(*detail.DestProductWarehouseID), + CreatedBy: uint(actorID), + Increase: product.ProductQty, + Decrease: 0, + LoggableType: string(utils.StockLogTypeTransfer), + LoggableId: uint(detail.Id), + Notes: "", + } + if err := stocklogsRepoTx.CreateOne(c.Context(), stockLogIncrease, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok masuk") + } } if len(req.Deliveries) > 0 { From bd0f89c521546007861db17911dd8bc5b3c3de49 Mon Sep 17 00:00:00 2001 From: ragilap Date: Mon, 26 Jan 2026 16:47:28 +0700 Subject: [PATCH 16/37] [FIX/BE-US] fix closing count sapronak,expense notes purchase --- .../closings/dto/closingSapronak.dto.go | 9 +++- .../repositories/closing.repository.go | 48 +++++++++++++------ .../purchases/services/expense_bridge.go | 41 +++++++++++++--- 3 files changed, 75 insertions(+), 23 deletions(-) diff --git a/internal/modules/closings/dto/closingSapronak.dto.go b/internal/modules/closings/dto/closingSapronak.dto.go index 81fe7ebd..92d3b2ee 100644 --- a/internal/modules/closings/dto/closingSapronak.dto.go +++ b/internal/modules/closings/dto/closingSapronak.dto.go @@ -196,7 +196,11 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin } for idx, item := range group.Items { - productKey := strings.ToUpper(flagKey + "|" + item.ProductName + "|" + item.NoReferensi + "|" + formatDate(item.Tanggal)) + refKey := strings.TrimSpace(item.NoReferensi) + productKey := strings.ToUpper(flagKey + "|" + item.ProductName + "|" + refKey) + if refKey == "" { + productKey = strings.ToUpper(flagKey + "|" + item.ProductName + "|" + formatDate(item.Tanggal)) + } baseRow := SapronakCategoryRowDTO{ ID: idx + 1, Date: formatDate(item.Tanggal), @@ -212,6 +216,9 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin switch strings.ToLower(item.JenisTransaksi) { case "pembelian", "adjustment masuk", "mutasi masuk": row.QtyIn += item.QtyMasuk + if item.Tanggal != nil { + row.Date = formatDate(item.Tanggal) + } if row.UnitPrice == 0 { if item.QtyMasuk > 0 && item.Nilai > 0 { row.UnitPrice = item.Nilai / item.QtyMasuk diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index daff5d35..82e6f4a7 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -709,6 +709,23 @@ var ( sapronakFlagsChickin = sapronakFlags(utils.FlagDOC, utils.FlagPullet) ) +func (r *ClosingRepositoryImpl) joinSapronakProductFlag(db *gorm.DB, productAlias string) *gorm.DB { + subquery := r.DB(). + Table("flags"). + Select("DISTINCT ON (flagable_id) flagable_id, name"). + Where("flagable_type = ?", entity.FlagableTypeProduct). + Where("name IN ?", sapronakFlagsAll). + Order(fmt.Sprintf( + "flagable_id, CASE WHEN name = '%s' THEN 1 WHEN name = '%s' THEN 2 WHEN name = '%s' THEN 3 WHEN name = '%s' THEN 4 ELSE 5 END", + utils.FlagDOC, + utils.FlagPullet, + utils.FlagPakan, + utils.FlagOVK, + )) + + return db.Joins("JOIN (?) f ON f.flagable_id = "+productAlias+".id", subquery) +} + func groupSapronakDetails(rows []SapronakDetailRow) map[uint][]SapronakDetailRow { m := make(map[uint][]SapronakDetailRow) for _, row := range rows { @@ -745,11 +762,12 @@ func (r *ClosingRepositoryImpl) usageQuery( COALESCE(p.product_price, 0) AS default_price `) db = applyJoins(db, joins...) - return db. + db = db. Joins("JOIN product_warehouses pw ON "+pwJoinCond). Joins("JOIN products p ON p.id = pw.product_id"). - Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). Where(where, args...) + db = r.joinSapronakProductFlag(db, "p") + return db } func (r *ClosingRepositoryImpl) fetchSapronakUsage( @@ -780,10 +798,10 @@ func (r *ClosingRepositoryImpl) detailQuery( db := r.withCtx(ctx). Table(table). Joins("JOIN product_warehouses pw ON "+pwJoinCond). - Joins("JOIN products p ON p.id = pw.product_id"). - Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct) + Joins("JOIN products p ON p.id = pw.product_id") db = applyJoins(db, joins...) + db = r.joinSapronakProductFlag(db, "p") return db.Select(selectSQL).Where(where, args...) } @@ -907,7 +925,6 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C `). Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id"). Joins("JOIN products p ON p.id = pw.product_id"). - Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). Joins("LEFT JOIN recording_stocks rs ON rs.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyRecordingStock.String()). Joins("LEFT JOIN recordings r ON r.id = rs.recording_id"). Joins("LEFT JOIN project_chickins pc_used ON pc_used.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyProjectChickin.String()). @@ -930,7 +947,8 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C `, fifo.UsableKeyRecordingStock.String(), projectFlockKandangID, fifo.UsableKeyProjectChickin.String(), projectFlockKandangID, - ). + ) + query = r.joinSapronakProductFlag(query, "p"). Group(` pw.product_id, p.name, f.name, pi.received_date, st.transfer_date, lt.transfer_date, sl.created_at, pc.chick_in_date, r.record_datetime, @@ -942,15 +960,15 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C } func (r *ClosingRepositoryImpl) incomingPurchaseBase(ctx context.Context, kandangID uint) *gorm.DB { - return r.withCtx(ctx). + db := r.withCtx(ctx). Table("purchase_items AS pi"). Joins("JOIN purchases po ON po.id = pi.purchase_id AND po.deleted_at IS NULL"). Joins("JOIN products p ON p.id = pi.product_id"). - Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). Joins("JOIN warehouses w ON w.id = pi.warehouse_id"). Where("w.kandang_id = ?", kandangID). Where("f.name IN ?", sapronakFlagsAll). Where("pi.received_date IS NOT NULL") + return r.joinSapronakProductFlag(db, "p") } func (r *ClosingRepositoryImpl) FetchSapronakIncoming(ctx context.Context, kandangID uint) ([]SapronakIncomingRow, error) { @@ -1021,10 +1039,10 @@ func (r *ClosingRepositoryImpl) fetchStockLogs(ctx context.Context, kandangID ui `). Joins("JOIN product_warehouses pw ON pw.id = sl.product_warehouse_id"). Joins("JOIN products p ON p.id = pw.product_id"). - Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). Joins("JOIN warehouses w ON w.id = pw.warehouse_id") db = applyJoins(db, joins...) + db = r.joinSapronakProductFlag(db, "p") if err := db. Where("sl.loggable_type = ?", logType). @@ -1093,10 +1111,10 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand Joins("JOIN product_warehouses pw ON pw.id = std.dest_product_warehouse_id"). Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). Joins("JOIN products p ON p.id = std.product_id"). - Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). Where("w.kandang_id = ?", kandangID). Where("(fw.kandang_id IS NULL OR fw.kandang_id <> w.kandang_id)"). Where("f.name IN ?", sapronakFlagsAll) + incomingQuery = r.joinSapronakProductFlag(incomingQuery, "p") incoming, err := scanAndGroupDetails(incomingQuery) if err != nil { return nil, nil, err @@ -1121,10 +1139,10 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand Joins("JOIN product_warehouses pw ON pw.id = ltt.product_warehouse_id"). Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). Joins("JOIN products p ON p.id = pw.product_id"). - Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). Where("w.kandang_id = ?", kandangID). Where("(w_source.kandang_id IS NULL OR w_source.kandang_id <> w.kandang_id)"). Where("f.name IN ?", sapronakFlagsAll) + incomingLayingQuery = r.joinSapronakProductFlag(incomingLayingQuery, "p") incomingLaying, err := scanAndGroupDetails(incomingLayingQuery) if err != nil { return nil, nil, err @@ -1152,12 +1170,12 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand Joins("LEFT JOIN product_warehouses pw_dest ON pw_dest.id = std.dest_product_warehouse_id"). Joins("LEFT JOIN warehouses w_dest ON w_dest.id = pw_dest.warehouse_id"). Joins("JOIN products p ON p.id = std.product_id"). - Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). Where("sa.status = ?", entity.StockAllocationStatusActive). Where("w.kandang_id = ?", kandangID). Where("(w_dest.kandang_id IS NULL OR w_dest.kandang_id <> w.kandang_id)"). Where("f.name IN ?", sapronakFlagsAll). Group("std.id, std.product_id, p.name, f.name, st.transfer_date, st.movement_number, p.product_price") + outgoingQuery = r.joinSapronakProductFlag(outgoingQuery, "p") outgoing, err := scanAndGroupDetails(outgoingQuery) if err != nil { return nil, nil, err @@ -1183,12 +1201,12 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id"). Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). Joins("JOIN products p ON p.id = pw.product_id"). - Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). Where("sa.status = ?", entity.StockAllocationStatusActive). Where("w.kandang_id = ?", kandangID). Where("(w_dest.kandang_id IS NULL OR w_dest.kandang_id <> w.kandang_id)"). Where("f.name IN ?", sapronakFlagsAll). Group("lts.id, pw.product_id, p.name, f.name, lt.transfer_date, lt.transfer_number, p.product_price") + outgoingLayingQuery = r.joinSapronakProductFlag(outgoingLayingQuery, "p") outgoingLaying, err := scanAndGroupDetails(outgoingLayingQuery) if err != nil { return nil, nil, err @@ -1218,12 +1236,12 @@ func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectF Joins("JOIN marketings m ON m.id = mp.marketing_id"). Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id"). Joins("JOIN products p ON p.id = pw.product_id"). - Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). Where("sa.status = ?", entity.StockAllocationStatusActive). Where("pw.project_flock_kandang_id = ?", projectFlockKandangID). Where("f.name IN ?", sapronakFlagsAll). Group("mdp.id, pw.product_id, p.name, f.name, mdp.delivery_date, mdp.created_at, m.so_number, mdp.unit_price, mp.unit_price") + query = r.joinSapronakProductFlag(query, "p") sales, err := scanAndGroupDetails(query) if err != nil { return nil, err @@ -1245,7 +1263,6 @@ func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectF Joins("JOIN marketings m ON m.id = mp.marketing_id"). Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id"). Joins("JOIN products p ON p.id = pw.product_id"). - Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). Joins("LEFT JOIN stock_allocations sa ON sa.usable_id = mdp.id AND sa.usable_type = ? AND sa.status = ?", fifo.UsableKeyMarketingDelivery.String(), entity.StockAllocationStatusActive, @@ -1256,6 +1273,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectF Where("f.name IN ?", sapronakFlagsAll). Group("mdp.id, pw.product_id, p.name, f.name, mdp.delivery_date, mdp.created_at, m.so_number, mdp.unit_price, mp.unit_price") + nonFifoQuery = r.joinSapronakProductFlag(nonFifoQuery, "p") nonFifoSales, err := scanAndGroupDetails(nonFifoQuery) if err != nil { return nil, err diff --git a/internal/modules/purchases/services/expense_bridge.go b/internal/modules/purchases/services/expense_bridge.go index 1210b3a1..d3bf2bbf 100644 --- a/internal/modules/purchases/services/expense_bridge.go +++ b/internal/modules/purchases/services/expense_bridge.go @@ -45,10 +45,7 @@ type groupedItem struct { projectFK *uint kandangID *uint totalPrice float64 -} - -func groupingKey(supplierID uint, date time.Time, warehouseID uint) string { - return fmt.Sprintf("%d:%s:%d", supplierID, utils.FormatDate(date), warehouseID) + poNumber string } type expenseBridge struct { @@ -222,6 +219,7 @@ func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates [ purchase, err := b.purchaseRepo.GetByID(ctx, purchaseID, func(db *gorm.DB) *gorm.DB { return db. Preload("Items"). + Preload("Items.Product"). Preload("Items.Warehouse"). Preload("Items.Warehouse.Kandang") }) @@ -309,7 +307,7 @@ func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates [ // If supplier/date unchanged, update nonstock in place. if oldSupplier == supplierID && oldDate.Equal(newDate) { - note := fmt.Sprintf("purchase_item:%d", payload.PurchaseItemID) + note := purchaseItemDisplayNote(item, payload.PurchaseItemID, purchasePoNumber(purchase)) if err := b.db.WithContext(ctx). Model(&entity.ExpenseNonstock{}). Where("id = ?", link.ExpenseNonstockID). @@ -340,7 +338,7 @@ func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates [ if err != nil { return err } - note := fmt.Sprintf("purchase_item:%d", payload.PurchaseItemID) + note := purchaseItemDisplayNote(item, payload.PurchaseItemID, purchasePoNumber(purchase)) if err := b.db.WithContext(ctx). Model(&entity.Expense{}). Where("id = ?", link.ExpenseID). @@ -392,6 +390,7 @@ func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates [ projectFK: projectFK, kandangID: kandangID, totalPrice: totalPrice, + poNumber: purchasePoNumber(purchase), } newNonstockID, err := b.findExpeditionNonstockID(ctx, supplierID) @@ -410,7 +409,7 @@ func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates [ createdNonstockID = noteMap[payload.PurchaseItemID] } - note := fmt.Sprintf("purchase_item:%d", payload.PurchaseItemID) + note := purchaseItemDisplayNote(item, payload.PurchaseItemID, purchasePoNumber(purchase)) updateBody := map[string]interface{}{ "expense_id": expenseDetail.Id, "qty": payload.ReceivedQty, @@ -483,6 +482,7 @@ func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates [ projectFK: projectFK, kandangID: kandangID, totalPrice: totalPrice, + poNumber: purchasePoNumber(purchase), }) } @@ -679,6 +679,14 @@ func (b *expenseBridge) linkExpenseNonstocksToItems(ctx context.Context, detail Update("expense_nonstock_id", expenseNonstockID).Error; err != nil { return err } + + note := purchaseItemDisplayNote(gi.item, gi.payload.PurchaseItemID, gi.poNumber) + if err := b.db.WithContext(ctx). + Model(&entity.ExpenseNonstock{}). + Where("id = ?", expenseNonstockID). + Update("notes", note).Error; err != nil { + return err + } } return nil @@ -709,3 +717,22 @@ func mapExpenseNotes(detail *expenseDto.ExpenseDetailDTO) map[uint]uint64 { } return result } + +func purchaseItemDisplayNote(item *entity.PurchaseItem, itemID uint, poNumber string) string { + poLabel := "PO" + if strings.TrimSpace(poNumber) != "" { + poLabel = strings.TrimSpace(poNumber) + } + productName := fmt.Sprintf("Item %d", itemID) + if item != nil && item.Product != nil && strings.TrimSpace(item.Product.Name) != "" { + productName = item.Product.Name + } + return fmt.Sprintf("%s (%s)", poLabel, productName) +} + +func purchasePoNumber(purchase *entity.Purchase) string { + if purchase == nil || purchase.PoNumber == nil { + return "" + } + return *purchase.PoNumber +} From 7a704c4ec4ee32c4ac15da5c92fce3f7e9786071 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Mon, 26 Jan 2026 18:03:54 +0700 Subject: [PATCH 17/37] Feat[BE]: implement max target quantity retrieval for kandangs and update routes --- .../projectflock_kandang.repository.go | 1 + .../controllers/transfer_laying.controller.go | 26 +++++++++++++ .../dto/transfer_laying.dto.go | 26 +++++++++++++ .../production/transfer_layings/route.go | 15 ++++---- .../services/transfer_laying.service.go | 37 +++++++++++++++++++ 5 files changed, 98 insertions(+), 7 deletions(-) diff --git a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go index 474a53c2..f5b55a78 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go @@ -85,6 +85,7 @@ func (r *projectFlockKandangRepositoryImpl) GetByProjectFlockID(ctx context.Cont var records []entity.ProjectFlockKandang if err := r.db.WithContext(ctx). Where("project_flock_id = ?", projectFlockID). + Preload("Kandang"). Find(&records).Error; err != nil { return nil, err } diff --git a/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go b/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go index d0ee5061..c299c3e8 100644 --- a/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go +++ b/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go @@ -218,3 +218,29 @@ func (u *TransferLayingController) GetAvailableQtyPerKandang(c *fiber.Ctx) error Data: resp, }) } + +func (u *TransferLayingController) GetMaxTargetQtyPerKandang(c *fiber.Ctx) error { + projectFlockID, err := strconv.ParseUint(c.Params("project_flock_id"), 10, 32) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id") + } + kandangMaxTargetQty, err := u.TransferLayingService.GetMaxTargetQtyPerKandang(c, uint(projectFlockID)) + if err != nil { + return err + } + + kandangs := make([]dto.KandangMaxTargetQtyDTO, 0, len(kandangMaxTargetQty)) + for pfkId, maxTargetQty := range kandangMaxTargetQty { + kandangs = append(kandangs, dto.ToKandangMaxTargetQtyDTO(pfkId, maxTargetQty)) + } + + resp := dto.ToMaxTargetQtyForTransferDTO(uint(projectFlockID), kandangs) + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get max target quantity successfully", + Data: resp, + }) +} diff --git a/internal/modules/production/transfer_layings/dto/transfer_laying.dto.go b/internal/modules/production/transfer_layings/dto/transfer_laying.dto.go index dfc5e5d9..5e440971 100644 --- a/internal/modules/production/transfer_layings/dto/transfer_laying.dto.go +++ b/internal/modules/production/transfer_layings/dto/transfer_laying.dto.go @@ -94,6 +94,18 @@ type AvailableQtyForTransferDTO struct { Kandangs []KandangAvailableQtyDTO `json:"kandangs"` } +// === Max Target Quantity DTOs === + +type KandangMaxTargetQtyDTO struct { + ProjectFlockKandangId uint `json:"project_flock_kandang_id"` + MaxTargetQty float64 `json:"max_target_qty"` +} + +type MaxTargetQtyForTransferDTO struct { + ProjectFlockId uint `json:"project_flock_id"` + ProjectFlockKandangs []KandangMaxTargetQtyDTO `json:"project_flock_kandangs"` +} + // === Mapper Functions === func ToProjectFlockSummaryDTO(pf *entity.ProjectFlock) *ProjectFlockSummaryDTO { @@ -285,3 +297,17 @@ func ToTransferLayingListDTOs(items []entity.LayingTransfer) []TransferLayingLis } return result } + +func ToKandangMaxTargetQtyDTO(pfkId uint, maxTargetQTY float64) KandangMaxTargetQtyDTO { + return KandangMaxTargetQtyDTO{ + ProjectFlockKandangId: uint(pfkId), + MaxTargetQty: maxTargetQTY, + } +} + +func ToMaxTargetQtyForTransferDTO(pfId uint, kandangs []KandangMaxTargetQtyDTO) MaxTargetQtyForTransferDTO { + return MaxTargetQtyForTransferDTO{ + ProjectFlockId: pfId, + ProjectFlockKandangs: kandangs, + } +} diff --git a/internal/modules/production/transfer_layings/route.go b/internal/modules/production/transfer_layings/route.go index 8f7a62c0..c16ba1a8 100644 --- a/internal/modules/production/transfer_layings/route.go +++ b/internal/modules/production/transfer_layings/route.go @@ -21,11 +21,12 @@ func TransferLayingRoutes(v1 fiber.Router, u user.UserService, s transferLaying. // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) // route.Post("/approval", m.Auth(u), ctrl.Approval) - route.Get("/",m.RequirePermissions(m.P_TransferToLaying_GetAll), ctrl.GetAll) - route.Post("/",m.RequirePermissions(m.P_TransferToLaying_CreateOne), ctrl.CreateOne) - route.Get("/:id",m.RequirePermissions(m.P_TransferToLaying_GetOne), ctrl.GetOne) - route.Patch("/:id",m.RequirePermissions(m.P_TransferToLaying_UpdateOne), ctrl.UpdateOne) - route.Delete("/:id",m.RequirePermissions(m.P_TransferToLaying_DeleteOne), ctrl.DeleteOne) - route.Post("/approvals",m.RequirePermissions(m.P_TransferToLaying_Approval), ctrl.Approval) - route.Get("/project-flocks/:project_flock_id/available-qty",m.RequirePermissions(m.P_TransferToLaying_GetAvailableQty), ctrl.GetAvailableQtyPerKandang) + route.Get("/", m.RequirePermissions(m.P_TransferToLaying_GetAll), ctrl.GetAll) + route.Post("/", m.RequirePermissions(m.P_TransferToLaying_CreateOne), ctrl.CreateOne) + route.Get("/:id", m.RequirePermissions(m.P_TransferToLaying_GetOne), ctrl.GetOne) + route.Patch("/:id", m.RequirePermissions(m.P_TransferToLaying_UpdateOne), ctrl.UpdateOne) + route.Delete("/:id", m.RequirePermissions(m.P_TransferToLaying_DeleteOne), ctrl.DeleteOne) + route.Post("/approvals", m.RequirePermissions(m.P_TransferToLaying_Approval), ctrl.Approval) + route.Get("/project-flocks/:project_flock_id/available-qty", m.RequirePermissions(m.P_TransferToLaying_GetAvailableQty), ctrl.GetAvailableQtyPerKandang) + route.Get("/project-flocks/:project_flock_id/max-target-qty", m.RequirePermissions(m.P_TransferToLaying_CreateOne), ctrl.GetMaxTargetQtyPerKandang) } diff --git a/internal/modules/production/transfer_layings/services/transfer_laying.service.go b/internal/modules/production/transfer_layings/services/transfer_laying.service.go index 8e0269cf..bfdfab0f 100644 --- a/internal/modules/production/transfer_layings/services/transfer_laying.service.go +++ b/internal/modules/production/transfer_layings/services/transfer_laying.service.go @@ -34,6 +34,7 @@ type TransferLayingService interface { DeleteOne(ctx *fiber.Ctx, id uint) error Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.LayingTransfer, error) GetAvailableQtyPerKandang(ctx *fiber.Ctx, projectFlockID uint) (*entity.ProjectFlock, map[uint]float64, error) + GetMaxTargetQtyPerKandang(ctx *fiber.Ctx, projectFlockID uint) (map[uint]float64, error) } type transferLayingService struct { @@ -888,3 +889,39 @@ func (s *transferLayingService) validateKandangOwnership( return nil } + +func (s transferLayingService) GetMaxTargetQtyPerKandang(c *fiber.Ctx, projectFlockID uint) (map[uint]float64, error) { + + if err := commonSvc.EnsureRelations(c.Context(), + commonSvc.RelationCheck{Name: "Project Flock", ID: &projectFlockID, Exists: s.ProjectFlockRepo.IdExists}, + ); err != nil { + return nil, err + } + + projectFlockKandangs, err := s.ProjectFlockKandangRepo.GetByProjectFlockID(c.Context(), projectFlockID) + if err != nil { + return nil, err + } + + kandangMaxTargetQty := make(map[uint]float64) + for _, projectFlockKandang := range projectFlockKandangs { + + capacity := projectFlockKandang.Kandang.Capacity + + availableQty, err := s.ProjectFlockPopulationRepo.GetAvailableQtyByProjectFlockKandangID( + c.Context(), + projectFlockKandang.Id, + ) + if err != nil { + return nil, err + } + + kandangMaxTargetQty[projectFlockKandang.Id] = capacity - availableQty + + if kandangMaxTargetQty[projectFlockKandang.Id] < 0 { + kandangMaxTargetQty[projectFlockKandang.Id] = 0 + } + } + + return kandangMaxTargetQty, nil +} From 3e0291c2bae6b15c72c13a01a4f89a5f436ffb1e Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Mon, 26 Jan 2026 23:50:04 +0700 Subject: [PATCH 18/37] Feat[BE]: enhance transfer laying functionality with comprehensive filtering options and improved DTO structures --- .../controllers/transfer_laying.controller.go | 9 +- .../dto/transfer_laying.dto.go | 248 ++++++++---------- .../laying_transfer.repository.go | 94 +++++++ .../services/transfer_laying.service.go | 40 +-- .../validations/transfer_laying.validation.go | 14 +- internal/utils/strings.go | 52 ++++ 6 files changed, 286 insertions(+), 171 deletions(-) diff --git a/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go b/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go index c299c3e8..581b9093 100644 --- a/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go +++ b/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go @@ -9,6 +9,7 @@ import ( service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/services" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/validations" "gitlab.com/mbugroup/lti-api.git/internal/response" + "gitlab.com/mbugroup/lti-api.git/internal/utils" "github.com/gofiber/fiber/v2" ) @@ -28,9 +29,11 @@ func (u *TransferLayingController) GetAll(c *fiber.Ctx) error { Page: c.QueryInt("page", 1), Limit: c.QueryInt("limit", 10), Search: c.Query("search", ""), - TransferDate: c.Query("transfer_date", ""), - FlockSource: uint(c.QueryInt("flock_source", 0)), - FlockDestination: uint(c.QueryInt("flock_destination", 0)), + StartDate: c.Query("start_date", ""), + EndDate: c.Query("end_date", ""), + FlockSource: utils.ParseQueryUintArray(c.Query("flock_source", "")), + FlockDestination: utils.ParseQueryUintArray(c.Query("flock_destination", "")), + Status: utils.ParseQueryArray(c.Query("status", "")), } if query.Page < 1 || query.Limit < 1 { diff --git a/internal/modules/production/transfer_layings/dto/transfer_laying.dto.go b/internal/modules/production/transfer_layings/dto/transfer_laying.dto.go index 5e440971..53e069b2 100644 --- a/internal/modules/production/transfer_layings/dto/transfer_laying.dto.go +++ b/internal/modules/production/transfer_layings/dto/transfer_laying.dto.go @@ -5,6 +5,9 @@ import ( entity "gitlab.com/mbugroup/lti-api.git/internal/entities" approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" + productWarehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/dto" + kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" + projectFlockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) @@ -17,60 +20,35 @@ type TransferLayingRelationDTO struct { Notes string `json:"notes"` } -type ProjectFlockSummaryDTO struct { - Id uint `json:"id"` - FlockName string `json:"flock_name"` - Category string `json:"category"` -} - -type ProductSummaryDTO struct { - Id uint `json:"id"` - Name string `json:"name"` -} - -type WarehouseSummaryDTO struct { - Id uint `json:"id"` - Name string `json:"name"` - Type string `json:"type"` -} - -type ProductWarehouseSummaryDTO struct { - Product *ProductSummaryDTO `json:"product,omitempty"` - Warehouse *WarehouseSummaryDTO `json:"warehouse,omitempty"` -} - -type ProjectFlockKandangSummaryDTO struct { - Id uint `json:"id"` - Kandang *KandangSummaryDTO `json:"kandang,omitempty"` -} - -type KandangSummaryDTO struct { - Id uint `json:"id"` - Name string `json:"name"` +type ProjectFlockKandangWithKandangDTO struct { + Id uint `json:"id"` + KandangId uint `json:"kandang_id"` + ProjectFlockId uint `json:"project_flock_id"` + Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"` } type LayingTransferSourceDTO struct { - SourceProjectFlockKandang *ProjectFlockKandangSummaryDTO `json:"source_project_flock_kandang,omitempty"` - Qty float64 `json:"qty"` - ProductWarehouse *ProductWarehouseSummaryDTO `json:"product_warehouse,omitempty"` - Note string `json:"note,omitempty"` + SourceProjectFlockKandang *ProjectFlockKandangWithKandangDTO `json:"source_project_flock_kandang,omitempty"` + Qty float64 `json:"qty"` + ProductWarehouse *productWarehouseDTO.ProductWarehouseRelationDTO `json:"product_warehouse,omitempty"` + Note string `json:"note,omitempty"` } type LayingTransferTargetDTO struct { - TargetProjectFlockKandang *ProjectFlockKandangSummaryDTO `json:"target_project_flock_kandang,omitempty"` - Qty float64 `json:"qty"` - ProductWarehouse *ProductWarehouseSummaryDTO `json:"product_warehouse,omitempty"` - Note string `json:"note,omitempty"` + TargetProjectFlockKandang *ProjectFlockKandangWithKandangDTO `json:"target_project_flock_kandang,omitempty"` + Qty float64 `json:"qty"` + ProductWarehouse *productWarehouseDTO.ProductWarehouseRelationDTO `json:"product_warehouse,omitempty"` + Note string `json:"note,omitempty"` } type TransferLayingListDTO struct { TransferLayingRelationDTO - FromProjectFlock *ProjectFlockSummaryDTO `json:"from_project_flock,omitempty"` - ToProjectFlock *ProjectFlockSummaryDTO `json:"to_project_flock,omitempty"` - CreatedBy uint `json:"created_by"` - CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` - CreatedAt time.Time `json:"created_at"` - Approval *approvalDTO.ApprovalRelationDTO `json:"approval,omitempty"` + FromProjectFlock *projectFlockDTO.ProjectFlockRelationDTO `json:"from_project_flock,omitempty"` + ToProjectFlock *projectFlockDTO.ProjectFlockRelationDTO `json:"to_project_flock,omitempty"` + CreatedBy uint `json:"created_by"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` + CreatedAt time.Time `json:"created_at"` + Approval *approvalDTO.ApprovalRelationDTO `json:"approval,omitempty"` } type TransferLayingDetailDTO struct { @@ -108,68 +86,12 @@ type MaxTargetQtyForTransferDTO struct { // === Mapper Functions === -func ToProjectFlockSummaryDTO(pf *entity.ProjectFlock) *ProjectFlockSummaryDTO { - if pf == nil || pf.Id == 0 { - return nil - } - - return &ProjectFlockSummaryDTO{ - Id: pf.Id, - FlockName: pf.FlockName, - Category: pf.Category, - } -} - -func ToProjectFlockKandangSummaryDTO(pfk *entity.ProjectFlockKandang) *ProjectFlockKandangSummaryDTO { - if pfk == nil || pfk.Id == 0 { - return nil - } - - var kandang *KandangSummaryDTO - if pfk.Kandang.Id != 0 { - kandang = &KandangSummaryDTO{ - Id: pfk.Kandang.Id, - Name: pfk.Kandang.Name, - } - } - - return &ProjectFlockKandangSummaryDTO{ - Id: pfk.Id, - Kandang: kandang, - } -} - -func ToProductSummaryDTO(product *entity.Product) *ProductSummaryDTO { - if product == nil || product.Id == 0 { - return nil - } - - return &ProductSummaryDTO{ - Id: product.Id, - Name: product.Name, - } -} - -func ToWarehouseSummaryDTO(warehouse *entity.Warehouse) *WarehouseSummaryDTO { - if warehouse == nil || warehouse.Id == 0 { - return nil - } - - return &WarehouseSummaryDTO{ - Id: warehouse.Id, - Name: warehouse.Name, - Type: warehouse.Type, - } -} - -func ToProductWarehouseSummaryDTO(pw *entity.ProductWarehouse) *ProductWarehouseSummaryDTO { - if pw == nil || pw.Id == 0 { - return nil - } - - return &ProductWarehouseSummaryDTO{ - Product: ToProductSummaryDTO(&pw.Product), - Warehouse: ToWarehouseSummaryDTO(&pw.Warehouse), +func ToTransferLayingRelationDTO(e entity.LayingTransfer) TransferLayingRelationDTO { + return TransferLayingRelationDTO{ + Id: e.Id, + TransferNumber: e.TransferNumber, + TransferDate: e.TransferDate, + Notes: e.Notes, } } @@ -184,10 +106,29 @@ func ToLayingTransferSourceDTO(source entity.LayingTransferSource) LayingTransfe displayQty = source.RequestedQty } + var pfkDTO *ProjectFlockKandangWithKandangDTO + if source.SourceProjectFlockKandang != nil && source.SourceProjectFlockKandang.Id != 0 { + pfkDTO = &ProjectFlockKandangWithKandangDTO{ + Id: source.SourceProjectFlockKandang.Id, + KandangId: source.SourceProjectFlockKandang.KandangId, + ProjectFlockId: source.SourceProjectFlockKandang.ProjectFlockId, + } + if source.SourceProjectFlockKandang.Kandang.Id != 0 { + mapped := kandangDTO.ToKandangRelationDTO(source.SourceProjectFlockKandang.Kandang) + pfkDTO.Kandang = &mapped + } + } + + var pwDTO *productWarehouseDTO.ProductWarehouseRelationDTO + if source.ProductWarehouse != nil && source.ProductWarehouse.Id != 0 { + mapped := productWarehouseDTO.ToProductWarehouseRelationDTO(*source.ProductWarehouse) + pwDTO = &mapped + } + return LayingTransferSourceDTO{ - SourceProjectFlockKandang: ToProjectFlockKandangSummaryDTO(source.SourceProjectFlockKandang), + SourceProjectFlockKandang: pfkDTO, Qty: displayQty, - ProductWarehouse: ToProductWarehouseSummaryDTO(source.ProductWarehouse), + ProductWarehouse: pwDTO, Note: source.Note, } } @@ -204,10 +145,29 @@ func ToLayingTransferSourceDTOs(sources []entity.LayingTransferSource) []LayingT } func ToLayingTransferTargetDTO(target entity.LayingTransferTarget) LayingTransferTargetDTO { + var pfkDTO *ProjectFlockKandangWithKandangDTO + if target.TargetProjectFlockKandang != nil && target.TargetProjectFlockKandang.Id != 0 { + pfkDTO = &ProjectFlockKandangWithKandangDTO{ + Id: target.TargetProjectFlockKandang.Id, + KandangId: target.TargetProjectFlockKandang.KandangId, + ProjectFlockId: target.TargetProjectFlockKandang.ProjectFlockId, + } + if target.TargetProjectFlockKandang.Kandang.Id != 0 { + mapped := kandangDTO.ToKandangRelationDTO(target.TargetProjectFlockKandang.Kandang) + pfkDTO.Kandang = &mapped + } + } + + var pwDTO *productWarehouseDTO.ProductWarehouseRelationDTO + if target.ProductWarehouse != nil && target.ProductWarehouse.Id != 0 { + mapped := productWarehouseDTO.ToProductWarehouseRelationDTO(*target.ProductWarehouse) + pwDTO = &mapped + } + return LayingTransferTargetDTO{ - TargetProjectFlockKandang: ToProjectFlockKandangSummaryDTO(target.TargetProjectFlockKandang), - Qty: target.TotalQty, // Ambil dari TotalQty (FIFO replenished quantity) - ProductWarehouse: ToProductWarehouseSummaryDTO(target.ProductWarehouse), + TargetProjectFlockKandang: pfkDTO, + Qty: target.TotalQty, + ProductWarehouse: pwDTO, Note: target.Note, } } @@ -223,15 +183,6 @@ func ToLayingTransferTargetDTOs(targets []entity.LayingTransferTarget) []LayingT return result } -func ToTransferLayingRelationDTO(e entity.LayingTransfer) TransferLayingRelationDTO { - return TransferLayingRelationDTO{ - Id: e.Id, - TransferNumber: e.TransferNumber, - TransferDate: e.TransferDate, - Notes: e.Notes, - } -} - func ToTransferLayingListDTO(e entity.LayingTransfer) TransferLayingListDTO { var createdUser *userDTO.UserRelationDTO if e.CreatedUser != nil && e.CreatedUser.Id != 0 { @@ -239,26 +190,52 @@ func ToTransferLayingListDTO(e entity.LayingTransfer) TransferLayingListDTO { createdUser = &mapped } + var approval *approvalDTO.ApprovalRelationDTO + if e.LatestApproval != nil { + mapped := approvalDTO.ToApprovalDTO(*e.LatestApproval) + approval = &mapped + } + + // Build from project flock DTO + var fromProjectFlock *projectFlockDTO.ProjectFlockRelationDTO + if e.FromProjectFlock != nil && e.FromProjectFlock.Id != 0 { + fromProjectFlock = &projectFlockDTO.ProjectFlockRelationDTO{ + Id: e.FromProjectFlock.Id, + FlockName: e.FromProjectFlock.FlockName, + } + } + + var toProjectFlock *projectFlockDTO.ProjectFlockRelationDTO + if e.ToProjectFlock != nil && e.ToProjectFlock.Id != 0 { + toProjectFlock = &projectFlockDTO.ProjectFlockRelationDTO{ + Id: e.ToProjectFlock.Id, + FlockName: e.ToProjectFlock.FlockName, + } + } + return TransferLayingListDTO{ TransferLayingRelationDTO: ToTransferLayingRelationDTO(e), - FromProjectFlock: ToProjectFlockSummaryDTO(e.FromProjectFlock), - ToProjectFlock: ToProjectFlockSummaryDTO(e.ToProjectFlock), + FromProjectFlock: fromProjectFlock, + ToProjectFlock: toProjectFlock, CreatedBy: e.CreatedBy, CreatedUser: createdUser, CreatedAt: e.CreatedAt, + Approval: approval, } } func ToTransferLayingDetailDTO(e entity.LayingTransfer, approvals []entity.Approval) TransferLayingDetailDTO { var latestApproval *approvalDTO.ApprovalRelationDTO - if e.LatestApproval != nil { - mapped := approvalDTO.ToApprovalDTO(*e.LatestApproval) + // Prioritas: e.LatestApproval > approvals slice + approvalToMap := e.LatestApproval + if approvalToMap == nil && len(approvals) > 0 { + approvalToMap = &approvals[len(approvals)-1] + } + + if approvalToMap != nil { + mapped := approvalDTO.ToApprovalDTO(*approvalToMap) latestApproval = &mapped - } else if len(approvals) > 0 { - // Fallback to approvals slice - latest := approvalDTO.ToApprovalDTO(approvals[len(approvals)-1]) - latestApproval = &latest } return TransferLayingDetailDTO{ @@ -272,13 +249,14 @@ func ToTransferLayingDetailDTO(e entity.LayingTransfer, approvals []entity.Appro func ToTransferLayingDetailDTOWithSingleApproval(e entity.LayingTransfer, approval *entity.Approval) TransferLayingDetailDTO { var mappedApproval *approvalDTO.ApprovalRelationDTO - // Prefer LatestApproval from entity - if e.LatestApproval != nil && e.LatestApproval.Id != 0 { - mapped := approvalDTO.ToApprovalDTO(*e.LatestApproval) - mappedApproval = &mapped - } else if approval != nil && approval.Id != 0 { - // Fallback to passed approval parameter - mapped := approvalDTO.ToApprovalDTO(*approval) + // Prioritas: e.LatestApproval > approval parameter + approvalToMap := e.LatestApproval + if approvalToMap == nil && approval != nil { + approvalToMap = approval + } + + if approvalToMap != nil { + mapped := approvalDTO.ToApprovalDTO(*approvalToMap) mappedApproval = &mapped } diff --git a/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go b/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go index 3dab5120..14fa4118 100644 --- a/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go +++ b/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go @@ -2,6 +2,7 @@ package repository import ( "context" + "strings" "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -12,6 +13,9 @@ type TransferLayingRepository interface { repository.BaseRepository[entity.LayingTransfer] GetByTransferNumber(ctx context.Context, transferNumber string) (*entity.LayingTransfer, error) IdExists(ctx context.Context, id uint) (bool, error) + + // Tambah method baru untuk query dengan filter lengkap + GetAllWithFilters(ctx context.Context, offset int, limit int, params *GetAllFilterParams) ([]entity.LayingTransfer, int64, error) } type TransferLayingRepositoryImpl struct { @@ -40,3 +44,93 @@ func (r *TransferLayingRepositoryImpl) GetByTransferNumber(ctx context.Context, } return &transfer, nil } + +type GetAllFilterParams struct { + Search string + StartDate string + EndDate string + FlockSource []uint + FlockDestination []uint + Status []string +} + +func (r *TransferLayingRepositoryImpl) GetAllWithFilters(ctx context.Context, offset int, limit int, params *GetAllFilterParams) ([]entity.LayingTransfer, int64, error) { + var records []entity.LayingTransfer + var total int64 + + q := r.db.WithContext(ctx).Model(&entity.LayingTransfer{}) + + if params.Search != "" { + searchPattern := "%" + params.Search + "%" + q = q.Joins("LEFT JOIN project_flocks AS pf_from ON laying_transfers.from_project_flock_id = pf_from.id"). + Joins("LEFT JOIN project_flocks AS pf_to ON laying_transfers.to_project_flock_id = pf_to.id"). + Where("laying_transfers.transfer_number ILIKE ? OR laying_transfers.notes ILIKE ? OR pf_from.flock_name ILIKE ? OR pf_to.flock_name ILIKE ?", + searchPattern, searchPattern, searchPattern, searchPattern) + } + + if params.StartDate != "" && params.EndDate != "" { + q = q.Where("transfer_date::date >= ?::date AND transfer_date::date <= ?::date", + params.StartDate, params.EndDate) + } else if params.StartDate != "" { + q = q.Where("transfer_date::date >= ?::date", params.StartDate) + } else if params.EndDate != "" { + q = q.Where("transfer_date::date <= ?::date", params.EndDate) + } + + if len(params.FlockSource) > 0 { + q = q.Where("from_project_flock_id IN ?", params.FlockSource) + } + + if len(params.FlockDestination) > 0 { + q = q.Where("to_project_flock_id IN ?", params.FlockDestination) + } + + if len(params.Status) > 0 { + statusConditions := []string{} + statusValues := []interface{}{} + + for _, status := range params.Status { + switch status { + case "PENDING": + statusConditions = append(statusConditions, + "NOT EXISTS (SELECT 1 FROM approvals WHERE approvable_type = 'TRANSFER_TO_LAYINGS' AND approvable_id = laying_transfers.id)") + + case "APPROVED": + statusConditions = append(statusConditions, + "EXISTS (SELECT 1 FROM approvals WHERE approvable_type = 'TRANSFER_TO_LAYINGS' AND approvable_id = laying_transfers.id AND action = 'APPROVED' ORDER BY created_at DESC LIMIT 1)") + + case "REJECTED": + statusConditions = append(statusConditions, + "EXISTS (SELECT 1 FROM approvals WHERE approvable_type = 'TRANSFER_TO_LAYINGS' AND approvable_id = laying_transfers.id AND action = 'REJECTED' ORDER BY created_at DESC LIMIT 1)") + } + } + + if len(statusConditions) > 0 { + q = q.Where("("+strings.Join(statusConditions, " OR ")+")", statusValues...) + } + } + + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + + q = q.Offset(offset).Limit(limit). + Preload("FromProjectFlock"). + Preload("ToProjectFlock"). + Preload("CreatedUser"). + Preload("Sources"). + Preload("Sources.SourceProjectFlockKandang"). + Preload("Sources.SourceProjectFlockKandang.Kandang"). + Preload("Sources.ProductWarehouse"). + Preload("Targets"). + Preload("Targets.TargetProjectFlockKandang"). + Preload("Targets.TargetProjectFlockKandang.Kandang"). + Preload("Targets.ProductWarehouse"). + Order("laying_transfers.created_at DESC") + + if err := q.Find(&records).Error; err != nil { + return nil, 0, err + } + + return records, total, nil +} diff --git a/internal/modules/production/transfer_layings/services/transfer_laying.service.go b/internal/modules/production/transfer_layings/services/transfer_laying.service.go index bfdfab0f..a5d0ba88 100644 --- a/internal/modules/production/transfer_layings/services/transfer_laying.service.go +++ b/internal/modules/production/transfer_layings/services/transfer_laying.service.go @@ -109,34 +109,20 @@ func (s transferLayingService) GetAll(c *fiber.Ctx, params *validation.Query) ([ offset := (params.Page - 1) * params.Limit - transferLayings, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { - // Apply search and filters - if params.Search != "" { - searchPattern := "%" + params.Search + "%" - db = db.Joins("LEFT JOIN project_flocks AS pf_from ON laying_transfers.from_project_flock_id = pf_from.id"). - Joins("LEFT JOIN project_flocks AS pf_to ON laying_transfers.to_project_flock_id = pf_to.id"). - Where("laying_transfers.transfer_number ILIKE ? OR laying_transfers.notes ILIKE ? OR pf_from.flock_name ILIKE ? OR pf_to.flock_name ILIKE ?", - searchPattern, searchPattern, searchPattern, searchPattern) - } + filterParams := &repository.GetAllFilterParams{ + Search: params.Search, + StartDate: params.StartDate, + EndDate: params.EndDate, + FlockSource: params.FlockSource, + FlockDestination: params.FlockDestination, + Status: params.Status, + } - if params.TransferDate != "" { - db = db.Where("transfer_date::date = ?::date", params.TransferDate) - } - - if params.FlockSource > 0 { - db = db.Where("from_project_flock_id = ?", params.FlockSource) - } - - if params.FlockDestination > 0 { - db = db.Where("to_project_flock_id = ?", params.FlockDestination) - } - - db = db.Order("created_at DESC") - - db = s.withRelations(db) - - return db - }) + transferLayings, total, err := s.Repository.GetAllWithFilters(c.Context(), offset, params.Limit, filterParams) + if err != nil { + s.Log.Errorf("Failed to get transferLayings: %+v", err) + return nil, 0, err + } if err != nil { s.Log.Errorf("Failed to get transferLayings: %+v", err) diff --git a/internal/modules/production/transfer_layings/validations/transfer_laying.validation.go b/internal/modules/production/transfer_layings/validations/transfer_laying.validation.go index 06d52316..0472ba39 100644 --- a/internal/modules/production/transfer_layings/validations/transfer_laying.validation.go +++ b/internal/modules/production/transfer_layings/validations/transfer_laying.validation.go @@ -29,12 +29,14 @@ type Update struct { } 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"` - TransferDate string `query:"transfer_date" validate:"omitempty"` - FlockSource uint `query:"flock_source" validate:"omitempty,number"` - FlockDestination uint `query:"flock_destination" validate:"omitempty,number"` + 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"` + StartDate string `query:"start_date" validate:"omitempty"` + EndDate string `query:"end_date" validate:"omitempty"` + FlockSource []uint `query:"flock_source" validate:"omitempty"` + FlockDestination []uint `query:"flock_destination" validate:"omitempty"` + Status []string `query:"status" validate:"omitempty"` } type Approve struct { diff --git a/internal/utils/strings.go b/internal/utils/strings.go index a58ba1ac..e9e23f84 100644 --- a/internal/utils/strings.go +++ b/internal/utils/strings.go @@ -2,6 +2,7 @@ package utils import ( "sort" + "strconv" "strings" ) @@ -47,3 +48,54 @@ func ParseFlags(raw string) []string { sort.Strings(res) return res } + +// ParseQueryArray parses comma-separated string values and returns a slice of trimmed strings +// Example: "a, b, c" → ["a", "b", "c"] +func ParseQueryArray(raw string) []string { + if raw == "" { + return nil + } + + parts := strings.Split(raw, ",") + result := make([]string, 0, len(parts)) + + for _, p := range parts { + trimmed := strings.TrimSpace(p) + if trimmed != "" { + result = append(result, trimmed) + } + } + + if len(result) == 0 { + return nil + } + return result +} + +// ParseQueryUintArray parses comma-separated string values and returns a slice of uint +// Invalid values are skipped +// Example: "1, 2, 3" → [1, 2, 3] +func ParseQueryUintArray(raw string) []uint { + if raw == "" { + return nil + } + + parts := strings.Split(raw, ",") + result := make([]uint, 0, len(parts)) + + for _, p := range parts { + trimmed := strings.TrimSpace(p) + if trimmed == "" { + continue + } + + if num, err := strconv.ParseUint(trimmed, 10, 32); err == nil { + result = append(result, uint(num)) + } + } + + if len(result) == 0 { + return nil + } + return result +} From 00cdfb692b6407bf56bdb8aff5a938308750062e Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 27 Jan 2026 10:34:25 +0700 Subject: [PATCH 19/37] [FIX/BE-US] feat adjustment location and area --- internal/middleware/role_scope.go | 39 +- .../closings/services/closing.service.go | 10 + .../services/daily-checklist.service.go | 138 +++++- .../services/adjustment.service.go | 20 + .../services/product-stock.service.go | 71 ++- .../product_warehouse.controller.go | 14 +- .../services/product_warehouse.service.go | 56 ++- .../product_warehouse.validation.go | 13 +- .../salesorder_delivery_product.repository.go | 20 +- .../services/deliveryorder.service.go | 17 +- .../employees/services/employees.service.go | 89 +++- .../controllers/warehouse.controller.go | 31 ++ .../warehouses/services/warehouse.service.go | 14 +- .../validations/warehouse.validation.go | 2 + .../controllers/projectflock.controller.go | 2 + .../services/projectflock.service.go | 6 + .../validations/projectflock.validation.go | 21 +- .../services/transfer_laying.service.go | 54 ++- .../services/uniformity.service.go | 2 +- .../controllers/repport.controller.go | 26 + .../customer_payment.repository.go | 40 +- .../repositories/debt_supplier.repository.go | 34 ++ .../repports/services/repport.service.go | 87 +++- .../validations/repport.validation.go | 91 ++-- internal/utils/constant.go | 10 + .../recording_fifo_integration_test.go | 446 ------------------ 26 files changed, 753 insertions(+), 600 deletions(-) delete mode 100644 test/integration/production/recordings/recording_fifo_integration_test.go diff --git a/internal/middleware/role_scope.go b/internal/middleware/role_scope.go index c46a7fab..8430b6fc 100644 --- a/internal/middleware/role_scope.go +++ b/internal/middleware/role_scope.go @@ -82,6 +82,18 @@ func ResolveLocationScope(c *fiber.Ctx, db *gorm.DB) (ScopeFilter, error) { return ScopeFilter{IDs: locationIDs, Restrict: true}, nil } +func ResolveLocationAreaScopes(c *fiber.Ctx, db *gorm.DB) (ScopeFilter, ScopeFilter, error) { + locationScope, err := ResolveLocationScope(c, db) + if err != nil { + return ScopeFilter{}, ScopeFilter{}, err + } + areaScope, err := ResolveAreaScope(c, db) + if err != nil { + return ScopeFilter{}, ScopeFilter{}, err + } + return locationScope, areaScope, nil +} + func collectRoleScope(c *fiber.Ctx) (roleScope, error) { ctx, ok := AuthDetails(c) if !ok || ctx == nil { @@ -212,6 +224,31 @@ func ApplyAreaScope(c *fiber.Ctx, db *gorm.DB, column string) (*gorm.DB, error) return ApplyScopeFilter(db, scope, column), nil } +func ApplyLocationAreaScope(c *fiber.Ctx, db *gorm.DB, locationColumn, areaColumn string) (*gorm.DB, error) { + scopeDB := db + if db != nil { + scopeDB = db.Session(&gorm.Session{NewDB: true}) + } + + if locationColumn != "" { + locationScope, err := ResolveLocationScope(c, scopeDB) + if err != nil { + return db, err + } + db = ApplyScopeFilter(db, locationScope, locationColumn) + } + + if areaColumn != "" { + areaScope, err := ResolveAreaScope(c, scopeDB) + if err != nil { + return db, err + } + db = ApplyScopeFilter(db, areaScope, areaColumn) + } + + return db, nil +} + func EnsureWarehouseAccess(c *fiber.Ctx, db *gorm.DB, warehouseID uint) error { if warehouseID == 0 { return fiber.NewError(fiber.StatusBadRequest, "Invalid warehouse id") @@ -486,7 +523,7 @@ func EnsureUniformityAccess(c *fiber.Ctx, db *gorm.DB, uniformityID uint) error var count int64 q := db.WithContext(c.Context()). - Table("project_flock_kandang_uniformities u"). + Table("project_flock_kandang_uniformity u"). Joins("JOIN project_flock_kandangs pfk ON pfk.id = u.project_flock_kandang_id"). Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id"). Where("u.id = ?", uniformityID) diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index caa3fc24..10ba9c9b 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -99,6 +99,10 @@ func (s closingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]dto.Cl return nil, 0, err } + scope, err := m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, 0, err + } offset := (params.Page - 1) * params.Limit statusFilter := "" @@ -113,6 +117,12 @@ func (s closingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]dto.Cl closings, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withClosingRelations(db) + if scope.Restrict { + if len(scope.IDs) == 0 { + return db.Where("1 = 0") + } + db = m.ApplyScopeFilter(db, scope, "project_flocks.location_id") + } if params.LocationID != nil { db = db.Where("location_id = ?", *params.LocationID) } diff --git a/internal/modules/daily-checklists/services/daily-checklist.service.go b/internal/modules/daily-checklists/services/daily-checklist.service.go index 64802560..e2974039 100644 --- a/internal/modules/daily-checklists/services/daily-checklist.service.go +++ b/internal/modules/daily-checklists/services/daily-checklist.service.go @@ -134,6 +134,87 @@ func (s dailyChecklistService) withRelations(db *gorm.DB) *gorm.DB { return db.Preload("Kandang") } +func (s dailyChecklistService) ensureChecklistAccess(c *fiber.Ctx, checklistID uint) error { + if checklistID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid checklist id") + } + + db := s.Repository.DB().WithContext(c.Context()). + Table("daily_checklists dc"). + Joins("JOIN kandangs k ON k.id = dc.kandang_id"). + Joins("JOIN locations loc ON loc.id = k.location_id"). + Joins("JOIN areas a ON a.id = loc.area_id"). + Where("dc.id = ?", checklistID) + + scopedDB, err := m.ApplyLocationAreaScope(c, db, "loc.id", "a.id") + if err != nil { + return err + } + + var count int64 + if err := scopedDB.Count(&count).Error; err != nil { + return err + } + if count == 0 { + return fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found") + } + return nil +} + +func (s dailyChecklistService) ensureKandangAccess(c *fiber.Ctx, kandangID uint) error { + if kandangID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid kandang id") + } + + db := s.Repository.DB().WithContext(c.Context()). + Table("kandangs k"). + Joins("JOIN locations loc ON loc.id = k.location_id"). + Joins("JOIN areas a ON a.id = loc.area_id"). + Where("k.id = ?", kandangID) + + scopedDB, err := m.ApplyLocationAreaScope(c, db, "loc.id", "a.id") + if err != nil { + return err + } + + var count int64 + if err := scopedDB.Count(&count).Error; err != nil { + return err + } + if count == 0 { + return fiber.NewError(fiber.StatusNotFound, "Kandang not found") + } + return nil +} + +func (s dailyChecklistService) ensureTaskAccess(c *fiber.Ctx, taskID uint) error { + if taskID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid task id") + } + + db := s.Repository.DB().WithContext(c.Context()). + Table("daily_checklist_activity_tasks t"). + Joins("JOIN daily_checklists dc ON dc.id = t.checklist_id"). + Joins("JOIN kandangs k ON k.id = dc.kandang_id"). + Joins("JOIN locations loc ON loc.id = k.location_id"). + Joins("JOIN areas a ON a.id = loc.area_id"). + Where("t.id = ?", taskID) + + scopedDB, err := m.ApplyLocationAreaScope(c, db, "loc.id", "a.id") + if err != nil { + return err + } + + var count int64 + if err := scopedDB.Count(&count).Error; err != nil { + return err + } + if count == 0 { + return fiber.NewError(fiber.StatusNotFound, "Task not found") + } + return nil +} + func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([]DailyChecklistListItem, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err @@ -143,7 +224,15 @@ func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([ db := s.Repository.DB().WithContext(c.Context()). Table("daily_checklists dc"). - Joins("JOIN kandangs k ON k.id = dc.kandang_id") + Joins("JOIN kandangs k ON k.id = dc.kandang_id"). + Joins("JOIN locations loc ON loc.id = k.location_id"). + Joins("JOIN areas a ON a.id = loc.area_id") + + var scopeErr error + db, scopeErr = m.ApplyLocationAreaScope(c, db, "loc.id", "a.id") + if scopeErr != nil { + return nil, 0, scopeErr + } if params.DateFrom != "" { dateFrom, err := time.Parse("2006-01-02", params.DateFrom) @@ -294,6 +383,9 @@ func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([ } func (s dailyChecklistService) GetOne(c *fiber.Ctx, id uint) (*entity.DailyChecklist, error) { + if err := s.ensureChecklistAccess(c, id); err != nil { + return nil, err + } 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") @@ -399,6 +491,9 @@ func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create) if err := s.Validate.Struct(req); err != nil { return nil, err } + if err := s.ensureKandangAccess(c, req.KandangId); err != nil { + return nil, err + } date, err := time.Parse("2006-01-02", req.Date) if err != nil { @@ -431,6 +526,9 @@ func (s dailyChecklistService) UpdateOne(c *fiber.Ctx, req *validation.Update, i if err := s.Validate.Struct(req); err != nil { return nil, err } + if err := s.ensureChecklistAccess(c, id); err != nil { + return nil, err + } deletedIDs := make([]uint, 0) if req.DeletedDocumentIDs != nil { @@ -502,6 +600,9 @@ func (s dailyChecklistService) UpdateOne(c *fiber.Ctx, req *validation.Update, i } func (s dailyChecklistService) DeleteOne(c *fiber.Ctx, id uint) error { + if err := s.ensureChecklistAccess(c, id); err != nil { + return err + } if err := s.Repository.DeleteOne(c.Context(), id); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found") @@ -516,6 +617,9 @@ func (s dailyChecklistService) AssignPhases(c *fiber.Ctx, id uint, req *validati if err := s.Validate.Struct(req); err != nil { return err } + if err := s.ensureChecklistAccess(c, id); err != nil { + return err + } if _, err := s.Repository.GetByID(c.Context(), id, nil); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -597,6 +701,9 @@ func (s dailyChecklistService) AssignPhases(c *fiber.Ctx, id uint, req *validati } func (s dailyChecklistService) RemoveAssignment(c *fiber.Ctx, id uint, employeeID uint) error { + if err := s.ensureChecklistAccess(c, id); 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") @@ -634,6 +741,9 @@ func (s dailyChecklistService) GetTasks(c *fiber.Ctx, checklistID uint) ([]entit if checklistID == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "checklist_id is required") } + if err := s.ensureChecklistAccess(c, checklistID); err != nil { + return nil, err + } if _, err := s.Repository.GetByID(c.Context(), checklistID, nil); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -658,6 +768,9 @@ func (s dailyChecklistService) GetChecklistPhaseIDs(c *fiber.Ctx, checklistID ui if checklistID == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "checklist_id is required") } + if err := s.ensureChecklistAccess(c, checklistID); err != nil { + return nil, err + } if _, err := s.Repository.GetByID(c.Context(), checklistID, nil); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -687,6 +800,9 @@ func (s dailyChecklistService) UpdateAssignment(c *fiber.Ctx, req *validation.Up if err := s.Validate.Struct(req); err != nil { return err } + if err := s.ensureTaskAccess(c, req.TaskID); err != nil { + return err + } task := new(entity.DailyChecklistActivityTask) if err := s.Repository.DB().WithContext(c.Context()).First(task, req.TaskID).Error; err != nil { @@ -808,6 +924,9 @@ func (s dailyChecklistService) AssignTasks(c *fiber.Ctx, id uint, req *validatio if err := s.Validate.Struct(req); err != nil { return err } + if err := s.ensureChecklistAccess(c, id); err != nil { + return err + } employeeIDs, err := parseIDs(req.EmployeeIDs) if err != nil { @@ -900,8 +1019,16 @@ func (s dailyChecklistService) GetSummary(c *fiber.Ctx, params *validation.Summa Joins("JOIN daily_checklists d ON d.id = t.checklist_id"). Joins("JOIN kandangs k ON k.id = d.kandang_id"). Joins("JOIN employees e ON e.id = a.employee_id"). + Joins("JOIN locations loc ON loc.id = k.location_id"). + Joins("JOIN areas ar ON ar.id = loc.area_id"). Where("d.date BETWEEN ? AND ? AND d.status = ?", dateFrom, dateTo, "APPROVED") + var scopeErr error + db, scopeErr = m.ApplyLocationAreaScope(c, db, "loc.id", "ar.id") + if scopeErr != nil { + return nil, scopeErr + } + if params.Category != "" { db = db.Where("d.category = ?", params.Category) } @@ -946,7 +1073,11 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report return nil, 0, err } - scope, err := m.ResolveLocationScope(c, s.Repository.DB()) + locationScope, err := m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, 0, err + } + areaScope, err := m.ResolveAreaScope(c, s.Repository.DB()) if err != nil { return nil, 0, err } @@ -967,7 +1098,8 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report Where("EXTRACT(YEAR FROM dc.date) = ?", params.Year). Where("dc.status = ?", "APPROVED") - db = m.ApplyScopeFilter(db, scope, "loc.id") + db = m.ApplyScopeFilter(db, locationScope, "loc.id") + db = m.ApplyScopeFilter(db, areaScope, "a.id") if params.AreaID != nil { db = db.Where("a.id = ?", *params.AreaID) diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index bec0ef74..737a4e86 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -74,6 +74,10 @@ func (s *adjustmentService) withRelations(db *gorm.DB) *gorm.DB { } func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.AdjustmentStock, error) { + if err := m.EnsureStockLogAccess(c, s.StockLogsRepository.DB(), id); err != nil { + return nil, err + } + adjustmentStock, err := s.AdjustmentStockRepository.GetByStockLogID(c.Context(), id) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -95,6 +99,9 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e if err != nil { return nil, err } + if err := m.EnsureWarehouseAccess(c, s.WarehouseRepo.DB(), uint(req.WarehouseID)); err != nil { + return nil, err + } if err := common.EnsureRelations(c.Context(), common.RelationCheck{Name: "Product", ID: &req.ProductID, Exists: s.ProductRepo.IdExists}, common.RelationCheck{Name: "Warehouse", ID: &req.WarehouseID, Exists: s.WarehouseRepo.IdExists}, @@ -304,6 +311,19 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu Preload("ProductWarehouse.Product"). Preload("ProductWarehouse.Warehouse") + scope, scopeErr := m.ResolveLocationScope(c, s.AdjustmentStockRepository.DB()) + if scopeErr != nil { + return nil, 0, scopeErr + } + if scope.Restrict { + if len(scope.IDs) == 0 { + return []*entity.AdjustmentStock{}, 0, nil + } + q = q.Joins("JOIN product_warehouses pw_scope ON pw_scope.id = adjustment_stocks.product_warehouse_id"). + Joins("JOIN warehouses w_scope ON w_scope.id = pw_scope.warehouse_id") + q = m.ApplyScopeFilter(q, scope, "w_scope.location_id") + } + if query.ProductID > 0 { q = q.Joins("JOIN stock_logs ON stock_logs.id = adjustment_stocks.stock_log_id"). Joins("JOIN product_warehouses ON product_warehouses.id = stock_logs.product_warehouse_id"). diff --git a/internal/modules/inventory/product-stocks/services/product-stock.service.go b/internal/modules/inventory/product-stocks/services/product-stock.service.go index a4e404d6..63ae97ac 100644 --- a/internal/modules/inventory/product-stocks/services/product-stock.service.go +++ b/internal/modules/inventory/product-stocks/services/product-stock.service.go @@ -37,19 +37,49 @@ func NewProductStockService( } } -func (s productStockService) withRelations(db *gorm.DB) *gorm.DB { +func (s productStockService) withRelations(db *gorm.DB, locationScope, areaScope m.ScopeFilter) *gorm.DB { + warehouseScope := func(db *gorm.DB) *gorm.DB { + if locationScope.Restrict { + db = db.Where("warehouses.location_id IN ?", locationScope.IDs) + } + if areaScope.Restrict { + db = db.Where("warehouses.area_id IN ?", areaScope.IDs) + } + return db + } + productWarehouseScope := func(db *gorm.DB) *gorm.DB { + db = db.Joins("JOIN warehouses w ON w.id = product_warehouses.warehouse_id") + if locationScope.Restrict { + db = db.Where("w.location_id IN ?", locationScope.IDs) + } + if areaScope.Restrict { + db = db.Where("w.area_id IN ?", areaScope.IDs) + } + return db + } + stockLogScope := func(db *gorm.DB) *gorm.DB { + db = db. + Joins("JOIN product_warehouses pw ON pw.id = stock_logs.product_warehouse_id"). + Joins("JOIN warehouses w ON w.id = pw.warehouse_id") + if locationScope.Restrict { + db = db.Where("w.location_id IN ?", locationScope.IDs) + } + if areaScope.Restrict { + db = db.Where("w.area_id IN ?", areaScope.IDs) + } + return db.Order("stock_logs.created_at ASC") + } + return db. Preload("CreatedUser"). Preload("Uom"). Preload("ProductCategory"). Preload("Flags"). - Preload("ProductWarehouses"). - Preload("ProductWarehouses.Warehouse"). + Preload("ProductWarehouses", productWarehouseScope). + Preload("ProductWarehouses.Warehouse", warehouseScope). Preload("ProductWarehouses.Warehouse.Location"). Preload("ProductWarehouses.Warehouse.Location.Area"). - Preload("ProductWarehouses.StockLogs", func(db *gorm.DB) *gorm.DB { - return db.Order("created_at ASC") - }). + Preload("ProductWarehouses.StockLogs", stockLogScope). Preload("ProductWarehouses.StockLogs.CreatedUser"). Preload("ProductSuppliers"). Preload("ProductSuppliers.Supplier", func(db *gorm.DB) *gorm.DB { @@ -62,7 +92,7 @@ func (s productStockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e return nil, 0, err } - scope, err := m.ResolveLocationScope(c, s.ProductRepository.DB()) + locationScope, areaScope, err := m.ResolveLocationAreaScopes(c, s.ProductRepository.DB()) if err != nil { return nil, 0, err } @@ -70,8 +100,8 @@ func (s productStockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e offset := (params.Page - 1) * params.Limit productStocks, total, err := s.ProductRepository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { - if scope.Restrict { - if len(scope.IDs) == 0 { + if locationScope.Restrict || areaScope.Restrict { + if (locationScope.Restrict && len(locationScope.IDs) == 0) || (areaScope.Restrict && len(areaScope.IDs) == 0) { return db.Where("1 = 0") } db = db.Where(`EXISTS ( @@ -80,8 +110,12 @@ func (s productStockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e JOIN warehouses w ON w.id = pw.warehouse_id WHERE pw.product_id = products.id AND pw.qty > 0 - AND w.location_id IN ? - )`, scope.IDs) + AND (? OR w.location_id IN ?) + AND (? OR w.area_id IN ?) + )`, + !locationScope.Restrict, locationScope.IDs, + !areaScope.Restrict, areaScope.IDs, + ) } else { db = db.Where(`EXISTS ( SELECT 1 @@ -91,7 +125,7 @@ func (s productStockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e )`) } - db = s.withRelations(db) + db = s.withRelations(db, locationScope, areaScope) if params.Search != "" { db = db.Where("products.name ILIKE ?", "%"+params.Search+"%") } @@ -106,13 +140,13 @@ func (s productStockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e } func (s productStockService) GetOne(c *fiber.Ctx, id uint) (*entity.Product, error) { - scope, err := m.ResolveLocationScope(c, s.ProductRepository.DB()) + locationScope, areaScope, err := m.ResolveLocationAreaScopes(c, s.ProductRepository.DB()) if err != nil { return nil, err } - if scope.Restrict { - if len(scope.IDs) == 0 { + if locationScope.Restrict || areaScope.Restrict { + if (locationScope.Restrict && len(locationScope.IDs) == 0) || (areaScope.Restrict && len(areaScope.IDs) == 0) { return nil, fiber.NewError(fiber.StatusNotFound, "Product not found") } var count int64 @@ -121,7 +155,8 @@ func (s productStockService) GetOne(c *fiber.Ctx, id uint) (*entity.Product, err Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). Where("pw.product_id = ?", id). Where("pw.qty > 0"). - Where("w.location_id IN ?", scope.IDs). + Where("(? OR w.location_id IN ?)", !locationScope.Restrict, locationScope.IDs). + Where("(? OR w.area_id IN ?)", !areaScope.Restrict, areaScope.IDs). Count(&count).Error; err != nil { return nil, err } @@ -130,7 +165,9 @@ func (s productStockService) GetOne(c *fiber.Ctx, id uint) (*entity.Product, err } } - product, err := s.ProductRepository.GetByID(c.Context(), id, s.withRelations) + product, err := s.ProductRepository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + return s.withRelations(db, locationScope, areaScope) + }) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Product not found") } diff --git a/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go b/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go index 671d964b..47d85a65 100644 --- a/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go +++ b/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go @@ -8,6 +8,7 @@ import ( service "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/services" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/validations" "gitlab.com/mbugroup/lti-api.git/internal/response" + "gitlab.com/mbugroup/lti-api.git/internal/utils" "github.com/gofiber/fiber/v2" ) @@ -24,12 +25,13 @@ func NewProductWarehouseController(productWarehouseService service.ProductWareho func (u *ProductWarehouseController) GetAll(c *fiber.Ctx) error { query := &validation.Query{ - Page: c.QueryInt("page", 1), - Limit: c.QueryInt("limit", 10), - ProductId: uint(c.QueryInt("product_id", 0)), - WarehouseId: uint(c.QueryInt("warehouse_id", 0)), - Flags: c.Query("flags", ""), - KandangId: uint(c.QueryInt("kandang_id", 0)), + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + ProductId: uint(c.QueryInt("product_id", 0)), + WarehouseId: uint(c.QueryInt("warehouse_id", 0)), + Flags: c.Query("flags", ""), + KandangId: uint(c.QueryInt("kandang_id", 0)), + TransferContext: c.Query(utils.TransferContextKey, ""), } if query.Page < 1 || query.Limit < 1 { diff --git a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go index ec1992ef..5bb3f692 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -7,11 +7,11 @@ import ( "github.com/gofiber/fiber/v2" "github.com/sirupsen/logrus" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/validations" kandangrepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" - m "gitlab.com/mbugroup/lti-api.git/internal/middleware" "gorm.io/gorm" ) @@ -54,9 +54,17 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) return nil, 0, err } - scope, err := m.ResolveLocationScope(c, s.Repository.DB()) - if err != nil { - return nil, 0, err + applyScope := true + if params.TransferContext == utils.TransferContextInventoryTransfer { + applyScope = !m.HasPermission(c, m.P_TransferCreateOne) + } + var scope m.ScopeFilter + var err error + if applyScope { + scope, err = m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, 0, err + } } if params.ProductId > 0 { @@ -96,12 +104,15 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) productWarehouses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) - if scope.Restrict { - if len(scope.IDs) == 0 { - return db.Where("1 = 0") + db = db.Joins("JOIN warehouses w_scope ON product_warehouses.warehouse_id = w_scope.id"). + Where("w_scope.deleted_at IS NULL") + if applyScope { + if scope.Restrict { + if len(scope.IDs) == 0 { + return db.Where("1 = 0") + } + db = db.Where("w_scope.location_id IN ?", scope.IDs) } - db = db.Joins("JOIN warehouses w_scope ON product_warehouses.warehouse_id = w_scope.id"). - Where("w_scope.location_id IN ?", scope.IDs) } if params.ProductId != 0 { @@ -130,19 +141,30 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) } func (s productWarehouseService) GetOne(c *fiber.Ctx, id uint) (*entity.ProductWarehouse, error) { - scope, err := m.ResolveLocationScope(c, s.Repository.DB()) - if err != nil { - return nil, err + applyScope := true + if c.Query(utils.TransferContextKey, "") == utils.TransferContextInventoryTransfer { + applyScope = !m.HasPermission(c, m.P_TransferCreateOne) + } + var scope m.ScopeFilter + var err error + if applyScope { + scope, err = m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, err + } } productWarehouse, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) - if scope.Restrict { - if len(scope.IDs) == 0 { - return db.Where("1 = 0") + db = db.Joins("JOIN warehouses w_scope ON product_warehouses.warehouse_id = w_scope.id"). + Where("w_scope.deleted_at IS NULL") + if applyScope { + if scope.Restrict { + if len(scope.IDs) == 0 { + return db.Where("1 = 0") + } + db = db.Where("w_scope.location_id IN ?", scope.IDs) } - db = db.Joins("JOIN warehouses w_scope ON product_warehouses.warehouse_id = w_scope.id"). - Where("w_scope.location_id IN ?", scope.IDs) } return db }) diff --git a/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go b/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go index 322d0a00..61a41ad0 100644 --- a/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go +++ b/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go @@ -13,10 +13,11 @@ 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"` - ProductId uint `query:"product_id" validate:"omitempty,number,min=1"` - WarehouseId uint `query:"warehouse_id" validate:"omitempty,number,min=1"` - Flags string `query:"flags" validate:"omitempty"` - KandangId uint `query:"kandang_id" validate:"omitempty,number,min=1"` + Page int `query:"page" validate:"omitempty,number,min=1"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + ProductId uint `query:"product_id" validate:"omitempty,number,min=1"` + WarehouseId uint `query:"warehouse_id" validate:"omitempty,number,min=1"` + Flags string `query:"flags" validate:"omitempty"` + KandangId uint `query:"kandang_id" validate:"omitempty,number,min=1"` + TransferContext string `query:"transfer_context" validate:"omitempty,oneof=inventory_transfer"` } diff --git a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go index 1ec0bddf..b6ab83c9 100644 --- a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go +++ b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go @@ -140,7 +140,7 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C Joins("JOIN marketings ON marketings.id = marketing_products.marketing_id"). Where("marketing_delivery_products.delivery_date IS NOT NULL") - if filters.ProductId > 0 || filters.WarehouseId > 0 || filters.AreaId > 0 || filters.LocationId > 0 || filters.Search != "" || filters.MarketingType != "" { + if filters.ProductId > 0 || filters.WarehouseId > 0 || filters.AreaId > 0 || filters.LocationId > 0 || filters.AllowedAreaIDs != nil || filters.AllowedLocationIDs != nil || filters.Search != "" || filters.MarketingType != "" { db = db.Joins("LEFT JOIN product_warehouses ON product_warehouses.id = marketing_products.product_warehouse_id") } @@ -190,7 +190,7 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C db = db.Where("product_warehouses.warehouse_id = ?", filters.WarehouseId) } - if filters.AreaId > 0 || filters.LocationId > 0 { + if filters.AreaId > 0 || filters.LocationId > 0 || filters.AllowedAreaIDs != nil || filters.AllowedLocationIDs != nil { db = db.Joins("LEFT JOIN project_flock_kandangs ON project_flock_kandangs.id = product_warehouses.project_flock_kandang_id"). Joins("LEFT JOIN project_flocks ON project_flocks.id = project_flock_kandangs.project_flock_id") @@ -201,6 +201,22 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C if filters.LocationId > 0 { db = db.Where("project_flocks.location_id = ?", filters.LocationId) } + + if filters.AllowedAreaIDs != nil { + if len(filters.AllowedAreaIDs) == 0 { + db = db.Where("1 = 0") + } else { + db = db.Where("project_flocks.area_id IN ?", filters.AllowedAreaIDs) + } + } + + if filters.AllowedLocationIDs != nil { + if len(filters.AllowedLocationIDs) == 0 { + db = db.Where("1 = 0") + } else { + db = db.Where("project_flocks.location_id IN ?", filters.AllowedLocationIDs) + } + } } if filters.MarketingType != "" { diff --git a/internal/modules/marketing/services/deliveryorder.service.go b/internal/modules/marketing/services/deliveryorder.service.go index 2a6a6fee..2022cc78 100644 --- a/internal/modules/marketing/services/deliveryorder.service.go +++ b/internal/modules/marketing/services/deliveryorder.service.go @@ -119,12 +119,17 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO if len(scope.IDs) == 0 { return db.Where("1 = 0") } - db = db. - Joins("JOIN marketing_products mp ON mp.marketing_id = marketings.id"). - Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id"). - Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). - Where("w.location_id IN ?", scope.IDs). - Distinct("marketings.*") + db = db.Where( + `EXISTS ( + SELECT 1 + FROM marketing_products mp + JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id + JOIN warehouses w ON w.id = pw.warehouse_id + WHERE mp.marketing_id = marketings.id + AND w.location_id IN ? + )`, + scope.IDs, + ) } if params.MarketingId != 0 { diff --git a/internal/modules/master/employees/services/employees.service.go b/internal/modules/master/employees/services/employees.service.go index b3673eaf..2817a9fa 100644 --- a/internal/modules/master/employees/services/employees.service.go +++ b/internal/modules/master/employees/services/employees.service.go @@ -5,6 +5,7 @@ import ( "strings" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" 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" @@ -43,6 +44,61 @@ func (s employeesService) withRelations(db *gorm.DB) *gorm.DB { Where("employees.deleted_at IS NULL") } +func (s employeesService) ensureEmployeeAccess(c *fiber.Ctx, employeeID uint) error { + if employeeID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid employee id") + } + + db := s.Repository.DB().WithContext(c.Context()). + Table("employees e"). + Joins("JOIN employee_kandangs ek ON ek.employee_id = e.id"). + Joins("JOIN kandangs k ON k.id = ek.kandang_id"). + Joins("JOIN locations loc ON loc.id = k.location_id"). + Joins("JOIN areas a ON a.id = loc.area_id"). + Where("e.id = ?", employeeID). + Where("e.deleted_at IS NULL") + + scopedDB, err := m.ApplyLocationAreaScope(c, db, "loc.id", "a.id") + if err != nil { + return err + } + + var count int64 + if err := scopedDB.Count(&count).Error; err != nil { + return err + } + if count == 0 { + return fiber.NewError(fiber.StatusNotFound, "Employees not found") + } + return nil +} + +func (s employeesService) ensureKandangIDsAccess(c *fiber.Ctx, kandangIDs []uint) error { + if len(kandangIDs) == 0 { + return fiber.NewError(fiber.StatusBadRequest, "kandang_ids must contain at least one valid id") + } + + db := s.Repository.DB().WithContext(c.Context()). + Table("kandangs k"). + Joins("JOIN locations loc ON loc.id = k.location_id"). + Joins("JOIN areas a ON a.id = loc.area_id"). + Where("k.id IN ?", kandangIDs) + + scopedDB, err := m.ApplyLocationAreaScope(c, db, "loc.id", "a.id") + if err != nil { + return err + } + + var count int64 + if err := scopedDB.Count(&count).Error; err != nil { + return err + } + if count != int64(len(kandangIDs)) { + return fiber.NewError(fiber.StatusNotFound, "Kandang not found") + } + return nil +} + 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 @@ -52,17 +108,29 @@ func (s employeesService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti employeess, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) + db = db.Joins("JOIN employee_kandangs ek ON ek.employee_id = employees.id"). + Joins("JOIN kandangs k ON k.id = ek.kandang_id"). + Joins("JOIN locations loc ON loc.id = k.location_id"). + Joins("JOIN areas a ON a.id = loc.area_id") + var scopeErr error + db, scopeErr = m.ApplyLocationAreaScope(c, db, "loc.id", "a.id") + if scopeErr != nil { + return db.Where("1 = 0") + } if params.Search != "" { db = db.Where("employees.name ILIKE ?", "%"+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) + db = db.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") + return db. + Select("employees.id, employees.name, employees.is_active, employees.created_at, employees.updated_at"). + Group("employees.id, employees.name, employees.is_active, employees.created_at, employees.updated_at"). + Order("employees.created_at DESC"). + Order("employees.updated_at DESC") }) if err != nil { @@ -73,6 +141,9 @@ func (s employeesService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti } func (s employeesService) GetOne(c *fiber.Ctx, id uint) (*entity.Employees, error) { + if err := s.ensureEmployeeAccess(c, id); err != nil { + return nil, err + } 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") @@ -98,6 +169,9 @@ func (s *employeesService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent if len(kandangIDs) == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "kandang_ids must contain at least one valid id") } + if err := s.ensureKandangIDsAccess(c, kandangIDs); err != nil { + return nil, err + } if _, err := s.Repository.First(c.Context(), func(db *gorm.DB) *gorm.DB { return db.Where("LOWER(name) = ?", strings.ToLower(name)) @@ -147,6 +221,9 @@ func (s employeesService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin if err := s.Validate.Struct(req); err != nil { return nil, err } + if err := s.ensureEmployeeAccess(c, id); err != nil { + return nil, err + } updateBody := make(map[string]any) var ( @@ -181,6 +258,9 @@ func (s employeesService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin if len(ids) == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "kandang_ids must contain at least one valid id") } + if err := s.ensureKandangIDsAccess(c, ids); err != nil { + return nil, err + } kandangIDs = ids needKandangUpdate = true @@ -234,6 +314,9 @@ func (s employeesService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } func (s employeesService) DeleteOne(c *fiber.Ctx, id uint) error { + if err := s.ensureEmployeeAccess(c, id); err != nil { + return err + } if err := s.Repository.DeleteOne(c.Context(), id); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, "Employees not found") diff --git a/internal/modules/master/warehouses/controllers/warehouse.controller.go b/internal/modules/master/warehouses/controllers/warehouse.controller.go index 4e93cb52..8d1572fa 100644 --- a/internal/modules/master/warehouses/controllers/warehouse.controller.go +++ b/internal/modules/master/warehouses/controllers/warehouse.controller.go @@ -3,11 +3,13 @@ package controller import ( "math" "strconv" + "strings" "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" service "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/services" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/validations" "gitlab.com/mbugroup/lti-api.git/internal/response" + "gitlab.com/mbugroup/lti-api.git/internal/utils" "github.com/gofiber/fiber/v2" ) @@ -23,6 +25,11 @@ func NewWarehouseController(warehouseService service.WarehouseService) *Warehous } func (u *WarehouseController) GetAll(c *fiber.Ctx) error { + excludeIDs, err := parseCommaSeparatedUint(c.Query("exclude_id", "")) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + query := &validation.Query{ Page: c.QueryInt("page", 1), Limit: c.QueryInt("limit", 10), @@ -30,6 +37,8 @@ func (u *WarehouseController) GetAll(c *fiber.Ctx) error { AreaId: c.QueryInt("area_id", 0), LocationId: c.QueryInt("location_id", 0), ActiveProjectFlockOnly: c.QueryBool("active_project_flock", false), + TransferContext: c.Query(utils.TransferContextKey, ""), + ExcludeIDs: excludeIDs, } if query.Page < 1 || query.Limit < 1 { @@ -56,6 +65,28 @@ func (u *WarehouseController) GetAll(c *fiber.Ctx) error { }) } +func parseCommaSeparatedUint(raw string) ([]uint, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + + parts := strings.Split(raw, ",") + out := make([]uint, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + value, err := strconv.ParseUint(part, 10, 64) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "invalid exclude_id") + } + out = append(out, uint(value)) + } + return out, nil +} + func (u *WarehouseController) GetOne(c *fiber.Ctx) error { param := c.Params("id") diff --git a/internal/modules/master/warehouses/services/warehouse.service.go b/internal/modules/master/warehouses/services/warehouse.service.go index 63c32b86..9d6321b5 100644 --- a/internal/modules/master/warehouses/services/warehouse.service.go +++ b/internal/modules/master/warehouses/services/warehouse.service.go @@ -54,7 +54,14 @@ func (s warehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti warehouses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) - db, scopeErr = m.ApplyAreaScope(c, db, "warehouses.area_id") + applyScope := true + if params.TransferContext == utils.TransferContextInventoryTransfer { + applyScope = !m.HasPermission(c, m.P_TransferCreateOne) + } + + if applyScope { + db, scopeErr = m.ApplyLocationAreaScope(c, db, "warehouses.location_id", "warehouses.area_id") + } if params.Search != "" { db = db.Where("warehouses.name ILIKE ?", "%"+params.Search+"%") } @@ -81,6 +88,9 @@ func (s warehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti ) `, "Aktif") } + if len(params.ExcludeIDs) > 0 { + db = db.Where("warehouses.id NOT IN ?", params.ExcludeIDs) + } return db.Order("created_at DESC").Order("updated_at DESC") }) @@ -99,7 +109,7 @@ func (s warehouseService) GetOne(c *fiber.Ctx, id uint) (*entity.Warehouse, erro warehouse, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) - db, scopeErr = m.ApplyAreaScope(c, db, "warehouses.area_id") + db, scopeErr = m.ApplyLocationAreaScope(c, db, "warehouses.location_id", "warehouses.area_id") return db }) if scopeErr != nil { diff --git a/internal/modules/master/warehouses/validations/warehouse.validation.go b/internal/modules/master/warehouses/validations/warehouse.validation.go index be796082..2a2a9f87 100644 --- a/internal/modules/master/warehouses/validations/warehouse.validation.go +++ b/internal/modules/master/warehouses/validations/warehouse.validation.go @@ -23,4 +23,6 @@ type Query struct { AreaId int `query:"area_id" validate:"omitempty,number,gt=0"` LocationId int `query:"location_id" validate:"omitempty,number,gt=0"` ActiveProjectFlockOnly bool `query:"active_project_flock"` + ExcludeIDs []uint `query:"-" validate:"omitempty,dive,gt=0"` + TransferContext string `query:"transfer_context" validate:"omitempty,oneof=inventory_transfer"` } diff --git a/internal/modules/production/project_flocks/controllers/projectflock.controller.go b/internal/modules/production/project_flocks/controllers/projectflock.controller.go index 8c5a9298..3df6ad45 100644 --- a/internal/modules/production/project_flocks/controllers/projectflock.controller.go +++ b/internal/modules/production/project_flocks/controllers/projectflock.controller.go @@ -12,6 +12,7 @@ import ( service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations" "gitlab.com/mbugroup/lti-api.git/internal/response" + "gitlab.com/mbugroup/lti-api.git/internal/utils" "github.com/gofiber/fiber/v2" ) @@ -76,6 +77,7 @@ func (u *ProjectflockController) GetAll(c *fiber.Ctx) error { query.Category = category } + query.TransferContext = c.Query(utils.TransferContextKey, "") if kandangRaw := c.Query("kandang_id", c.Query("kandang_ids", "")); kandangRaw != "" { ids, err := parseUintList(kandangRaw) diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index ec271c55..96e4b6b0 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -121,6 +121,12 @@ func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e if err != nil { return nil, 0, nil, err } + if params.TransferContext == utils.TransferContextTransferToLaying { + if m.HasPermission(c, m.P_TransferToLaying_CreateOne) || m.HasPermission(c, m.P_TransferToLaying_UpdateOne) { + scope.Restrict = false + scope.IDs = nil + } + } offset := (params.Page - 1) * params.Limit diff --git a/internal/modules/production/project_flocks/validations/projectflock.validation.go b/internal/modules/production/project_flocks/validations/projectflock.validation.go index 5b2a9407..1fb48abe 100644 --- a/internal/modules/production/project_flocks/validations/projectflock.validation.go +++ b/internal/modules/production/project_flocks/validations/projectflock.validation.go @@ -12,16 +12,17 @@ type Create 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"` - SortBy string `query:"sort_by" validate:"omitempty"` - SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` - AreaId uint `query:"area_id" validate:"omitempty,number,gt=0"` - LocationId uint `query:"location_id" validate:"omitempty,number,gt=0"` - Period int `query:"period" validate:"omitempty,number,gt=0"` - Category string `query:"category" validate:"omitempty"` - KandangIds []uint `query:"kandang_id" validate:"omitempty,dive,gt=0"` + 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"` + SortBy string `query:"sort_by" validate:"omitempty"` + SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` + AreaId uint `query:"area_id" validate:"omitempty,number,gt=0"` + LocationId uint `query:"location_id" validate:"omitempty,number,gt=0"` + Period int `query:"period" validate:"omitempty,number,gt=0"` + Category string `query:"category" validate:"omitempty"` + KandangIds []uint `query:"kandang_id" validate:"omitempty,dive,gt=0"` + TransferContext string `query:"transfer_context" validate:"omitempty,oneof=transfer_to_laying"` } type Approve struct { diff --git a/internal/modules/production/transfer_layings/services/transfer_laying.service.go b/internal/modules/production/transfer_layings/services/transfer_laying.service.go index b8c71568..2f11e9a1 100644 --- a/internal/modules/production/transfer_layings/services/transfer_laying.service.go +++ b/internal/modules/production/transfer_layings/services/transfer_laying.service.go @@ -106,16 +106,32 @@ func (s transferLayingService) GetAll(c *fiber.Ctx, params *validation.Query) ([ return nil, 0, err } + scope, err := m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, 0, err + } + offset := (params.Page - 1) * params.Limit transferLayings, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + needFlockJoin := scope.Restrict || params.Search != "" + if needFlockJoin { + db = db.Joins("LEFT JOIN project_flocks AS pf_from ON laying_transfers.from_project_flock_id = pf_from.id"). + Joins("LEFT JOIN project_flocks AS pf_to ON laying_transfers.to_project_flock_id = pf_to.id") + } + + if scope.Restrict { + if len(scope.IDs) == 0 { + return db.Where("1 = 0") + } + db = db.Where("(pf_from.location_id IN ? OR pf_to.location_id IN ?)", scope.IDs, scope.IDs) + } + // Apply search and filters if params.Search != "" { searchPattern := "%" + params.Search + "%" - db = db.Joins("LEFT JOIN project_flocks AS pf_from ON laying_transfers.from_project_flock_id = pf_from.id"). - Joins("LEFT JOIN project_flocks AS pf_to ON laying_transfers.to_project_flock_id = pf_to.id"). - Where("laying_transfers.transfer_number ILIKE ? OR laying_transfers.notes ILIKE ? OR pf_from.flock_name ILIKE ? OR pf_to.flock_name ILIKE ?", - searchPattern, searchPattern, searchPattern, searchPattern) + db = db.Where("laying_transfers.transfer_number ILIKE ? OR laying_transfers.notes ILIKE ? OR pf_from.flock_name ILIKE ? OR pf_to.flock_name ILIKE ?", + searchPattern, searchPattern, searchPattern, searchPattern) } if params.TransferDate != "" { @@ -181,11 +197,10 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) if err := s.Validate.Struct(req); err != nil { return nil, err } - if err := m.EnsureProjectFlockAccess(c, s.Repository.DB(), req.SourceProjectFlockId); err != nil { - return nil, err - } - if err := m.EnsureProjectFlockAccess(c, s.Repository.DB(), req.TargetProjectFlockId); err != nil { - return nil, err + if !m.HasPermission(c, m.P_TransferToLaying_CreateOne) { + if err := m.EnsureProjectFlockAccess(c, s.Repository.DB(), req.TargetProjectFlockId); err != nil { + return nil, err + } } actorID, err := m.ActorIDFromContext(c) @@ -415,14 +430,10 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, if err := s.Validate.Struct(req); err != nil { return nil, err } - if err := m.EnsureLayingTransferAccess(c, s.Repository.DB(), id); err != nil { - return nil, err - } - if err := m.EnsureProjectFlockAccess(c, s.Repository.DB(), req.SourceProjectFlockId); err != nil { - return nil, err - } - if err := m.EnsureProjectFlockAccess(c, s.Repository.DB(), req.TargetProjectFlockId); err != nil { - return nil, err + if !m.HasPermission(c, m.P_TransferToLaying_UpdateOne) { + if err := m.EnsureProjectFlockAccess(c, s.Repository.DB(), req.TargetProjectFlockId); err != nil { + return nil, err + } } existingTransfer, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { @@ -599,6 +610,9 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, } func (s transferLayingService) DeleteOne(c *fiber.Ctx, id uint) error { + if err := m.EnsureLayingTransferAccess(c, s.Repository.DB(), id); err != nil { + return err + } _, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { return db.Preload("Sources.ProductWarehouse").Preload("Targets") @@ -669,6 +683,12 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( return nil, fiber.NewError(fiber.StatusBadRequest, "approvable_ids must contain at least one id") } + for _, approvableID := range approvableIDs { + if err := m.EnsureLayingTransferAccess(c, s.Repository.DB(), approvableID); err != nil { + return nil, err + } + } + step := utils.TransferToLayingStepPengajuan if action == entity.ApprovalActionApproved { step = utils.TransferToLayingStepDisetujui diff --git a/internal/modules/production/uniformities/services/uniformity.service.go b/internal/modules/production/uniformities/services/uniformity.service.go index e7671713..1e4ccbd5 100644 --- a/internal/modules/production/uniformities/services/uniformity.service.go +++ b/internal/modules/production/uniformities/services/uniformity.service.go @@ -92,7 +92,7 @@ func (s uniformityService) GetAll(c *fiber.Ctx, params *validation.Query) ([]ent offset := (params.Page - 1) * params.Limit uniformitys, total, err := s.Repository.GetAllWithFilters(c.Context(), offset, params.Limit, params, func(db *gorm.DB) *gorm.DB { db = db. - Joins("JOIN project_flock_kandangs pfk ON pfk.id = project_flock_kandang_uniformities.project_flock_kandang_id"). + Joins("JOIN project_flock_kandangs pfk ON pfk.id = project_flock_kandang_uniformity.project_flock_kandang_id"). Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id") db, scopeErr = m.ApplyLocationScope(c, db, "pf.location_id") return db diff --git a/internal/modules/repports/controllers/repport.controller.go b/internal/modules/repports/controllers/repport.controller.go index 54e04442..9becdf87 100644 --- a/internal/modules/repports/controllers/repport.controller.go +++ b/internal/modules/repports/controllers/repport.controller.go @@ -112,6 +112,10 @@ func (c *RepportController) GetMarketing(ctx *fiber.Ctx) error { if err != nil { return err } + areaScope, err := m.ResolveAreaScope(ctx, c.RepportService.DB()) + if err != nil { + return err + } if locationScope.Restrict { allowed := toInt64Slice(locationScope.IDs) if len(allowed) == 0 { @@ -119,6 +123,13 @@ func (c *RepportController) GetMarketing(ctx *fiber.Ctx) error { } query.AllowedLocationIDs = allowed } + if areaScope.Restrict { + allowed := toInt64Slice(areaScope.IDs) + if len(allowed) == 0 { + allowed = []int64{-1} + } + query.AllowedAreaIDs = allowed + } if query.Page < 1 || query.Limit < 1 { return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") @@ -220,6 +231,21 @@ func (c *RepportController) GetDebtSupplier(ctx *fiber.Ctx) error { SortOrder: ctx.Query("sort_order", ""), } + locationScope, err := m.ResolveLocationScope(ctx, c.RepportService.DB()) + if err != nil { + return err + } + areaScope, err := m.ResolveAreaScope(ctx, c.RepportService.DB()) + if err != nil { + return err + } + if locationScope.Restrict { + query.AllowedLocationIDs = toInt64Slice(locationScope.IDs) + } + if areaScope.Restrict { + query.AllowedAreaIDs = toInt64Slice(areaScope.IDs) + } + if query.Page < 1 || query.Limit < 1 { return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") } diff --git a/internal/modules/repports/repositories/customer_payment.repository.go b/internal/modules/repports/repositories/customer_payment.repository.go index 8a5747aa..9004882b 100644 --- a/internal/modules/repports/repositories/customer_payment.repository.go +++ b/internal/modules/repports/repositories/customer_payment.repository.go @@ -30,7 +30,7 @@ type CustomerPaymentTransaction struct { type CustomerPaymentRepository interface { GetCustomerPaymentTransactions(ctx context.Context, customerID *uint) ([]CustomerPaymentTransaction, error) GetInitialBalanceByCustomer(ctx context.Context, customerID uint) (float64, error) - GetCustomerIDsWithTransactions(ctx context.Context, limit, offset int) ([]uint, int64, error) + GetCustomerIDsWithTransactions(ctx context.Context, limit, offset int, allowedCustomerIDs []uint) ([]uint, int64, error) } type customerPaymentRepositoryImpl struct { @@ -146,7 +146,7 @@ func (r *customerPaymentRepositoryImpl) GetInitialBalanceByCustomer(ctx context. return result.Nominal, nil } -func (r *customerPaymentRepositoryImpl) GetCustomerIDsWithTransactions(ctx context.Context, limit, offset int) ([]uint, int64, error) { +func (r *customerPaymentRepositoryImpl) GetCustomerIDsWithTransactions(ctx context.Context, limit, offset int, allowedCustomerIDs []uint) ([]uint, int64, error) { subQuery := r.db.WithContext(ctx). Table("(" + "SELECT DISTINCT c.id as customer_id FROM marketing_delivery_products mdp " + @@ -161,26 +161,36 @@ func (r *customerPaymentRepositoryImpl) GetCustomerIDsWithTransactions(ctx conte "AND p.transaction_type = 'PENJUALAN' AND p.deleted_at IS NULL AND c.deleted_at IS NULL" + ") as customer_ids") + if len(allowedCustomerIDs) > 0 { + subQuery = subQuery.Where("customer_id IN ?", allowedCustomerIDs) + } + var total int64 if err := subQuery.Count(&total).Error; err != nil { return nil, 0, err } var customerIDs []uint - err := r.db.WithContext(ctx). - Table("("+ - "SELECT DISTINCT c.id as customer_id FROM marketing_delivery_products mdp "+ - "INNER JOIN marketing_products mp ON mp.id = mdp.marketing_product_id "+ - "INNER JOIN marketings m ON m.id = mp.marketing_id "+ - "INNER JOIN customers c ON c.id = m.customer_id "+ - "WHERE mdp.delivery_date IS NOT NULL AND m.deleted_at IS NULL AND c.deleted_at IS NULL "+ - "UNION "+ - "SELECT DISTINCT c.id as customer_id FROM payments p "+ - "INNER JOIN customers c ON c.id = p.party_id "+ - "WHERE p.party_type = 'CUSTOMER' AND p.direction = 'IN' "+ - "AND p.transaction_type = 'PENJUALAN' AND p.deleted_at IS NULL AND c.deleted_at IS NULL"+ + query := r.db.WithContext(ctx). + Table("(" + + "SELECT DISTINCT c.id as customer_id FROM marketing_delivery_products mdp " + + "INNER JOIN marketing_products mp ON mp.id = mdp.marketing_product_id " + + "INNER JOIN marketings m ON m.id = mp.marketing_id " + + "INNER JOIN customers c ON c.id = m.customer_id " + + "WHERE mdp.delivery_date IS NOT NULL AND m.deleted_at IS NULL AND c.deleted_at IS NULL " + + "UNION " + + "SELECT DISTINCT c.id as customer_id FROM payments p " + + "INNER JOIN customers c ON c.id = p.party_id " + + "WHERE p.party_type = 'CUSTOMER' AND p.direction = 'IN' " + + "AND p.transaction_type = 'PENJUALAN' AND p.deleted_at IS NULL AND c.deleted_at IS NULL" + ") as customer_ids"). - Select("customer_id"). + Select("customer_id") + + if len(allowedCustomerIDs) > 0 { + query = query.Where("customer_id IN ?", allowedCustomerIDs) + } + + err := query. Order("customer_id ASC"). Limit(limit). Offset(offset). diff --git a/internal/modules/repports/repositories/debt_supplier.repository.go b/internal/modules/repports/repositories/debt_supplier.repository.go index 74039ebf..fefcbade 100644 --- a/internal/modules/repports/repositories/debt_supplier.repository.go +++ b/internal/modules/repports/repositories/debt_supplier.repository.go @@ -70,6 +70,7 @@ func (r *debtSupplierRepositoryImpl) baseSupplierQuery(ctx context.Context, filt Model(&entity.Supplier{}). Joins("JOIN purchases ON purchases.supplier_id = suppliers.id"). Joins("JOIN purchase_items ON purchase_items.purchase_id = purchases.id"). + Joins("JOIN warehouses w ON w.id = purchase_items.warehouse_id"). Joins("JOIN (?) AS la ON la.approvable_id = purchases.id", r.latestPurchaseApproval(ctx)). Where("la.step_number >= ?", uint16(utils.PurchaseStepReceiving)). Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)). @@ -79,6 +80,22 @@ func (r *debtSupplierRepositoryImpl) baseSupplierQuery(ctx context.Context, filt db = db.Where("suppliers.id IN ?", filters.SupplierIDs) } + if filters.AllowedAreaIDs != nil { + if len(filters.AllowedAreaIDs) == 0 { + db = db.Where("1 = 0") + } else { + db = db.Where("w.area_id IN ?", filters.AllowedAreaIDs) + } + } + + if filters.AllowedLocationIDs != nil { + if len(filters.AllowedLocationIDs) == 0 { + db = db.Where("1 = 0") + } else { + db = db.Where("w.location_id IN ?", filters.AllowedLocationIDs) + } + } + if filters.StartDate != "" { if dateFrom, err := utils.ParseDateString(filters.StartDate); err == nil { db = db.Where(fmt.Sprintf("DATE(%s) >= ?", dateColumn), dateFrom) @@ -226,12 +243,29 @@ func (r *debtSupplierRepositoryImpl) getPurchaseIDs(ctx context.Context, supplie Table("purchases"). Select("DISTINCT purchases.id"). Joins("JOIN purchase_items ON purchase_items.purchase_id = purchases.id"). + Joins("JOIN warehouses w ON w.id = purchase_items.warehouse_id"). Joins("JOIN (?) AS la ON la.approvable_id = purchases.id", r.latestPurchaseApproval(ctx)). Where("purchases.supplier_id IN ?", supplierIDs). Where("la.step_number >= ?", uint16(utils.PurchaseStepReceiving)). Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)). Where("purchase_items.received_date IS NOT NULL") + if filters.AllowedAreaIDs != nil { + if len(filters.AllowedAreaIDs) == 0 { + db = db.Where("1 = 0") + } else { + db = db.Where("w.area_id IN ?", filters.AllowedAreaIDs) + } + } + + if filters.AllowedLocationIDs != nil { + if len(filters.AllowedLocationIDs) == 0 { + db = db.Where("1 = 0") + } else { + db = db.Where("w.location_id IN ?", filters.AllowedLocationIDs) + } + } + if filters.StartDate != "" { if dateFrom, err := utils.ParseDateString(filters.StartDate); err == nil { db = db.Where(fmt.Sprintf("DATE(%s) >= ?", dateColumn), dateFrom) diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 1ab5e7ba..531b9394 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -46,12 +46,13 @@ type RepportService interface { GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error) GetProductionResult(ctx *fiber.Ctx, params *validation.ProductionResultQuery) ([]dto.ProductionResultDTO, int64, error) GetCustomerPayment(ctx *fiber.Ctx, params *validation.CustomerPaymentQuery) ([]dto.CustomerPaymentReportItem, int64, error) + DB() *gorm.DB } type repportService struct { Log *logrus.Logger Validate *validator.Validate - DB *gorm.DB + db *gorm.DB ExpenseRealizationRepo expenseRepo.ExpenseRealizationRepository MarketingDeliveryRepo marketingRepo.MarketingDeliveryProductRepository PurchaseRepo purchaseRepo.PurchaseRepository @@ -100,7 +101,7 @@ func NewRepportService( return &repportService{ Log: utils.Log, Validate: validate, - DB: db, + db: db, ExpenseRealizationRepo: expenseRealizationRepo, MarketingDeliveryRepo: marketingDeliveryRepo, PurchaseRepo: purchaseRepo, @@ -119,6 +120,9 @@ func NewRepportService( } } +func (s *repportService) DB() *gorm.DB { + return s.db +} func (s *repportService) GetExpense(c *fiber.Ctx, params *validation.ExpenseQuery) ([]dto.RepportExpenseListDTO, int64, error) { if err := s.Validate.Struct(params); err != nil { @@ -407,11 +411,38 @@ func (s *repportService) GetCustomerPayment(ctx *fiber.Ctx, params *validation.C return nil, 0, err } + locationScope, err := m.ResolveLocationScope(ctx, s.DB()) + if err != nil { + return nil, 0, err + } + areaScope, err := m.ResolveAreaScope(ctx, s.DB()) + if err != nil { + return nil, 0, err + } + + restrictScope := locationScope.Restrict || areaScope.Restrict + var allowedCustomerIDs []uint + if restrictScope { + if (locationScope.Restrict && len(locationScope.IDs) == 0) || (areaScope.Restrict && len(areaScope.IDs) == 0) { + return []dto.CustomerPaymentReportItem{}, 0, nil + } + allowedCustomerIDs, err = s.getCustomerIDsByScope(ctx.Context(), locationScope.IDs, areaScope.IDs) + if err != nil { + return nil, 0, err + } + if len(allowedCustomerIDs) == 0 { + return []dto.CustomerPaymentReportItem{}, 0, nil + } + } + var customerIDs []uint var totalCustomers int64 if len(params.CustomerIDs) > 0 { customerIDs = params.CustomerIDs + if restrictScope { + customerIDs = intersectUint(customerIDs, allowedCustomerIDs) + } totalCustomers = int64(len(customerIDs)) if len(customerIDs) == 0 { @@ -430,7 +461,7 @@ func (s *repportService) GetCustomerPayment(ctx *fiber.Ctx, params *validation.C offset := (page - 1) * limit var err error - customerIDs, totalCustomers, err = s.CustomerPaymentRepo.GetCustomerIDsWithTransactions(ctx.Context(), limit, offset) + customerIDs, totalCustomers, err = s.CustomerPaymentRepo.GetCustomerIDsWithTransactions(ctx.Context(), limit, offset, allowedCustomerIDs) if err != nil { return nil, 0, err } @@ -456,6 +487,37 @@ func (s *repportService) GetCustomerPayment(ctx *fiber.Ctx, params *validation.C return result, totalCustomers, nil } +func (s *repportService) getCustomerIDsByScope(ctx context.Context, locationIDs, areaIDs []uint) ([]uint, error) { + if len(locationIDs) == 0 && len(areaIDs) == 0 { + return []uint{}, nil + } + + db := s.db.WithContext(ctx). + Table("customers c"). + Select("DISTINCT c.id"). + Joins("JOIN marketings m ON m.customer_id = c.id"). + Joins("JOIN marketing_products mp ON mp.marketing_id = m.id"). + Joins("JOIN marketing_delivery_products mdp ON mdp.marketing_product_id = mp.id"). + Joins("JOIN product_warehouses pw ON pw.id = mdp.product_warehouse_id"). + Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). + Where("mdp.delivery_date IS NOT NULL"). + Where("m.deleted_at IS NULL"). + Where("c.deleted_at IS NULL") + + if len(locationIDs) > 0 { + db = db.Where("w.location_id IN ?", locationIDs) + } + if len(areaIDs) > 0 { + db = db.Where("w.area_id IN ?", areaIDs) + } + + var customerIDs []uint + if err := db.Pluck("c.id", &customerIDs).Error; err != nil { + return nil, err + } + return customerIDs, nil +} + func (s *repportService) processCustomerPayment(ctx context.Context, customerID uint, params *validation.CustomerPaymentQuery) (dto.CustomerPaymentReportItem, error) { customer, err := s.CustomerRepo.GetByID(ctx, customerID, nil) @@ -803,7 +865,7 @@ func (s *repportService) getUniformityByWeek(ctx context.Context, projectFlockKa } var rows []entity.ProjectFlockKandangUniformity - if err := s.DB.WithContext(ctx). + if err := s.db.WithContext(ctx). Model(&entity.ProjectFlockKandangUniformity{}). Select("week, uniformity, uniform_date, id, chart_data"). Where("project_flock_kandang_id = ?", projectFlockKandangID). @@ -2007,6 +2069,23 @@ func intersectInt64(a, b []int64) []int64 { return out } +func intersectUint(a, b []uint) []uint { + if len(a) == 0 || len(b) == 0 { + return nil + } + set := make(map[uint]struct{}, len(b)) + for _, id := range b { + set[id] = struct{}{} + } + out := make([]uint, 0, len(a)) + for _, id := range a { + if _, ok := set[id]; ok { + out = append(out, id) + } + } + return out +} + func parseOptionalFloat64(raw string) (*float64, error) { raw = strings.TrimSpace(raw) if raw == "" { diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index de68e467..37c581d9 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -1,63 +1,66 @@ package validation type ExpenseQuery struct { - Page int `query:"page" validate:"omitempty,min=1,gt=0"` - Limit int `query:"limit" validate:"omitempty,min=1,max=100,gt=0"` - Search string `query:"search" validate:"omitempty,max=100"` - Category string `query:"category" validate:"omitempty,oneof=BOP NON-BOP"` - SupplierId int64 `query:"supplier_id" validate:"omitempty"` - KandangId int64 `query:"kandang_id" validate:"omitempty"` - ProjectFlockKandangId int64 `query:"project_flock_kandang_id" validate:"omitempty"` - ProjectFlockId int64 `query:"project_flock_id" validate:"omitempty"` - NonstockId int64 `query:"nonstock_id" validate:"omitempty"` - AreaId int64 `query:"area_id" validate:"omitempty"` - LocationId int64 `query:"location_id" validate:"omitempty"` - RealizationDate string `query:"realization_date" validate:"omitempty"` + Page int `query:"page" validate:"omitempty,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=100"` + Category string `query:"category" validate:"omitempty,oneof=BOP NON-BOP"` + SupplierId int64 `query:"supplier_id" validate:"omitempty"` + KandangId int64 `query:"kandang_id" validate:"omitempty"` + ProjectFlockKandangId int64 `query:"project_flock_kandang_id" validate:"omitempty"` + ProjectFlockId int64 `query:"project_flock_id" validate:"omitempty"` + NonstockId int64 `query:"nonstock_id" validate:"omitempty"` + AreaId int64 `query:"area_id" validate:"omitempty"` + LocationId int64 `query:"location_id" validate:"omitempty"` + RealizationDate string `query:"realization_date" validate:"omitempty"` AllowedAreaIDs []int64 `query:"-"` AllowedLocationIDs []int64 `query:"-"` } type MarketingQuery struct { - Page int `query:"page" validate:"omitempty,min=1,gt=0"` - Limit int `query:"limit" validate:"omitempty,min=1,gt=0"` - Search string `query:"search" validate:"omitempty,max=100"` - CustomerId int64 `query:"customer_id" validate:"omitempty"` - ProductId int64 `query:"product_id" validate:"omitempty"` - WarehouseId int64 `query:"warehouse_id" validate:"omitempty"` - SalesPersonId int64 `query:"sales_person_id" validate:"omitempty"` - AreaId int64 `query:"area_id" validate:"omitempty"` - LocationId int64 `query:"location_id" validate:"omitempty"` - MarketingType string `query:"marketing_type" validate:"omitempty,oneof=ayam telur trading"` - FilterBy string `query:"filter_by" validate:"omitempty,oneof= so_date realization_date"` - StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"` - EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"` - SortBy string `query:"sort_by" validate:"omitempty,oneof=so_date realization_date customer warehouse product sales_person vehicle_number sales_amount hpp_amount qty average_weight total_weight sales_price hpp_price aging_days"` - SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` + Page int `query:"page" validate:"omitempty,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,min=1,gt=0"` + Search string `query:"search" validate:"omitempty,max=100"` + CustomerId int64 `query:"customer_id" validate:"omitempty"` + ProductId int64 `query:"product_id" validate:"omitempty"` + WarehouseId int64 `query:"warehouse_id" validate:"omitempty"` + SalesPersonId int64 `query:"sales_person_id" validate:"omitempty"` + AreaId int64 `query:"area_id" validate:"omitempty"` + LocationId int64 `query:"location_id" validate:"omitempty"` + MarketingType string `query:"marketing_type" validate:"omitempty,oneof=ayam telur trading"` + FilterBy string `query:"filter_by" validate:"omitempty,oneof= so_date realization_date"` + StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"` + EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"` + SortBy string `query:"sort_by" validate:"omitempty,oneof=so_date realization_date customer warehouse product sales_person vehicle_number sales_amount hpp_amount qty average_weight total_weight sales_price hpp_price aging_days"` + SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` + AllowedAreaIDs []int64 `query:"-"` AllowedLocationIDs []int64 `query:"-"` } type PurchaseSupplierQuery struct { - Page int `query:"page" validate:"omitempty,min=1,gt=0"` - Limit int `query:"limit" validate:"omitempty,min=1,gt=0"` - AreaId int64 `query:"area_id" validate:"omitempty"` - SupplierId int64 `query:"supplier_id" validate:"omitempty"` - ProductId int64 `query:"product_id" validate:"omitempty"` - ProductCategoryId int64 `query:"product_category_id" validate:"omitempty"` - StartDate string `query:"start_date" validate:"omitempty"` - EndDate string `query:"end_date" validate:"omitempty"` - SortBy string `query:"sort_by" validate:"omitempty"` - FilterBy string `query:"filter_by" validate:"omitempty"` + Page int `query:"page" validate:"omitempty,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,min=1,gt=0"` + AreaId int64 `query:"area_id" validate:"omitempty"` + SupplierId int64 `query:"supplier_id" validate:"omitempty"` + ProductId int64 `query:"product_id" validate:"omitempty"` + ProductCategoryId int64 `query:"product_category_id" validate:"omitempty"` + StartDate string `query:"start_date" validate:"omitempty"` + EndDate string `query:"end_date" validate:"omitempty"` + SortBy string `query:"sort_by" validate:"omitempty"` + FilterBy string `query:"filter_by" validate:"omitempty"` AllowedAreaIDs []int64 `query:"-"` } type DebtSupplierQuery struct { - Page int `query:"page" validate:"omitempty,min=1,gt=0"` - Limit int `query:"limit" validate:"omitempty,min=1,gt=0"` - SupplierIDs []int64 `query:"-" validate:"omitempty,dive,gt=0"` - StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"` - EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"` - FilterBy string `query:"filter_by" validate:"omitempty,oneof=received_date po_date"` - SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` + Page int `query:"page" validate:"omitempty,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,min=1,gt=0"` + SupplierIDs []int64 `query:"-" validate:"omitempty,dive,gt=0"` + StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"` + EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"` + FilterBy string `query:"filter_by" validate:"omitempty,oneof=received_date po_date"` + SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` + AllowedAreaIDs []int64 `query:"-"` + AllowedLocationIDs []int64 `query:"-"` } type HppPerKandangQuery struct { diff --git a/internal/utils/constant.go b/internal/utils/constant.go index d27b07ef..d204ca97 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -117,6 +117,16 @@ const ( StockLogTypeRecording StockLogType = "RECORDING" ) +// ------------------------------------------------------------------- +// Transfer context +// ------------------------------------------------------------------- + +const ( + TransferContextKey = "transfer_context" + TransferContextInventoryTransfer = "inventory_transfer" + TransferContextTransferToLaying = "transfer_to_laying" +) + // ------------------------------------------------------------------- // WarehouseType // ------------------------------------------------------------------- diff --git a/test/integration/production/recordings/recording_fifo_integration_test.go b/test/integration/production/recordings/recording_fifo_integration_test.go deleted file mode 100644 index dd5f7d53..00000000 --- a/test/integration/production/recordings/recording_fifo_integration_test.go +++ /dev/null @@ -1,446 +0,0 @@ -package test - -import ( - "context" - "fmt" - "strings" - "testing" - "time" - - "github.com/glebarez/sqlite" - "gorm.io/gorm" - "gorm.io/gorm/logger" - - commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" - commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" - entity "gitlab.com/mbugroup/lti-api.git/internal/entities" - rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" - recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" - servicePkg "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services" - "gitlab.com/mbugroup/lti-api.git/internal/utils" - "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" -) - -func TestRecordingFIFO_CreatePendingWithoutStock(t *testing.T) { - db, svc, _, _ := setupRecordingFIFOTableTest(t) - ctx := context.Background() - - recordingID := uint(1) - productWarehouse := createProductWarehouseRow(t, db, 0) - stock := createRecordingStockRow(t, db, recordingID, productWarehouse.Id, 10) - - if err := svc.ConsumeRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil { - t.Fatalf("consumeRecordingStocks (pending) failed: %v", err) - } - - updated := fetchRecordingStock(t, db, stock.Id) - assertFloatEqual(t, 0, updated.UsageQty, "usage_qty should remain zero when no stock is available") - assertFloatEqual(t, 10, updated.PendingQty, "pending_qty should capture the entire request") - assertWarehouseQuantity(t, db, productWarehouse.Id, 0) - assertAllocationCount(t, db, 0) - - assertAllocationCount(t, db, 0) -} - -func TestRecordingFIFO_EditReallocatesUsage(t *testing.T) { - db, svc, fifoSvc, stockableKey := setupRecordingFIFOTableTest(t) - ctx := context.Background() - - recordingID := uint(1) - productWarehouse := createProductWarehouseRow(t, db, 0) - stock := createRecordingStockRow(t, db, recordingID, productWarehouse.Id, 10) - lot := createStockLot(t, db, productWarehouse.Id) - - if _, err := fifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ - StockableKey: stockableKey, - StockableID: lot.Id, - ProductWarehouseID: productWarehouse.Id, - Quantity: 12, - }); err != nil { - t.Fatalf("replenish failed: %v", err) - } - - if err := svc.ConsumeRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil { - t.Fatalf("consumeRecordingStocks (initial) failed: %v", err) - } - - assertWarehouseQuantity(t, db, productWarehouse.Id, 2) - - desired := 4.0 - stock.UsageQty = &desired - - if err := svc.ConsumeRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil { - t.Fatalf("consumeRecordingStocks (edit) failed: %v", err) - } - - updated := fetchRecordingStock(t, db, stock.Id) - assertFloatEqual(t, 4, updated.UsageQty, "usage_qty should reflect edited request") - assertFloatEqual(t, 0, updated.PendingQty, "pending_qty should remain zero after downsize") - assertWarehouseQuantity(t, db, productWarehouse.Id, 8) - - alloc := fetchSingleAllocation(t, db, stock.Id) - if alloc.Status != entity.StockAllocationStatusActive { - t.Fatalf("expected ACTIVE allocation, got %s", alloc.Status) - } - if mathAbs(alloc.Qty-4) > 1e-6 { - t.Fatalf("expected allocation qty 4, got %.3f", alloc.Qty) - } -} - -func TestRecordingFIFO_DeleteReleasesStock(t *testing.T) { - db, svc, fifoSvc, stockableKey := setupRecordingFIFOTableTest(t) - ctx := context.Background() - - recordingID := uint(1) - productWarehouse := createProductWarehouseRow(t, db, 0) - stock := createRecordingStockRow(t, db, recordingID, productWarehouse.Id, 10) - lot := createStockLot(t, db, productWarehouse.Id) - - if _, err := fifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ - StockableKey: stockableKey, - StockableID: lot.Id, - ProductWarehouseID: productWarehouse.Id, - Quantity: 10, - }); err != nil { - t.Fatalf("replenish failed: %v", err) - } - if err := svc.ConsumeRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil { - t.Fatalf("consumeRecordingStocks failed: %v", err) - } - - if err := svc.ReleaseRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil { - t.Fatalf("releaseRecordingStocks failed: %v", err) - } - - updated := fetchRecordingStock(t, db, stock.Id) - assertFloatEqual(t, 0, updated.UsageQty, "usage_qty should be cleared after delete") - assertFloatEqual(t, 0, updated.PendingQty, "pending_qty should be cleared after delete") - assertWarehouseQuantity(t, db, productWarehouse.Id, 10) - - alloc := fetchSingleAllocation(t, db, stock.Id) - if alloc.Status != entity.StockAllocationStatusReleased { - t.Fatalf("expected allocation to be released, got %s", alloc.Status) - } -} - -// --- helpers ---------------------------------------------------------------- - -type recordingStockTable struct { - Id uint `gorm:"primaryKey"` - RecordingId uint `gorm:"column:recording_id;not null"` - ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` - UsageQty *float64 `gorm:"column:usage_qty"` - PendingQty *float64 `gorm:"column:pending_qty"` - CreatedAt time.Time - UpdatedAt time.Time -} - -func (recordingStockTable) TableName() string { return "recording_stocks" } - -type productWarehouseTable struct { - Id uint `gorm:"primaryKey"` - ProductId uint `gorm:"column:product_id"` - WarehouseId uint `gorm:"column:warehouse_id"` - Quantity float64 `gorm:"column:quantity"` - CreatedBy uint `gorm:"column:created_by"` - CreatedAt time.Time - UpdatedAt time.Time - DeletedAt gorm.DeletedAt `gorm:"index"` -} - -func (productWarehouseTable) TableName() string { return "product_warehouses" } - -type stockAllocationTable struct { - Id uint `gorm:"primaryKey"` - ProductWarehouseId uint `gorm:"not null"` - StockableType string `gorm:"size:100"` - StockableId uint - UsableType string `gorm:"size:100"` - UsableId uint - Qty float64 `gorm:"column:qty"` - Status string `gorm:"size:20"` - Note *string `gorm:"type:text"` - CreatedAt time.Time - UpdatedAt time.Time - ReleasedAt *time.Time - DeletedAt gorm.DeletedAt `gorm:"index"` -} - -func (stockAllocationTable) TableName() string { return "stock_allocations" } - -type testStockSource struct { - Id uint `gorm:"primaryKey"` - ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` - TotalQty float64 `gorm:"column:total_qty"` - TotalUsedQty float64 `gorm:"column:total_used_qty"` - CreatedAt time.Time `gorm:"column:created_at"` - UpdatedAt time.Time -} - -func (testStockSource) TableName() string { return "test_fifo_stockables" } - -func setupRecordingFIFOTableTest(t *testing.T) (*gorm.DB, servicePkg.RecordingFIFOIntegrationService, commonSvc.FifoService, fifo.StockableKey) { - t.Helper() - - db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{ - Logger: logger.Default.LogMode(logger.Silent), - }) - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - - if err := db.AutoMigrate( - &recordingStockTable{}, - &productWarehouseTable{}, - &stockAllocationTable{}, - &testStockSource{}, - ); err != nil { - t.Fatalf("auto migrate: %v", err) - } - - if err := db.AutoMigrate( - &entity.ProductWarehouse{}, - &entity.StockAllocation{}, - &entity.RecordingStock{}, - ); err != nil { - t.Fatalf("auto migrate entities: %v", err) - } - - stockAllocRepo := newFifoTestStockAllocationRepo(db) - productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) - fifoSvc := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log) - - registerRecordingUsable(t, fifoSvc) - - key := fifo.StockableKey(fmt.Sprintf("TEST_STOCKABLE_%s_%d", sanitizeKey(t.Name()), time.Now().UnixNano())) - if err := fifoSvc.RegisterStockable(fifo.StockableConfig{ - Key: key, - Table: "test_fifo_stockables", - Columns: fifo.StockableColumns{ - ID: "id", - ProductWarehouseID: "product_warehouse_id", - TotalQuantity: "total_qty", - TotalUsedQuantity: "total_used_qty", - CreatedAt: "created_at", - }, - }); err != nil { - t.Fatalf("register stockable: %v", err) - } - - svc := servicePkg.NewRecordingFIFOIntegrationService( - recordingRepo.NewRecordingRepository(db), - productWarehouseRepo, - fifoSvc, - ) - - return db, svc, fifoSvc, key -} - -func registerRecordingUsable(t *testing.T, fifoSvc commonSvc.FifoService) { - t.Helper() - err := fifoSvc.RegisterUsable(fifo.UsableConfig{ - Key: fifo.UsableKeyRecordingStock, - Table: "recording_stocks", - Columns: fifo.UsableColumns{ - ID: "id", - ProductWarehouseID: "product_warehouse_id", - UsageQuantity: "usage_qty", - PendingQuantity: "pending_qty", - CreatedAt: "created_at", - }, - }) - if err != nil && !strings.Contains(strings.ToLower(err.Error()), "already registered") { - t.Fatalf("register usable: %v", err) - } - if _, ok := fifo.Usable(fifo.UsableKeyRecordingStock); !ok { - t.Fatal("recording stock usable key not registered") - } -} - -func createProductWarehouseRow(t *testing.T, db *gorm.DB, qty float64) entity.ProductWarehouse { - t.Helper() - pw := entity.ProductWarehouse{ - ProductId: 1, - WarehouseId: 1, - Quantity: qty, - // CreatedBy: 1, - } - if err := db.Create(&pw).Error; err != nil { - t.Fatalf("create product warehouse: %v", err) - } - return pw -} - -func createRecordingStockRow(t *testing.T, db *gorm.DB, recordingID, productWarehouseID uint, desired float64) entity.RecordingStock { - t.Helper() - stock := entity.RecordingStock{ - RecordingId: recordingID, - ProductWarehouseId: productWarehouseID, - UsageQty: floatPtr(0), - PendingQty: floatPtr(0), - } - if err := db.Create(&stock).Error; err != nil { - t.Fatalf("create recording stock: %v", err) - } - stock.UsageQty = floatPtr(desired) - return stock -} - -func createStockLot(t *testing.T, db *gorm.DB, productWarehouseID uint) testStockSource { - t.Helper() - lot := testStockSource{ - ProductWarehouseId: productWarehouseID, - CreatedAt: time.Now(), - } - if err := db.Create(&lot).Error; err != nil { - t.Fatalf("create stock lot: %v", err) - } - return lot -} - -func fetchRecordingStock(t *testing.T, db *gorm.DB, id uint) entity.RecordingStock { - t.Helper() - var stock entity.RecordingStock - if err := db.First(&stock, id).Error; err != nil { - t.Fatalf("fetch recording stock: %v", err) - } - return stock -} - -func fetchSingleAllocation(t *testing.T, db *gorm.DB, usableID uint) entity.StockAllocation { - t.Helper() - var alloc entity.StockAllocation - if err := db.Where("usable_id = ?", usableID).Order("created_at ASC").First(&alloc).Error; err != nil { - t.Fatalf("fetch allocation: %v", err) - } - return alloc -} - -func assertAllocationCount(t *testing.T, db *gorm.DB, expected int64) { - t.Helper() - var count int64 - if err := db.Model(&entity.StockAllocation{}).Count(&count).Error; err != nil { - t.Fatalf("count allocations: %v", err) - } - if count != expected { - t.Fatalf("expected %d allocations, got %d", expected, count) - } -} - -func assertWarehouseQuantity(t *testing.T, db *gorm.DB, id uint, expected float64) { - t.Helper() - var pw entity.ProductWarehouse - if err := db.First(&pw, id).Error; err != nil { - t.Fatalf("fetch product warehouse: %v", err) - } - if mathAbs(pw.Quantity-expected) > 1e-6 { - t.Fatalf("expected warehouse quantity %.3f, got %.3f", expected, pw.Quantity) - } -} - -func assertFloatEqual(t *testing.T, expected float64, value *float64, msg string) { - t.Helper() - if value == nil { - t.Fatalf("expected %s %.3f, got nil", msg, expected) - } - if mathAbs(*value-expected) > 1e-6 { - t.Fatalf("%s: expected %.3f, got %.3f", msg, expected, *value) - } -} - -func floatPtr(v float64) *float64 { - p := new(float64) - *p = v - return p -} - -func mathAbs(v float64) float64 { - if v < 0 { - return -v - } - return v -} - -func sanitizeKey(name string) string { - if name == "" { - return "CASE" - } - clean := strings.Map(func(r rune) rune { - if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { - return r - } - if r >= 'a' && r <= 'z' { - return r - 32 - } - return '_' - }, name) - return clean -} - -type fifoTestStockAllocationRepo struct { - commonRepo.StockAllocationRepository - db *gorm.DB -} - -func newFifoTestStockAllocationRepo(db *gorm.DB) commonRepo.StockAllocationRepository { - return &fifoTestStockAllocationRepo{ - StockAllocationRepository: commonRepo.NewStockAllocationRepository(db), - db: db, - } -} - -func (r *fifoTestStockAllocationRepo) PatchOne( - ctx context.Context, - id uint, - updates map[string]any, - modifier func(*gorm.DB) *gorm.DB, -) error { - base := r.db - - setClauses := make([]string, 0, len(updates)) - args := make([]any, 0, len(updates)+1) - for column, value := range updates { - colName := column - if strings.EqualFold(column, "quantity") { - colName = "qty" - } - setClauses = append(setClauses, fmt.Sprintf("%s = ?", colName)) - args = append(args, value) - } - args = append(args, id) - sql := fmt.Sprintf("UPDATE stock_allocations SET %s WHERE id = ?", strings.Join(setClauses, ", ")) - - result := base.Exec(sql, args...) - if result.Error != nil { - return result.Error - } - if result.RowsAffected == 0 { - return gorm.ErrRecordNotFound - } - return nil -} - -func (r *fifoTestStockAllocationRepo) ReleaseByUsable( - ctx context.Context, - usableType string, - usableID uint, - note *string, - modifier func(*gorm.DB) *gorm.DB, -) error { - base := r.db - - setClause := "status = ?, released_at = ?" - args := []any{entity.StockAllocationStatusReleased, time.Now()} - if note != nil { - setClause += ", note = ?" - args = append(args, *note) - } - args = append(args, usableType, usableID, entity.StockAllocationStatusActive) - sql := fmt.Sprintf( - "UPDATE stock_allocations SET %s WHERE usable_type = ? AND usable_id = ? AND status = ?", - setClause, - ) - - result := base.Exec(sql, args...) - return result.Error -} From 5621c63e9af9876f62797e52c784d22dc3a8af00 Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 27 Jan 2026 11:44:54 +0700 Subject: [PATCH 20/37] [FIX/BE-US] remove stock logs counting sapronak --- .../closings/repositories/closing.repository.go | 5 ++--- .../repositories/dashboard_stats.repository.go | 10 ++++++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index 82e6f4a7..f479306c 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -906,7 +906,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C pi.received_date, st.transfer_date, lt.transfer_date, - sl.created_at, + ast.created_at, pc.chick_in_date, r.record_datetime ) AS date, @@ -935,7 +935,6 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()). Joins("LEFT JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id"). Joins("LEFT JOIN adjustment_stocks ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyAdjustmentIn.String()). - Joins("LEFT JOIN stock_logs sl ON sl.id = ast.stock_log_id"). Joins("LEFT JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()). Joins("LEFT JOIN project_chickins pc ON pc.id = pfp.project_chickin_id"). Where("sa.status = ?", entity.StockAllocationStatusActive). @@ -951,7 +950,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C query = r.joinSapronakProductFlag(query, "p"). Group(` pw.product_id, p.name, f.name, - pi.received_date, st.transfer_date, lt.transfer_date, sl.created_at, pc.chick_in_date, r.record_datetime, + pi.received_date, st.transfer_date, lt.transfer_date, ast.created_at, pc.chick_in_date, r.record_datetime, po.po_number, st.movement_number, lt.transfer_number, ast.id, pc.id, r.id, pi.price, p.product_price `) diff --git a/internal/modules/dashboards/repositories/dashboard_stats.repository.go b/internal/modules/dashboards/repositories/dashboard_stats.repository.go index 493851e5..0662a0de 100644 --- a/internal/modules/dashboards/repositories/dashboard_stats.repository.go +++ b/internal/modules/dashboards/repositories/dashboard_stats.repository.go @@ -572,11 +572,17 @@ func (r *DashboardRepositoryImpl) GetComparisonWeeklyMetrics(ctx context.Context } var rows []ComparisonWeeklyMetric + weekExpr := `CASE + WHEN r.day IS NULL OR r.day <= 0 THEN 1 + WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17 + ELSE ((r.day - 1) / 7 + 1) + END` + db := r.DB().WithContext(ctx). Table("recordings AS r"). - Select(fmt.Sprintf(`(CASE WHEN r.day IS NULL OR r.day <= 0 THEN 1 ELSE ((r.day - 1) / 7 + 1) END) AS week, + Select(fmt.Sprintf(`%s AS week, %s AS series_id, - COALESCE(AVG(%s), 0) AS value`, seriesExpr, metricExpr)). + COALESCE(AVG(%s), 0) AS value`, weekExpr, seriesExpr, metricExpr)). Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id"). From ba20394a101df9bfd807d640001dbfedd33bc546 Mon Sep 17 00:00:00 2001 From: giovanni Date: Tue, 27 Jan 2026 12:27:25 +0700 Subject: [PATCH 21/37] fix closing data produksi sales --- internal/common/repository/common.hpp.repository.go | 2 +- internal/modules/closings/services/closing.service.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/common/repository/common.hpp.repository.go b/internal/common/repository/common.hpp.repository.go index da4b1908..97ad3800 100644 --- a/internal/common/repository/common.hpp.repository.go +++ b/internal/common/repository/common.hpp.repository.go @@ -253,7 +253,7 @@ func (r *HppRepositoryImpl) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangI Joins("JOIN marketing_delivery_products mdp ON mdp.id = sa.usable_id"). Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). Where("r.record_datetime <= ?", *endDate). - Where("mdp.delivery_date = ?", *startDate) + Where("mdp.delivery_date <= ?", *startDate) var totals struct { TotalPieces float64 diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 5494a835..43042b9b 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -839,7 +839,7 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint // FeedUsedPerHead: feedUsedPerHead, } - chickenFlagNames := []string{string(utils.FlagPullet)} + chickenFlagNames := []string{string(utils.FlagPullet), string(utils.FlagAyamAfkir), string(utils.FlagAyamCulling)} chickenSalesWeight, chickenSalesQty, chickenSalesPrice, err := s.Repository.SumMarketingWeightAndQtyByProjectFlockKandangIDsAndFlagNames(c.Context(), projectFlockKandangIDs, chickenFlagNames) if err != nil { s.Log.Errorf("Failed to fetch chicken sales data for project flock %d: %+v", projectFlockID, err) From ae7e53ac1f4a69d394b9f81cc199616a145ce29a Mon Sep 17 00:00:00 2001 From: giovanni Date: Tue, 27 Jan 2026 13:26:04 +0700 Subject: [PATCH 22/37] add flag layer --- internal/modules/closings/services/closing.service.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 43042b9b..ddc1a192 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -839,7 +839,7 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint // FeedUsedPerHead: feedUsedPerHead, } - chickenFlagNames := []string{string(utils.FlagPullet), string(utils.FlagAyamAfkir), string(utils.FlagAyamCulling)} + chickenFlagNames := []string{string(utils.FlagPullet), string(utils.FlagAyamAfkir), string(utils.FlagAyamCulling), string(utils.FlagLayer)} chickenSalesWeight, chickenSalesQty, chickenSalesPrice, err := s.Repository.SumMarketingWeightAndQtyByProjectFlockKandangIDsAndFlagNames(c.Context(), projectFlockKandangIDs, chickenFlagNames) if err != nil { s.Log.Errorf("Failed to fetch chicken sales data for project flock %d: %+v", projectFlockID, err) From f3ddd7997409d62330a35f4b343bd986d963719c Mon Sep 17 00:00:00 2001 From: "Hafizh A. Y" Date: Tue, 27 Jan 2026 14:52:32 +0700 Subject: [PATCH 23/37] fix(BE): sorting by date approval get all --- .../common/service/common.approval.service.go | 9 +- .../controllers/approval.controller.go | 11 + .../validations/approval.validation.go | 1 + .../closings/services/closing.service.go | 4 +- .../transfer_fifo_integration_test.go | 304 ------------ .../recording_fifo_integration_test.go | 446 ------------------ 6 files changed, 21 insertions(+), 754 deletions(-) delete mode 100644 test/integration/inventory/transfers/transfer_fifo_integration_test.go delete mode 100644 test/integration/production/recordings/recording_fifo_integration_test.go diff --git a/internal/common/service/common.approval.service.go b/internal/common/service/common.approval.service.go index 569a7cc6..c509c22b 100644 --- a/internal/common/service/common.approval.service.go +++ b/internal/common/service/common.approval.service.go @@ -15,7 +15,7 @@ type ApprovalService interface { WorkflowSteps(workflow approvalutils.ApprovalWorkflowKey) map[approvalutils.ApprovalStep]string WorkflowStepName(workflow approvalutils.ApprovalWorkflowKey, step approvalutils.ApprovalStep) (string, bool) CreateApproval(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableID uint, step approvalutils.ApprovalStep, action *entity.ApprovalAction, actorID uint, note *string) (*entity.Approval, error) - List(ctx context.Context, module string, approvableID *uint, page, limit int, search string) ([]entity.Approval, int64, error) + List(ctx context.Context, module string, approvableID *uint, page, limit int, search string, sortByDate string) ([]entity.Approval, int64, error) ListByTarget(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableID uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.Approval, error) LatestByTarget(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableID uint, modifier func(*gorm.DB) *gorm.DB) (*entity.Approval, error) LatestByTargets(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableIDs []uint, modifier func(*gorm.DB) *gorm.DB) (map[uint]*entity.Approval, error) @@ -70,9 +70,14 @@ func (s *approvalService) List( approvableID *uint, page, limit int, search string, + sortByDate string, ) ([]entity.Approval, int64, error) { module = strings.TrimSpace(strings.ToUpper(module)) search = strings.TrimSpace(search) + sortByDate = strings.TrimSpace(strings.ToUpper(sortByDate)) + if sortByDate != "ASC" && sortByDate != "DESC" { + sortByDate = "DESC" + } if limit <= 0 { limit = 10 @@ -90,7 +95,7 @@ func (s *approvalService) List( func(db *gorm.DB) *gorm.DB { query := db. Where("approvable_type = ?", module). - Order("action_at DESC"). + Order("action_at " + sortByDate). Preload("ActionUser") if approvableID != nil { diff --git a/internal/modules/approvals/controllers/approval.controller.go b/internal/modules/approvals/controllers/approval.controller.go index 94a66afd..b7d6b870 100644 --- a/internal/modules/approvals/controllers/approval.controller.go +++ b/internal/modules/approvals/controllers/approval.controller.go @@ -44,6 +44,15 @@ func (u *ApprovalController) GetAll(c *fiber.Ctx) error { page := c.QueryInt("page", 1) limit := c.QueryInt("limit", 10) search := strings.TrimSpace(c.Query("search", "")) + sortByDate := strings.TrimSpace(c.Query("sort_by_date", "")) + if sortByDate == "" { + sortByDate = "DESC" + } else { + sortByDate = strings.ToUpper(sortByDate) + if sortByDate != "ASC" && sortByDate != "DESC" { + return fiber.NewError(fiber.StatusBadRequest, "sort_by_date must be either ASC or DESC") + } + } query := &validation.Query{ ModuleName: moduleName, @@ -52,6 +61,7 @@ func (u *ApprovalController) GetAll(c *fiber.Ctx) error { Page: page, Limit: limit, Search: search, + SortByDate: sortByDate, } records, totalResults, err := u.ApprovalService.List( @@ -61,6 +71,7 @@ func (u *ApprovalController) GetAll(c *fiber.Ctx) error { query.Page, query.Limit, query.Search, + query.SortByDate, ) if err != nil { return err diff --git a/internal/modules/approvals/validations/approval.validation.go b/internal/modules/approvals/validations/approval.validation.go index 7338550e..51ec7fa1 100644 --- a/internal/modules/approvals/validations/approval.validation.go +++ b/internal/modules/approvals/validations/approval.validation.go @@ -7,4 +7,5 @@ type Query struct { Page int `query:"page" validate:"omitempty,number,min=1"` Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` Search string `query:"search" validate:"omitempty,max=50"` + SortByDate string `query:"sort_by_date" validate:"omitempty,oneof=ASC DESC"` } diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 5494a835..4b87243c 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -264,7 +264,7 @@ func (s closingService) getClosingSummaryByKandang(ctx context.Context, projectF statusProject := "Belum Selesai" var approvalDate string if s.ApprovalSvc != nil { - records, _, err := s.ApprovalSvc.List(ctx, utils.ApprovalWorkflowProjectFlockKandang.String(), &kandang.Id, 1, 1000, "") + records, _, err := s.ApprovalSvc.List(ctx, utils.ApprovalWorkflowProjectFlockKandang.String(), &kandang.Id, 1, 1000, "", "") if err != nil { s.Log.Errorf("Failed to fetch approvals for project flock kandang %d: %+v", kandang.Id, err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch approval data") @@ -542,7 +542,7 @@ func (s closingService) getApprovalStatuses(ctx context.Context, projectFlockID return "", "Belum Selesai", nil } - records, _, err := s.ApprovalSvc.List(ctx, utils.ApprovalWorkflowProjectFlock.String(), &projectFlockID, 1, 1000, "") + records, _, err := s.ApprovalSvc.List(ctx, utils.ApprovalWorkflowProjectFlock.String(), &projectFlockID, 1, 1000, "", "") if err != nil { return "", "", err } diff --git a/test/integration/inventory/transfers/transfer_fifo_integration_test.go b/test/integration/inventory/transfers/transfer_fifo_integration_test.go deleted file mode 100644 index d9f127a1..00000000 --- a/test/integration/inventory/transfers/transfer_fifo_integration_test.go +++ /dev/null @@ -1,304 +0,0 @@ -package test - -import ( - "context" - "math" - "strings" - "testing" - - "github.com/glebarez/sqlite" - "gorm.io/gorm" - "gorm.io/gorm/logger" - - commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" - commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" - entity "gitlab.com/mbugroup/lti-api.git/internal/entities" - rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" - "gitlab.com/mbugroup/lti-api.git/internal/utils" - "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" -) - -// Test Transfer FIFO with Purchase as initial stockable -func TestTransferFIFO_PurchaseToTransfer(t *testing.T) { - db, fifoSvc := setupTransferFIFOTest(t) - ctx := context.Background() - - // Setup warehouses - sourcePW := createProductWarehouseRow(t, db, 100) // 100 qty from purchase - destPW := createProductWarehouseRow(t, db, 0) // 0 qty initially - - // Step 1: Simulate Purchase - Replenish stock to source warehouse - purchaseStockableKey := fifo.StockableKey("PURCHASE_ITEMS") - if _, err := fifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ - StockableKey: purchaseStockableKey, - StockableID: 1, // PurchaseItem ID - ProductWarehouseID: sourcePW.Id, - Quantity: 100, - }); err != nil { - t.Fatalf("Failed to replenish from purchase: %v", err) - } - - // Verify source warehouse has stock - assertWarehouseQuantity(t, db, sourcePW.Id, 100) - assertAllocationCount(t, db, 1) // 1 allocation from purchase - - // Step 2: Create Transfer - will consume from source (usable) and replenish to dest (stockable) - - // Register Transfer as Usable (source warehouse - STOCK_TRANSFER_OUT) - transferUsableKey := fifo.UsableKey("STOCK_TRANSFER_OUT") - if err := fifoSvc.RegisterUsable(fifo.UsableConfig{ - Key: transferUsableKey, - Table: "stock_transfer_details", - Columns: fifo.UsableColumns{ - ID: "id", - ProductWarehouseID: "source_product_warehouse_id", - UsageQuantity: "usage_qty", - PendingQuantity: "pending_qty", - CreatedAt: "created_at", - }, - }); err != nil && !strings.Contains(strings.ToLower(err.Error()), "already registered") { - t.Fatalf("Failed to register STOCK_TRANSFER_OUT as Usable: %v", err) - } - - // Register Transfer as Stockable (destination warehouse - STOCK_TRANSFER_IN) - transferStockableKey := fifo.StockableKey("STOCK_TRANSFER_IN") - if err := fifoSvc.RegisterStockable(fifo.StockableConfig{ - Key: transferStockableKey, - Table: "stock_transfer_details", - Columns: fifo.StockableColumns{ - ID: "id", - ProductWarehouseID: "dest_product_warehouse_id", - TotalQuantity: "total_qty", - TotalUsedQuantity: "total_used", - CreatedAt: "created_at", - }, - }); err != nil && !strings.Contains(strings.ToLower(err.Error()), "already registered") { - t.Fatalf("Failed to register STOCK_TRANSFER_IN as Stockable: %v", err) - } - - // Create transfer detail record - transferDetail := entity.StockTransferDetail{ - Id: 1, - StockTransferId: 1, - ProductId: 1, - SourceProductWarehouseID: uint64Ptr(uint64(sourcePW.Id)), - DestProductWarehouseID: uint64Ptr(uint64(destPW.Id)), - UsageQty: 0, - PendingQty: 0, - TotalQty: 0, - TotalUsed: 0, - } - transferDetailID := uint(transferDetail.Id) - if err := db.Create(&transferDetail).Error; err != nil { - t.Fatalf("Failed to create transfer detail: %v", err) - } - - transferQty := 50.0 - - // Consume from source warehouse (STOCK_TRANSFER_OUT) - consumeResult, err := fifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ - UsableKey: "STOCK_TRANSFER_OUT", - UsableID: transferDetailID, - ProductWarehouseID: sourcePW.Id, - Quantity: transferQty, - AllowPending: false, // Don't allow pending - }) - if err != nil { - t.Fatalf("Failed to consume from source warehouse: %v", err) - } - - // Verify consumption - if mathAbs(consumeResult.UsageQuantity-transferQty) > 1e-6 { - t.Fatalf("Expected usage quantity %.2f, got %.2f", transferQty, consumeResult.UsageQuantity) - } - if mathAbs(consumeResult.PendingQuantity) > 1e-6 { - t.Fatalf("Expected pending quantity 0, got %.2f", consumeResult.PendingQuantity) - } - - // Update transfer detail usable fields - if err := db.Model(&entity.StockTransferDetail{}). - Where("id = ?", transferDetail.Id). - Updates(map[string]interface{}{ - "usage_qty": consumeResult.UsageQuantity, - "pending_qty": consumeResult.PendingQuantity, - }).Error; err != nil { - t.Fatalf("Failed to update transfer detail usable fields: %v", err) - } - - // Verify source warehouse decreased - assertWarehouseQuantity(t, db, sourcePW.Id, 50) // 100 - 50 = 50 - - // Verify allocation updated - should have 50 allocated to transfer - allocations := fetchAllocationsByUsable(t, db, "STOCK_TRANSFER_OUT", transferDetailID) - if len(allocations) != 1 { - t.Fatalf("Expected 1 allocation, got %d", len(allocations)) - } - if mathAbs(allocations[0].Qty-transferQty) > 1e-6 { - t.Fatalf("Expected allocation qty %.2f, got %.2f", transferQty, allocations[0].Qty) - } - - // Replenish to destination warehouse (STOCK_TRANSFER_IN) - note := "Transfer #1" - replenishResult, err := fifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ - StockableKey: "STOCK_TRANSFER_IN", - StockableID: transferDetailID, - ProductWarehouseID: destPW.Id, - Quantity: transferQty, - Note: ¬e, - }) - if err != nil { - t.Fatalf("Failed to replenish to destination warehouse: %v", err) - } - - // Verify replenishment - if mathAbs(replenishResult.AddedQuantity-transferQty) > 1e-6 { - t.Fatalf("Expected added quantity %.2f, got %.2f", transferQty, replenishResult.AddedQuantity) - } - - // Update transfer detail stockable fields - if err := db.Model(&entity.StockTransferDetail{}). - Where("id = ?", transferDetail.Id). - Updates(map[string]interface{}{ - "total_qty": replenishResult.AddedQuantity, - }).Error; err != nil { - t.Fatalf("Failed to update transfer detail stockable fields: %v", err) - } - - // Verify destination warehouse increased - assertWarehouseQuantity(t, db, destPW.Id, transferQty) - - // Verify new stockable allocation created - stockableAllocations := fetchAllocationsByStockable(t, db, "STOCK_TRANSFER_IN", transferDetailID) - if len(stockableAllocations) != 1 { - t.Fatalf("Expected 1 stockable allocation, got %d", len(stockableAllocations)) - } - if mathAbs(stockableAllocations[0].Qty-transferQty) > 1e-6 { - t.Fatalf("Expected stockable allocation qty %.2f, got %.2f", transferQty, stockableAllocations[0].Qty) - } - - t.Logf("✅ Transfer FIFO test passed:") - t.Logf(" - Source warehouse: 100 → 50 (consumed %d)", int(transferQty)) - t.Logf(" - Destination warehouse: 0 → %d (replenished)", int(transferQty)) - t.Logf(" - Usable allocation: %.2f allocated to transfer", allocations[0].Qty) - t.Logf(" - Stockable allocation: %.2f available at destination", stockableAllocations[0].Qty) -} - -// Setup function for transfer FIFO test -func setupTransferFIFOTest(t *testing.T) (*gorm.DB, commonSvc.FifoService) { - t.Helper() - - db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{ - Logger: logger.Default.LogMode(logger.Silent), - }) - if err != nil { - t.Fatalf("open db: %v", err) - } - - if err := db.AutoMigrate( - &entity.ProductWarehouse{}, - &entity.StockAllocation{}, - &entity.StockTransferDetail{}, - ); err != nil { - t.Fatalf("auto migrate entities: %v", err) - } - - stockAllocRepo := commonRepo.NewStockAllocationRepository(db) - productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) - fifoSvc := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log) - - // Register Purchase as Stockable - purchaseStockableKey := fifo.StockableKey("PURCHASE_ITEMS") - if err := fifoSvc.RegisterStockable(fifo.StockableConfig{ - Key: purchaseStockableKey, - Table: "purchase_items", - Columns: fifo.StockableColumns{ - ID: "id", - ProductWarehouseID: "product_warehouse_id", - TotalQuantity: "total_qty", - TotalUsedQuantity: "total_used", - CreatedAt: "created_at", - }, - }); err != nil && !strings.Contains(strings.ToLower(err.Error()), "already registered") { - t.Fatalf("register purchase stockable: %v", err) - } - - return db, fifoSvc -} - -// Helper functions - -func createProductWarehouseRow(t *testing.T, db *gorm.DB, qty float64) entity.ProductWarehouse { - t.Helper() - pw := entity.ProductWarehouse{ - ProductId: 1, - WarehouseId: 1, - Quantity: qty, - } - if err := db.Create(&pw).Error; err != nil { - t.Fatalf("create product warehouse: %v", err) - } - return pw -} - -func assertWarehouseQuantity(t *testing.T, db *gorm.DB, pwID uint, expected float64) { - t.Helper() - var pw entity.ProductWarehouse - if err := db.First(&pw, pwID).Error; err != nil { - t.Fatalf("fetch product warehouse %d: %v", pwID, err) - } - if mathAbs(pw.Quantity-expected) > 1e-6 { - t.Fatalf("expected warehouse quantity %.2f, got %.2f", expected, pw.Quantity) - } -} - -func assertAllocationCount(t *testing.T, db *gorm.DB, expected int) { - t.Helper() - var count int64 - if err := db.Model(&entity.StockAllocation{}).Count(&count).Error; err != nil { - t.Fatalf("count allocations: %v", err) - } - if int(count) != expected { - t.Fatalf("expected %d allocations, got %d", expected, count) - } -} - -func fetchAllocationsByUsable(t *testing.T, db *gorm.DB, usableType string, usableID uint) []entity.StockAllocation { - t.Helper() - var allocations []entity.StockAllocation - if err := db.Where("usable_type = ? AND usable_id = ?", usableType, usableID). - Find(&allocations).Error; err != nil { - t.Fatalf("fetch allocations by usable: %v", err) - } - return allocations -} - -func fetchAllocationsByStockable(t *testing.T, db *gorm.DB, stockableType string, stockableID uint) []entity.StockAllocation { - t.Helper() - var allocations []entity.StockAllocation - if err := db.Where("stockable_type = ? AND stockable_id = ?", stockableType, stockableID). - Find(&allocations).Error; err != nil { - t.Fatalf("fetch allocations by stockable: %v", err) - } - return allocations -} - -func floatPtr(f float64) *float64 { - return &f -} - -func uint64Ptr(u uint64) *uint64 { - return &u -} - -func mathAbs(f float64) float64 { - return math.Abs(f) -} - -func sanitizeKey(name string) string { - return strings.Map(func(r rune) rune { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { - return r - } - return '_' - }, name) -} diff --git a/test/integration/production/recordings/recording_fifo_integration_test.go b/test/integration/production/recordings/recording_fifo_integration_test.go deleted file mode 100644 index dd5f7d53..00000000 --- a/test/integration/production/recordings/recording_fifo_integration_test.go +++ /dev/null @@ -1,446 +0,0 @@ -package test - -import ( - "context" - "fmt" - "strings" - "testing" - "time" - - "github.com/glebarez/sqlite" - "gorm.io/gorm" - "gorm.io/gorm/logger" - - commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" - commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" - entity "gitlab.com/mbugroup/lti-api.git/internal/entities" - rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" - recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" - servicePkg "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services" - "gitlab.com/mbugroup/lti-api.git/internal/utils" - "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" -) - -func TestRecordingFIFO_CreatePendingWithoutStock(t *testing.T) { - db, svc, _, _ := setupRecordingFIFOTableTest(t) - ctx := context.Background() - - recordingID := uint(1) - productWarehouse := createProductWarehouseRow(t, db, 0) - stock := createRecordingStockRow(t, db, recordingID, productWarehouse.Id, 10) - - if err := svc.ConsumeRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil { - t.Fatalf("consumeRecordingStocks (pending) failed: %v", err) - } - - updated := fetchRecordingStock(t, db, stock.Id) - assertFloatEqual(t, 0, updated.UsageQty, "usage_qty should remain zero when no stock is available") - assertFloatEqual(t, 10, updated.PendingQty, "pending_qty should capture the entire request") - assertWarehouseQuantity(t, db, productWarehouse.Id, 0) - assertAllocationCount(t, db, 0) - - assertAllocationCount(t, db, 0) -} - -func TestRecordingFIFO_EditReallocatesUsage(t *testing.T) { - db, svc, fifoSvc, stockableKey := setupRecordingFIFOTableTest(t) - ctx := context.Background() - - recordingID := uint(1) - productWarehouse := createProductWarehouseRow(t, db, 0) - stock := createRecordingStockRow(t, db, recordingID, productWarehouse.Id, 10) - lot := createStockLot(t, db, productWarehouse.Id) - - if _, err := fifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ - StockableKey: stockableKey, - StockableID: lot.Id, - ProductWarehouseID: productWarehouse.Id, - Quantity: 12, - }); err != nil { - t.Fatalf("replenish failed: %v", err) - } - - if err := svc.ConsumeRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil { - t.Fatalf("consumeRecordingStocks (initial) failed: %v", err) - } - - assertWarehouseQuantity(t, db, productWarehouse.Id, 2) - - desired := 4.0 - stock.UsageQty = &desired - - if err := svc.ConsumeRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil { - t.Fatalf("consumeRecordingStocks (edit) failed: %v", err) - } - - updated := fetchRecordingStock(t, db, stock.Id) - assertFloatEqual(t, 4, updated.UsageQty, "usage_qty should reflect edited request") - assertFloatEqual(t, 0, updated.PendingQty, "pending_qty should remain zero after downsize") - assertWarehouseQuantity(t, db, productWarehouse.Id, 8) - - alloc := fetchSingleAllocation(t, db, stock.Id) - if alloc.Status != entity.StockAllocationStatusActive { - t.Fatalf("expected ACTIVE allocation, got %s", alloc.Status) - } - if mathAbs(alloc.Qty-4) > 1e-6 { - t.Fatalf("expected allocation qty 4, got %.3f", alloc.Qty) - } -} - -func TestRecordingFIFO_DeleteReleasesStock(t *testing.T) { - db, svc, fifoSvc, stockableKey := setupRecordingFIFOTableTest(t) - ctx := context.Background() - - recordingID := uint(1) - productWarehouse := createProductWarehouseRow(t, db, 0) - stock := createRecordingStockRow(t, db, recordingID, productWarehouse.Id, 10) - lot := createStockLot(t, db, productWarehouse.Id) - - if _, err := fifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ - StockableKey: stockableKey, - StockableID: lot.Id, - ProductWarehouseID: productWarehouse.Id, - Quantity: 10, - }); err != nil { - t.Fatalf("replenish failed: %v", err) - } - if err := svc.ConsumeRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil { - t.Fatalf("consumeRecordingStocks failed: %v", err) - } - - if err := svc.ReleaseRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil { - t.Fatalf("releaseRecordingStocks failed: %v", err) - } - - updated := fetchRecordingStock(t, db, stock.Id) - assertFloatEqual(t, 0, updated.UsageQty, "usage_qty should be cleared after delete") - assertFloatEqual(t, 0, updated.PendingQty, "pending_qty should be cleared after delete") - assertWarehouseQuantity(t, db, productWarehouse.Id, 10) - - alloc := fetchSingleAllocation(t, db, stock.Id) - if alloc.Status != entity.StockAllocationStatusReleased { - t.Fatalf("expected allocation to be released, got %s", alloc.Status) - } -} - -// --- helpers ---------------------------------------------------------------- - -type recordingStockTable struct { - Id uint `gorm:"primaryKey"` - RecordingId uint `gorm:"column:recording_id;not null"` - ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` - UsageQty *float64 `gorm:"column:usage_qty"` - PendingQty *float64 `gorm:"column:pending_qty"` - CreatedAt time.Time - UpdatedAt time.Time -} - -func (recordingStockTable) TableName() string { return "recording_stocks" } - -type productWarehouseTable struct { - Id uint `gorm:"primaryKey"` - ProductId uint `gorm:"column:product_id"` - WarehouseId uint `gorm:"column:warehouse_id"` - Quantity float64 `gorm:"column:quantity"` - CreatedBy uint `gorm:"column:created_by"` - CreatedAt time.Time - UpdatedAt time.Time - DeletedAt gorm.DeletedAt `gorm:"index"` -} - -func (productWarehouseTable) TableName() string { return "product_warehouses" } - -type stockAllocationTable struct { - Id uint `gorm:"primaryKey"` - ProductWarehouseId uint `gorm:"not null"` - StockableType string `gorm:"size:100"` - StockableId uint - UsableType string `gorm:"size:100"` - UsableId uint - Qty float64 `gorm:"column:qty"` - Status string `gorm:"size:20"` - Note *string `gorm:"type:text"` - CreatedAt time.Time - UpdatedAt time.Time - ReleasedAt *time.Time - DeletedAt gorm.DeletedAt `gorm:"index"` -} - -func (stockAllocationTable) TableName() string { return "stock_allocations" } - -type testStockSource struct { - Id uint `gorm:"primaryKey"` - ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` - TotalQty float64 `gorm:"column:total_qty"` - TotalUsedQty float64 `gorm:"column:total_used_qty"` - CreatedAt time.Time `gorm:"column:created_at"` - UpdatedAt time.Time -} - -func (testStockSource) TableName() string { return "test_fifo_stockables" } - -func setupRecordingFIFOTableTest(t *testing.T) (*gorm.DB, servicePkg.RecordingFIFOIntegrationService, commonSvc.FifoService, fifo.StockableKey) { - t.Helper() - - db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{ - Logger: logger.Default.LogMode(logger.Silent), - }) - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - - if err := db.AutoMigrate( - &recordingStockTable{}, - &productWarehouseTable{}, - &stockAllocationTable{}, - &testStockSource{}, - ); err != nil { - t.Fatalf("auto migrate: %v", err) - } - - if err := db.AutoMigrate( - &entity.ProductWarehouse{}, - &entity.StockAllocation{}, - &entity.RecordingStock{}, - ); err != nil { - t.Fatalf("auto migrate entities: %v", err) - } - - stockAllocRepo := newFifoTestStockAllocationRepo(db) - productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) - fifoSvc := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log) - - registerRecordingUsable(t, fifoSvc) - - key := fifo.StockableKey(fmt.Sprintf("TEST_STOCKABLE_%s_%d", sanitizeKey(t.Name()), time.Now().UnixNano())) - if err := fifoSvc.RegisterStockable(fifo.StockableConfig{ - Key: key, - Table: "test_fifo_stockables", - Columns: fifo.StockableColumns{ - ID: "id", - ProductWarehouseID: "product_warehouse_id", - TotalQuantity: "total_qty", - TotalUsedQuantity: "total_used_qty", - CreatedAt: "created_at", - }, - }); err != nil { - t.Fatalf("register stockable: %v", err) - } - - svc := servicePkg.NewRecordingFIFOIntegrationService( - recordingRepo.NewRecordingRepository(db), - productWarehouseRepo, - fifoSvc, - ) - - return db, svc, fifoSvc, key -} - -func registerRecordingUsable(t *testing.T, fifoSvc commonSvc.FifoService) { - t.Helper() - err := fifoSvc.RegisterUsable(fifo.UsableConfig{ - Key: fifo.UsableKeyRecordingStock, - Table: "recording_stocks", - Columns: fifo.UsableColumns{ - ID: "id", - ProductWarehouseID: "product_warehouse_id", - UsageQuantity: "usage_qty", - PendingQuantity: "pending_qty", - CreatedAt: "created_at", - }, - }) - if err != nil && !strings.Contains(strings.ToLower(err.Error()), "already registered") { - t.Fatalf("register usable: %v", err) - } - if _, ok := fifo.Usable(fifo.UsableKeyRecordingStock); !ok { - t.Fatal("recording stock usable key not registered") - } -} - -func createProductWarehouseRow(t *testing.T, db *gorm.DB, qty float64) entity.ProductWarehouse { - t.Helper() - pw := entity.ProductWarehouse{ - ProductId: 1, - WarehouseId: 1, - Quantity: qty, - // CreatedBy: 1, - } - if err := db.Create(&pw).Error; err != nil { - t.Fatalf("create product warehouse: %v", err) - } - return pw -} - -func createRecordingStockRow(t *testing.T, db *gorm.DB, recordingID, productWarehouseID uint, desired float64) entity.RecordingStock { - t.Helper() - stock := entity.RecordingStock{ - RecordingId: recordingID, - ProductWarehouseId: productWarehouseID, - UsageQty: floatPtr(0), - PendingQty: floatPtr(0), - } - if err := db.Create(&stock).Error; err != nil { - t.Fatalf("create recording stock: %v", err) - } - stock.UsageQty = floatPtr(desired) - return stock -} - -func createStockLot(t *testing.T, db *gorm.DB, productWarehouseID uint) testStockSource { - t.Helper() - lot := testStockSource{ - ProductWarehouseId: productWarehouseID, - CreatedAt: time.Now(), - } - if err := db.Create(&lot).Error; err != nil { - t.Fatalf("create stock lot: %v", err) - } - return lot -} - -func fetchRecordingStock(t *testing.T, db *gorm.DB, id uint) entity.RecordingStock { - t.Helper() - var stock entity.RecordingStock - if err := db.First(&stock, id).Error; err != nil { - t.Fatalf("fetch recording stock: %v", err) - } - return stock -} - -func fetchSingleAllocation(t *testing.T, db *gorm.DB, usableID uint) entity.StockAllocation { - t.Helper() - var alloc entity.StockAllocation - if err := db.Where("usable_id = ?", usableID).Order("created_at ASC").First(&alloc).Error; err != nil { - t.Fatalf("fetch allocation: %v", err) - } - return alloc -} - -func assertAllocationCount(t *testing.T, db *gorm.DB, expected int64) { - t.Helper() - var count int64 - if err := db.Model(&entity.StockAllocation{}).Count(&count).Error; err != nil { - t.Fatalf("count allocations: %v", err) - } - if count != expected { - t.Fatalf("expected %d allocations, got %d", expected, count) - } -} - -func assertWarehouseQuantity(t *testing.T, db *gorm.DB, id uint, expected float64) { - t.Helper() - var pw entity.ProductWarehouse - if err := db.First(&pw, id).Error; err != nil { - t.Fatalf("fetch product warehouse: %v", err) - } - if mathAbs(pw.Quantity-expected) > 1e-6 { - t.Fatalf("expected warehouse quantity %.3f, got %.3f", expected, pw.Quantity) - } -} - -func assertFloatEqual(t *testing.T, expected float64, value *float64, msg string) { - t.Helper() - if value == nil { - t.Fatalf("expected %s %.3f, got nil", msg, expected) - } - if mathAbs(*value-expected) > 1e-6 { - t.Fatalf("%s: expected %.3f, got %.3f", msg, expected, *value) - } -} - -func floatPtr(v float64) *float64 { - p := new(float64) - *p = v - return p -} - -func mathAbs(v float64) float64 { - if v < 0 { - return -v - } - return v -} - -func sanitizeKey(name string) string { - if name == "" { - return "CASE" - } - clean := strings.Map(func(r rune) rune { - if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { - return r - } - if r >= 'a' && r <= 'z' { - return r - 32 - } - return '_' - }, name) - return clean -} - -type fifoTestStockAllocationRepo struct { - commonRepo.StockAllocationRepository - db *gorm.DB -} - -func newFifoTestStockAllocationRepo(db *gorm.DB) commonRepo.StockAllocationRepository { - return &fifoTestStockAllocationRepo{ - StockAllocationRepository: commonRepo.NewStockAllocationRepository(db), - db: db, - } -} - -func (r *fifoTestStockAllocationRepo) PatchOne( - ctx context.Context, - id uint, - updates map[string]any, - modifier func(*gorm.DB) *gorm.DB, -) error { - base := r.db - - setClauses := make([]string, 0, len(updates)) - args := make([]any, 0, len(updates)+1) - for column, value := range updates { - colName := column - if strings.EqualFold(column, "quantity") { - colName = "qty" - } - setClauses = append(setClauses, fmt.Sprintf("%s = ?", colName)) - args = append(args, value) - } - args = append(args, id) - sql := fmt.Sprintf("UPDATE stock_allocations SET %s WHERE id = ?", strings.Join(setClauses, ", ")) - - result := base.Exec(sql, args...) - if result.Error != nil { - return result.Error - } - if result.RowsAffected == 0 { - return gorm.ErrRecordNotFound - } - return nil -} - -func (r *fifoTestStockAllocationRepo) ReleaseByUsable( - ctx context.Context, - usableType string, - usableID uint, - note *string, - modifier func(*gorm.DB) *gorm.DB, -) error { - base := r.db - - setClause := "status = ?, released_at = ?" - args := []any{entity.StockAllocationStatusReleased, time.Now()} - if note != nil { - setClause += ", note = ?" - args = append(args, *note) - } - args = append(args, usableType, usableID, entity.StockAllocationStatusActive) - sql := fmt.Sprintf( - "UPDATE stock_allocations SET %s WHERE usable_type = ? AND usable_id = ? AND status = ?", - setClause, - ) - - result := base.Exec(sql, args...) - return result.Error -} From 77043005dddd3edcd6ac6ff697ac1e006b905ad2 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 27 Jan 2026 14:53:51 +0700 Subject: [PATCH 24/37] [FIX]BE: fixing penjualan ayam tidak mucnul di in closing penjualan --- .../closings/services/closing.service.go | 7 +-- .../services/closingKeuangan.service.go | 4 +- .../salesorder_delivery_product.repository.go | 43 ++++++++++++++++++- 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 5494a835..02942f44 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -162,12 +162,7 @@ func (s closingService) GetProjectFlockByID(c *fiber.Ctx, id uint) (*entity.Proj func (s closingService) GetPenjualan(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error) { - projectFlock, err := s.ProjectFlockRepo.GetByID(c.Context(), projectFlockID, nil) - if err != nil { - return nil, err - } - - realisasi, err := s.MarketingDeliveryProductRepo.GetClosingPenjualan(c.Context(), projectFlockID, projectFlockKandangID, projectFlock.Category) + realisasi, err := s.MarketingDeliveryProductRepo.GetClosingPenjualan(c.Context(), projectFlockID, projectFlockKandangID) if err != nil { return nil, err } diff --git a/internal/modules/closings/services/closingKeuangan.service.go b/internal/modules/closings/services/closingKeuangan.service.go index 85aa5f1c..ca76c67e 100644 --- a/internal/modules/closings/services/closingKeuangan.service.go +++ b/internal/modules/closings/services/closingKeuangan.service.go @@ -270,9 +270,9 @@ func (s closingKeuanganService) calculateProductionData(c *fiber.Ctx, projectFlo var deliveryProducts []entity.MarketingDeliveryProduct if projectFlockKandangID != nil { - deliveryProducts, err = s.MarketingDeliveryProductRepo.GetClosingPenjualan(c.Context(), projectFlock.Id, projectFlockKandangID, projectFlock.Category) + deliveryProducts, err = s.MarketingDeliveryProductRepo.GetClosingPenjualanByCategory(c.Context(), projectFlock.Id, projectFlockKandangID, projectFlock.Category) } else { - deliveryProducts, err = s.MarketingDeliveryProductRepo.GetClosingPenjualan(c.Context(), projectFlock.Id, nil, projectFlock.Category) + deliveryProducts, err = s.MarketingDeliveryProductRepo.GetClosingPenjualanByCategory(c.Context(), projectFlock.Id, nil, projectFlock.Category) } if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data penjualan") diff --git a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go index bcd788cd..6f638ac6 100644 --- a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go +++ b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go @@ -14,7 +14,8 @@ import ( type MarketingDeliveryProductRepository interface { repository.BaseRepository[entity.MarketingDeliveryProduct] GetDeliveryProductsByProjectFlockID(ctx context.Context, projectFlockID uint, callback func(*gorm.DB) *gorm.DB) ([]entity.MarketingDeliveryProduct, error) - GetClosingPenjualan(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint, category string) ([]entity.MarketingDeliveryProduct, error) + GetClosingPenjualan(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error) + GetClosingPenjualanByCategory(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint, category string) ([]entity.MarketingDeliveryProduct, error) GetByMarketingId(ctx context.Context, marketingId uint) ([]entity.MarketingDeliveryProduct, error) GetByMarketingProductID(ctx context.Context, marketingProductID uint) (*entity.MarketingDeliveryProduct, error) GetAllWithFilters(ctx context.Context, offset, limit int, filters *validation.MarketingQuery) ([]entity.MarketingDeliveryProduct, int64, error) @@ -54,7 +55,45 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetDeliveryProductsByProjectFlo return deliveryProducts, nil } -func (r *MarketingDeliveryProductRepositoryImpl) GetClosingPenjualan(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint, category string) ([]entity.MarketingDeliveryProduct, error) { +func (r *MarketingDeliveryProductRepositoryImpl) GetClosingPenjualan(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error) { + var deliveryProducts []entity.MarketingDeliveryProduct + + db := r.DB().WithContext(ctx). + Joins("JOIN marketing_products ON marketing_products.id = marketing_delivery_products.marketing_product_id"). + Joins("JOIN product_warehouses ON product_warehouses.id = marketing_products.product_warehouse_id"). + Joins("JOIN products ON products.id = product_warehouses.product_id"). + Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = product_warehouses.project_flock_kandang_id"). + Where("project_flock_kandangs.project_flock_id = ?", projectFlockID). + Where("marketing_delivery_products.delivery_date IS NOT NULL"). + Distinct("marketing_delivery_products.*") + + if projectFlockKandangID != nil { + db = db.Where("product_warehouses.project_flock_kandang_id = ?", *projectFlockKandangID) + } + + db = db. + Preload("MarketingProduct"). + Preload("MarketingProduct.ProductWarehouse"). + Preload("MarketingProduct.ProductWarehouse.Product"). + Preload("MarketingProduct.ProductWarehouse.Product.ProductCategory"). + Preload("MarketingProduct.ProductWarehouse.Product.Uom"). + Preload("MarketingProduct.ProductWarehouse.Product.Flags"). + Preload("MarketingProduct.ProductWarehouse.Warehouse"). + Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang"). + Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Kandang"). + Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Chickins"). + Preload("MarketingProduct.Marketing"). + Preload("MarketingProduct.Marketing.Customer"). + Order("marketing_delivery_products.delivery_date DESC") + + if err := db.Find(&deliveryProducts).Error; err != nil { + return nil, err + } + + return deliveryProducts, nil +} + +func (r *MarketingDeliveryProductRepositoryImpl) GetClosingPenjualanByCategory(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint, category string) ([]entity.MarketingDeliveryProduct, error) { var deliveryProducts []entity.MarketingDeliveryProduct db := r.DB().WithContext(ctx). From 37f0324e2a7d71b498f553b6ed790791581b5867 Mon Sep 17 00:00:00 2001 From: "Hafizh A. Y" Date: Tue, 27 Jan 2026 15:30:44 +0700 Subject: [PATCH 25/37] fix(BE): change name from sort to order --- .../common/service/common.approval.service.go | 12 ++++++------ .../approvals/controllers/approval.controller.go | 16 ++++++++-------- .../approvals/validations/approval.validation.go | 2 +- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/internal/common/service/common.approval.service.go b/internal/common/service/common.approval.service.go index c509c22b..90db5830 100644 --- a/internal/common/service/common.approval.service.go +++ b/internal/common/service/common.approval.service.go @@ -15,7 +15,7 @@ type ApprovalService interface { WorkflowSteps(workflow approvalutils.ApprovalWorkflowKey) map[approvalutils.ApprovalStep]string WorkflowStepName(workflow approvalutils.ApprovalWorkflowKey, step approvalutils.ApprovalStep) (string, bool) CreateApproval(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableID uint, step approvalutils.ApprovalStep, action *entity.ApprovalAction, actorID uint, note *string) (*entity.Approval, error) - List(ctx context.Context, module string, approvableID *uint, page, limit int, search string, sortByDate string) ([]entity.Approval, int64, error) + List(ctx context.Context, module string, approvableID *uint, page, limit int, search string, orderByDate string) ([]entity.Approval, int64, error) ListByTarget(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableID uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.Approval, error) LatestByTarget(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableID uint, modifier func(*gorm.DB) *gorm.DB) (*entity.Approval, error) LatestByTargets(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableIDs []uint, modifier func(*gorm.DB) *gorm.DB) (map[uint]*entity.Approval, error) @@ -70,13 +70,13 @@ func (s *approvalService) List( approvableID *uint, page, limit int, search string, - sortByDate string, + orderByDate string, ) ([]entity.Approval, int64, error) { module = strings.TrimSpace(strings.ToUpper(module)) search = strings.TrimSpace(search) - sortByDate = strings.TrimSpace(strings.ToUpper(sortByDate)) - if sortByDate != "ASC" && sortByDate != "DESC" { - sortByDate = "DESC" + orderByDate = strings.TrimSpace(strings.ToUpper(orderByDate)) + if orderByDate != "ASC" && orderByDate != "DESC" { + orderByDate = "DESC" } if limit <= 0 { @@ -95,7 +95,7 @@ func (s *approvalService) List( func(db *gorm.DB) *gorm.DB { query := db. Where("approvable_type = ?", module). - Order("action_at " + sortByDate). + Order("action_at " + orderByDate). Preload("ActionUser") if approvableID != nil { diff --git a/internal/modules/approvals/controllers/approval.controller.go b/internal/modules/approvals/controllers/approval.controller.go index b7d6b870..1ec32ff2 100644 --- a/internal/modules/approvals/controllers/approval.controller.go +++ b/internal/modules/approvals/controllers/approval.controller.go @@ -44,13 +44,13 @@ func (u *ApprovalController) GetAll(c *fiber.Ctx) error { page := c.QueryInt("page", 1) limit := c.QueryInt("limit", 10) search := strings.TrimSpace(c.Query("search", "")) - sortByDate := strings.TrimSpace(c.Query("sort_by_date", "")) - if sortByDate == "" { - sortByDate = "DESC" + orderByDate := strings.TrimSpace(c.Query("order_by_date", "")) + if orderByDate == "" { + orderByDate = "DESC" } else { - sortByDate = strings.ToUpper(sortByDate) - if sortByDate != "ASC" && sortByDate != "DESC" { - return fiber.NewError(fiber.StatusBadRequest, "sort_by_date must be either ASC or DESC") + orderByDate = strings.ToUpper(orderByDate) + if orderByDate != "ASC" && orderByDate != "DESC" { + return fiber.NewError(fiber.StatusBadRequest, "order_by_date must be either ASC or DESC") } } @@ -61,7 +61,7 @@ func (u *ApprovalController) GetAll(c *fiber.Ctx) error { Page: page, Limit: limit, Search: search, - SortByDate: sortByDate, + OrderByDate: orderByDate, } records, totalResults, err := u.ApprovalService.List( @@ -71,7 +71,7 @@ func (u *ApprovalController) GetAll(c *fiber.Ctx) error { query.Page, query.Limit, query.Search, - query.SortByDate, + query.OrderByDate, ) if err != nil { return err diff --git a/internal/modules/approvals/validations/approval.validation.go b/internal/modules/approvals/validations/approval.validation.go index 51ec7fa1..bd067d71 100644 --- a/internal/modules/approvals/validations/approval.validation.go +++ b/internal/modules/approvals/validations/approval.validation.go @@ -7,5 +7,5 @@ type Query struct { Page int `query:"page" validate:"omitempty,number,min=1"` Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` Search string `query:"search" validate:"omitempty,max=50"` - SortByDate string `query:"sort_by_date" validate:"omitempty,oneof=ASC DESC"` + OrderByDate string `query:"order_by_date" validate:"omitempty,oneof=ASC DESC"` } From 6cac4f0243374189ab80cb9da2bd76d03653ecc2 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 27 Jan 2026 16:57:36 +0700 Subject: [PATCH 26/37] [FEAT] Update StockTransferDelivery and TransferDeliveryDTO to allow optional SupplierId --- internal/entities/stock_transfer_delivery.go | 2 +- .../inventory/transfers/dto/transfer.dto.go | 52 +++++++++++-------- .../transfers/services/transfer.service.go | 19 ++++++- .../validations/transfer.validation.go | 2 +- 4 files changed, 50 insertions(+), 25 deletions(-) diff --git a/internal/entities/stock_transfer_delivery.go b/internal/entities/stock_transfer_delivery.go index 0eeccc04..f7ca8a30 100644 --- a/internal/entities/stock_transfer_delivery.go +++ b/internal/entities/stock_transfer_delivery.go @@ -6,7 +6,7 @@ import "time" type StockTransferDelivery struct { Id uint64 `gorm:"primaryKey;autoIncrement"` StockTransferId uint64 - SupplierId uint64 + SupplierId *uint64 VehiclePlate string DriverName string DocumentNumber string diff --git a/internal/modules/inventory/transfers/dto/transfer.dto.go b/internal/modules/inventory/transfers/dto/transfer.dto.go index 73b1a66c..1d29c48d 100644 --- a/internal/modules/inventory/transfers/dto/transfer.dto.go +++ b/internal/modules/inventory/transfers/dto/transfer.dto.go @@ -9,12 +9,12 @@ import ( ) type TransferRelationDTO struct { - Id uint64 `json:"id"` - MovementNumber string `json:"movement_number"` - TransferReason string `json:"transfer_reason"` - TransferDate string `json:"transfer_date"` - SourceWarehouse *warehouseDTO.WarehouseRelationDTO `json:"source_warehouse,omitempty"` - DestinationWarehouse *warehouseDTO.WarehouseRelationDTO `json:"destination_warehouse,omitempty"` + Id uint64 `json:"id"` + MovementNumber string `json:"movement_number"` + TransferReason string `json:"transfer_reason"` + TransferDate string `json:"transfer_date"` + SourceWarehouse *warehouseDTO.WarehouseRelationDTO `json:"source_warehouse,omitempty"` + DestinationWarehouse *warehouseDTO.WarehouseRelationDTO `json:"destination_warehouse,omitempty"` } type ProductSimpleDTO struct { @@ -51,16 +51,16 @@ type TransferDetailDTO struct { } type TransferDetailItemDTO struct { - Id uint64 `json:"id"` - Product ProductSimpleDTO `json:"product"` - Quantity float64 `json:"quantity"` - TransportPerItem *float64 `json:"transport_per_item,omitempty"` // Biaya ekspedisi per item - ExpeditionVendor *SupplierSimpleDTO `json:"expedition_vendor,omitempty"` // Vendor ekspedisi + Id uint64 `json:"id"` + Product ProductSimpleDTO `json:"product"` + Quantity float64 `json:"quantity"` + TransportPerItem *float64 `json:"transport_per_item,omitempty"` // Biaya ekspedisi per item + ExpeditionVendor *SupplierSimpleDTO `json:"expedition_vendor,omitempty"` // Vendor ekspedisi } type TransferDeliveryDTO struct { Id uint64 `json:"id"` - Supplier SupplierSimpleDTO `json:"supplier"` + Supplier *SupplierSimpleDTO `json:"supplier,omitempty"` VehiclePlate string `json:"vehicle_plate"` DriverName string `json:"driver_name"` DocumentNumber string `json:"document_number"` @@ -115,7 +115,6 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { Quantity: d.UsageQty + d.PendingQty, // Total actual quantity allocated } - if d.ExpenseNonstock != nil { priceCopy := d.ExpenseNonstock.Price detailDTO.TransportPerItem = &priceCopy @@ -155,12 +154,17 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { } } - deliveries = append(deliveries, TransferDeliveryDTO{ - Id: del.Id, - Supplier: SupplierSimpleDTO{ + var supplier *SupplierSimpleDTO + if del.Supplier != nil { + supplier = &SupplierSimpleDTO{ Id: del.Supplier.Id, Name: del.Supplier.Name, - }, + } + } + + deliveries = append(deliveries, TransferDeliveryDTO{ + Id: del.Id, + Supplier: supplier, VehiclePlate: del.VehiclePlate, DriverName: del.DriverName, DocumentNumber: del.DocumentNumber, @@ -201,7 +205,6 @@ func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { Quantity: d.UsageQty + d.PendingQty, // Total actual quantity allocated } - if d.ExpenseNonstock != nil { priceCopy := d.ExpenseNonstock.Price detailDTO.TransportPerItem = &priceCopy @@ -241,12 +244,17 @@ func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { } } - deliveries = append(deliveries, TransferDeliveryDTO{ - Id: del.Id, - Supplier: SupplierSimpleDTO{ + var supplier *SupplierSimpleDTO + if del.Supplier != nil { + supplier = &SupplierSimpleDTO{ Id: del.Supplier.Id, Name: del.Supplier.Name, - }, + } + } + + deliveries = append(deliveries, TransferDeliveryDTO{ + Id: del.Id, + Supplier: supplier, VehiclePlate: del.VehiclePlate, DriverName: del.DriverName, DocumentNumber: del.DocumentNumber, diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index fe5f8f5a..8278ce9f 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -196,6 +196,11 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } for _, delivery := range req.Deliveries { + // Skip supplier validation if SupplierID is 0 (optional) + if delivery.SupplierID == 0 { + continue + } + supplier, err := s.SupplierRepo.GetByID(c.Context(), uint(delivery.SupplierID), nil) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -310,9 +315,16 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques var deliveries []*entity.StockTransferDelivery for _, delivery := range req.Deliveries { + supplierId := func() *uint64 { + if delivery.SupplierID > 0 { + id := uint64(delivery.SupplierID) + return &id + } + return nil + }() deliveries = append(deliveries, &entity.StockTransferDelivery{ StockTransferId: entityTransfer.Id, - SupplierId: uint64(delivery.SupplierID), + SupplierId: supplierId, VehiclePlate: delivery.VehiclePlate, DriverName: delivery.DriverName, ShippingCostItem: delivery.DeliveryCostPerItem, @@ -458,6 +470,11 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques if len(req.Deliveries) > 0 { for _, delivery := range req.Deliveries { + // Skip adding to expensePayloads if SupplierID is 0 (optional) + if delivery.SupplierID == 0 { + continue + } + for _, prod := range delivery.Products { detail := detailMap[uint64(prod.ProductID)] if detail == nil { diff --git a/internal/modules/inventory/transfers/validations/transfer.validation.go b/internal/modules/inventory/transfers/validations/transfer.validation.go index 785295e2..e2f357f3 100644 --- a/internal/modules/inventory/transfers/validations/transfer.validation.go +++ b/internal/modules/inventory/transfers/validations/transfer.validation.go @@ -26,7 +26,7 @@ type TransferDelivery struct { DocumentIndex int `json:"document_index" validate:"omitempty,min=-1" default:"-1"` DriverName string `json:"driver_name" validate:"required"` VehiclePlate string `json:"vehicle_plate" validate:"required"` - SupplierID uint `json:"supplier_id" validate:"required"` + SupplierID uint `json:"supplier_id" ` Products []TransferDeliveryProduct `json:"products" validate:"required,dive"` } From e503a84660ebc9d515d49ce880f96f08402a94ab Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 27 Jan 2026 20:43:01 +0700 Subject: [PATCH 27/37] [FEAT] Add stock logging for transfer laying process and introduce new StockLogType for transfer laying --- .../services/transfer_laying.service.go | 43 ++++++++++++++++--- internal/utils/constant.go | 13 +++--- 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/internal/modules/production/transfer_layings/services/transfer_laying.service.go b/internal/modules/production/transfer_layings/services/transfer_laying.service.go index a5d0ba88..3aa4788b 100644 --- a/internal/modules/production/transfer_layings/services/transfer_laying.service.go +++ b/internal/modules/production/transfer_layings/services/transfer_laying.service.go @@ -650,9 +650,9 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( repoTx := s.Repository.WithTx(dbTransaction) approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) - // Gunakan repo baru untuk transaction scope agar bisa akses method custom sourceRepoTx := repository.NewLayingTransferSourceRepository(dbTransaction) targetRepoTx := repository.NewLayingTransferTargetRepository(dbTransaction) + stockLogRepoTx := rStockLogs.NewStockLogRepository(dbTransaction) for _, approvableID := range approvableIDs { transfer, err := repoTx.GetByID(c.Context(), approvableID, nil) @@ -687,23 +687,28 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil targets transfer") } - // Hitung total quantity dari targets untuk di-consume dari sources totalTargetQty := 0.0 for _, target := range targets { totalTargetQty += target.TotalQty } - // Consume dari laying_transfer_sources (Usable) - akan consume dari ProjectFlockPopulation (Stockable) + totalSourceRequested := 0.0 + for _, source := range sources { + totalSourceRequested += source.RequestedQty + } + for _, source := range sources { if source.ProductWarehouseId == nil { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Source product warehouse tidak ditemukan untuk transfer %d", approvableID)) } + sourceShare := (source.RequestedQty / totalSourceRequested) * totalTargetQty + consumeResult, err := s.FifoSvc.Consume(c.Context(), commonSvc.StockConsumeRequest{ UsableKey: fifo.UsableKeyTransferToLayingOut, UsableID: source.Id, ProductWarehouseID: *source.ProductWarehouseId, - Quantity: totalTargetQty, + Quantity: sourceShare, AllowPending: false, Tx: dbTransaction, }) @@ -717,6 +722,19 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( }, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Gagal update source usage qty") } + + stockLogDecrease := &entity.StockLog{ + ProductWarehouseId: *source.ProductWarehouseId, + CreatedBy: actorID, + Increase: 0, + Decrease: sourceShare, + LoggableType: string(utils.StockLogTypeTransferLaying), + LoggableId: approvableID, + Notes: fmt.Sprintf("TL #%s", transfer.TransferNumber), + } + if err := stockLogRepoTx.CreateOne(c.Context(), stockLogDecrease, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar") + } } for _, target := range targets { @@ -725,7 +743,7 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( } note := fmt.Sprintf("Transfer to Laying #%s", transfer.TransferNumber) - replenishResult, err := s.FifoSvc.Replenish(c.Context(), commonSvc.StockReplenishRequest{ + _, err := s.FifoSvc.Replenish(c.Context(), commonSvc.StockReplenishRequest{ StockableKey: fifo.StockableKeyTransferToLayingIn, StockableID: target.Id, ProductWarehouseID: *target.ProductWarehouseId, @@ -738,10 +756,23 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( } if err := targetRepoTx.PatchOne(c.Context(), target.Id, map[string]interface{}{ - "total_qty": replenishResult.AddedQuantity, + "total_qty": target.TotalQty, }, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Gagal update target total qty") } + + stockLogIncrease := &entity.StockLog{ + ProductWarehouseId: *target.ProductWarehouseId, + CreatedBy: actorID, + Increase: target.TotalQty, + Decrease: 0, + LoggableType: string(utils.StockLogTypeTransferLaying), + LoggableId: approvableID, + Notes: fmt.Sprintf("TL #%s", transfer.TransferNumber), + } + if err := stockLogRepoTx.CreateOne(c.Context(), stockLogIncrease, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok masuk") + } } } } diff --git a/internal/utils/constant.go b/internal/utils/constant.go index d27b07ef..cb8586ac 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -109,12 +109,13 @@ const ( type StockLogType string const ( - StockLogTypeAdjustment StockLogType = "ADJUSTMENT" - StockLogTypeTransfer StockLogType = "TRANSFER" - StockLogTypeMarketing StockLogType = "MARKETING" - StockLogTypeChikin StockLogType = "CHICKIN" - StockLogTypePurchase StockLogType = "PURCHASE" - StockLogTypeRecording StockLogType = "RECORDING" + StockLogTypeAdjustment StockLogType = "ADJUSTMENT" + StockLogTypeTransfer StockLogType = "TRANSFER" + StockLogTypeTransferLaying StockLogType = "TRANSFER_LAYING" + StockLogTypeMarketing StockLogType = "MARKETING" + StockLogTypeChikin StockLogType = "CHICKIN" + StockLogTypePurchase StockLogType = "PURCHASE" + StockLogTypeRecording StockLogType = "RECORDING" ) // ------------------------------------------------------------------- From 095080320c470d2f641d247d1f958d63bd526223 Mon Sep 17 00:00:00 2001 From: giovanni Date: Tue, 27 Jan 2026 21:20:17 +0700 Subject: [PATCH 28/37] add migration for change type data id tables module daily checklist --- ...pe_data_column_id_daily_checklist.down.sql | 55 ++++++++++++++++++ ...type_data_column_id_daily_checklist.up.sql | 58 +++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 internal/database/migrations/20260127114036_change_type_data_column_id_daily_checklist.down.sql create mode 100644 internal/database/migrations/20260127114036_change_type_data_column_id_daily_checklist.up.sql diff --git a/internal/database/migrations/20260127114036_change_type_data_column_id_daily_checklist.down.sql b/internal/database/migrations/20260127114036_change_type_data_column_id_daily_checklist.down.sql new file mode 100644 index 00000000..dccb2ae4 --- /dev/null +++ b/internal/database/migrations/20260127114036_change_type_data_column_id_daily_checklist.down.sql @@ -0,0 +1,55 @@ +BEGIN; + +DO $$ +DECLARE + t text; + seq_name text; +BEGIN + FOREACH t IN ARRAY ARRAY[ + 'daily_checklist_activity_task_assignments', + 'daily_checklist_activity_tasks', + 'daily_checklist_phases', + 'daily_checklist_tasks', + 'employee_kandangs', + 'employees', + 'phase_activities', + 'phases' + ] + LOOP + -- Sequence name convention + seq_name := format('public.%I_id_seq', t); + + -- 1) Drop default nextval (bigserial behavior) + EXECUTE format( + 'ALTER TABLE public.%I ALTER COLUMN id DROP DEFAULT', + t + ); + + -- 2) Add IDENTITY back (BY DEFAULT is safer for rollback) + EXECUTE format( + 'ALTER TABLE public.%I ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY', + t + ); + + -- 3) Detach & optionally drop sequence (safe) + IF EXISTS ( + SELECT 1 FROM pg_class + WHERE relkind = 'S' + AND relname = t || '_id_seq' + ) THEN + EXECUTE format( + 'ALTER SEQUENCE %s OWNED BY NONE', + seq_name + ); + + -- Optional: drop sequence (comment if you want to keep it) + EXECUTE format( + 'DROP SEQUENCE IF EXISTS %s', + seq_name + ); + END IF; + + END LOOP; +END $$; + +COMMIT; diff --git a/internal/database/migrations/20260127114036_change_type_data_column_id_daily_checklist.up.sql b/internal/database/migrations/20260127114036_change_type_data_column_id_daily_checklist.up.sql new file mode 100644 index 00000000..9c7ce8f7 --- /dev/null +++ b/internal/database/migrations/20260127114036_change_type_data_column_id_daily_checklist.up.sql @@ -0,0 +1,58 @@ +BEGIN; + +DO $$ +DECLARE + t text; + seq_name text; + max_id bigint; +BEGIN + FOREACH t IN ARRAY ARRAY[ + 'daily_checklist_activity_task_assignments', + 'daily_checklist_activity_tasks', + 'daily_checklist_phases', + 'daily_checklist_tasks', + 'employee_kandangs', + 'employees', + 'phase_activities', + 'phases' + ] + LOOP + -- Sequence name convention: public._id_seq + seq_name := format('public.%I_id_seq', t); + + -- Drop IDENTITY only if the column is identity (safe to re-run) + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = t + AND column_name = 'id' + AND is_identity = 'YES' + ) THEN + EXECUTE format('ALTER TABLE public.%I ALTER COLUMN id DROP IDENTITY', t); + END IF; + + -- Ensure sequence exists + EXECUTE format('CREATE SEQUENCE IF NOT EXISTS %s', seq_name); + + -- Set default like bigserial + EXECUTE format( + 'ALTER TABLE public.%I ALTER COLUMN id SET DEFAULT nextval(''%s'')', + t, seq_name + ); + + -- Own the sequence by the column + EXECUTE format( + 'ALTER SEQUENCE %s OWNED BY public.%I.id', + seq_name, t + ); + + -- Sync sequence to MAX(id) + 1 to avoid duplicate key + EXECUTE format('SELECT COALESCE(MAX(id), 0) FROM public.%I', t) INTO max_id; + + EXECUTE format('SELECT setval(''%s'', $1, false)', seq_name) + USING (max_id + 1); + END LOOP; +END $$; + +COMMIT; From 8410573ee6c28b90a3da568be76e0d72dbcb3ae8 Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 27 Jan 2026 22:28:14 +0700 Subject: [PATCH 29/37] [FIX/BE-US] add transfer to laying filter location and area --- .../laying_transfer.repository.go | 21 +++++++++++++++++++ .../services/transfer_laying.service.go | 12 ++++++----- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go b/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go index 14fa4118..ebf63252 100644 --- a/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go +++ b/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go @@ -52,6 +52,8 @@ type GetAllFilterParams struct { FlockSource []uint FlockDestination []uint Status []string + LocationIDs []uint + LocationRestrict bool } func (r *TransferLayingRepositoryImpl) GetAllWithFilters(ctx context.Context, offset int, limit int, params *GetAllFilterParams) ([]entity.LayingTransfer, int64, error) { @@ -110,6 +112,25 @@ func (r *TransferLayingRepositoryImpl) GetAllWithFilters(ctx context.Context, of } } + if params.LocationRestrict { + if len(params.LocationIDs) == 0 { + q = q.Where("1 = 0") + } else { + q = q.Where( + `EXISTS ( + SELECT 1 FROM project_flocks pf + WHERE pf.id = laying_transfers.from_project_flock_id + AND pf.location_id IN ? + ) OR EXISTS ( + SELECT 1 FROM project_flocks pf + WHERE pf.id = laying_transfers.to_project_flock_id + AND pf.location_id IN ? + )`, + params.LocationIDs, params.LocationIDs, + ) + } + } + if err := q.Count(&total).Error; err != nil { return nil, 0, err } diff --git a/internal/modules/production/transfer_layings/services/transfer_laying.service.go b/internal/modules/production/transfer_layings/services/transfer_laying.service.go index 60096547..9c586064 100644 --- a/internal/modules/production/transfer_layings/services/transfer_laying.service.go +++ b/internal/modules/production/transfer_layings/services/transfer_laying.service.go @@ -107,6 +107,11 @@ func (s transferLayingService) GetAll(c *fiber.Ctx, params *validation.Query) ([ return nil, 0, err } + scope, err := m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, 0, err + } + offset := (params.Page - 1) * params.Limit filterParams := &repository.GetAllFilterParams{ @@ -116,6 +121,8 @@ func (s transferLayingService) GetAll(c *fiber.Ctx, params *validation.Query) ([ FlockSource: params.FlockSource, FlockDestination: params.FlockDestination, Status: params.Status, + LocationIDs: scope.IDs, + LocationRestrict: scope.Restrict, } transferLayings, total, err := s.Repository.GetAllWithFilters(c.Context(), offset, params.Limit, filterParams) @@ -124,11 +131,6 @@ func (s transferLayingService) GetAll(c *fiber.Ctx, params *validation.Query) ([ return nil, 0, err } - if err != nil { - s.Log.Errorf("Failed to get transferLayings: %+v", err) - return nil, 0, err - } - approvalRepo := commonRepo.NewApprovalRepository(s.Repository.DB()) for i, transfer := range transferLayings { latestApproval, err := approvalRepo.LatestByTarget(c.Context(), string(utils.ApprovalWorkflowTransferToLaying), transfer.Id, func(db *gorm.DB) *gorm.DB { From 3d70701d033051cea542583868e7ef2c665cff9b Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 28 Jan 2026 09:21:44 +0700 Subject: [PATCH 30/37] [FIX] Update validation for Query limit to remove max constraint --- .../transfer_layings/validations/transfer_laying.validation.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/modules/production/transfer_layings/validations/transfer_laying.validation.go b/internal/modules/production/transfer_layings/validations/transfer_laying.validation.go index 0472ba39..a2fef4a1 100644 --- a/internal/modules/production/transfer_layings/validations/transfer_laying.validation.go +++ b/internal/modules/production/transfer_layings/validations/transfer_laying.validation.go @@ -30,7 +30,7 @@ type Update struct { 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"` + Limit int `query:"limit" validate:"omitempty,number,min=1,gt=0"` Search string `query:"search" validate:"omitempty"` StartDate string `query:"start_date" validate:"omitempty"` EndDate string `query:"end_date" validate:"omitempty"` From 3d39d6d31e93fca735ae2adba09423ca50b8b743 Mon Sep 17 00:00:00 2001 From: giovanni Date: Wed, 28 Jan 2026 09:46:34 +0700 Subject: [PATCH 31/37] adjust validation select phase --- ...036_change_type_data_column_id_daily_checklist.down.sql | 1 + ...14036_change_type_data_column_id_daily_checklist.up.sql | 1 + .../modules/closings/repositories/closing.repository.go | 7 ++++--- .../validations/daily-checklist.validation.go | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/internal/database/migrations/20260127114036_change_type_data_column_id_daily_checklist.down.sql b/internal/database/migrations/20260127114036_change_type_data_column_id_daily_checklist.down.sql index dccb2ae4..ee02950a 100644 --- a/internal/database/migrations/20260127114036_change_type_data_column_id_daily_checklist.down.sql +++ b/internal/database/migrations/20260127114036_change_type_data_column_id_daily_checklist.down.sql @@ -10,6 +10,7 @@ BEGIN 'daily_checklist_activity_tasks', 'daily_checklist_phases', 'daily_checklist_tasks', + 'daily_checklists', 'employee_kandangs', 'employees', 'phase_activities', diff --git a/internal/database/migrations/20260127114036_change_type_data_column_id_daily_checklist.up.sql b/internal/database/migrations/20260127114036_change_type_data_column_id_daily_checklist.up.sql index 9c7ce8f7..3c1913cb 100644 --- a/internal/database/migrations/20260127114036_change_type_data_column_id_daily_checklist.up.sql +++ b/internal/database/migrations/20260127114036_change_type_data_column_id_daily_checklist.up.sql @@ -11,6 +11,7 @@ BEGIN 'daily_checklist_activity_tasks', 'daily_checklist_phases', 'daily_checklist_tasks', + 'daily_checklists', 'employee_kandangs', 'employees', 'phase_activities', diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index f479306c..3363d404 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -355,9 +355,10 @@ func (r *ClosingRepositoryImpl) SumMarketingWeightAndQtyByProjectFlockKandangIDs Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id"). Joins("JOIN products prod ON prod.id = pw.product_id"). Joins("JOIN flags f ON f.flagable_id = prod.id AND f.flagable_type = ?", "products"). + Joins("JOIN marketing_delivery_products mdp ON mdp.marketing_product_id = mp.id"). Where("pw.project_flock_kandang_id IN ?", projectFlockKandangIDs). Where("f.name IN ?", flagNames). - Select("COALESCE(SUM(mp.total_weight), 0) AS total_weight, COALESCE(SUM(mp.qty), 0) AS total_qty, COALESCE(SUM(mp.total_price), 0) AS total_price"). + Select("COALESCE(SUM(mdp.total_weight), 0) AS total_weight, COALESCE(SUM(mdp.usage_qty), 0) AS total_qty, COALESCE(SUM(mp.total_price), 0) AS total_price"). Scan(&agg).Error if err != nil { return 0, 0, 0, err @@ -797,7 +798,7 @@ func (r *ClosingRepositoryImpl) detailQuery( ) *gorm.DB { db := r.withCtx(ctx). Table(table). - Joins("JOIN product_warehouses pw ON "+pwJoinCond). + Joins("JOIN product_warehouses pw ON " + pwJoinCond). Joins("JOIN products p ON p.id = pw.product_id") db = applyJoins(db, joins...) @@ -1034,7 +1035,7 @@ func (r *ClosingRepositoryImpl) fetchStockLogs(ctx context.Context, kandangID ui COALESCE(sl.increase,0) AS increase, COALESCE(sl.decrease,0) AS decrease, COALESCE(p.product_price,0) AS price, - `+movementSelect+` + ` + movementSelect + ` `). Joins("JOIN product_warehouses pw ON pw.id = sl.product_warehouse_id"). Joins("JOIN products p ON p.id = pw.product_id"). diff --git a/internal/modules/daily-checklists/validations/daily-checklist.validation.go b/internal/modules/daily-checklists/validations/daily-checklist.validation.go index 9157c4e2..353aeaa5 100644 --- a/internal/modules/daily-checklists/validations/daily-checklist.validation.go +++ b/internal/modules/daily-checklists/validations/daily-checklist.validation.go @@ -29,7 +29,7 @@ type Query struct { } type AssignPhases struct { - PhaseIDs string `json:"phase_ids" validate:"required"` + PhaseIDs string `json:"phase_ids" validate:"omitempty"` } type AssignTask struct { From 8086d2fb9ea14bf6040495e62700e0ba6e4cd914 Mon Sep 17 00:00:00 2001 From: giovanni Date: Wed, 28 Jan 2026 09:50:03 +0700 Subject: [PATCH 32/37] adjust query --- internal/modules/closings/repositories/closing.repository.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index 3363d404..04391332 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -358,7 +358,7 @@ func (r *ClosingRepositoryImpl) SumMarketingWeightAndQtyByProjectFlockKandangIDs Joins("JOIN marketing_delivery_products mdp ON mdp.marketing_product_id = mp.id"). Where("pw.project_flock_kandang_id IN ?", projectFlockKandangIDs). Where("f.name IN ?", flagNames). - Select("COALESCE(SUM(mdp.total_weight), 0) AS total_weight, COALESCE(SUM(mdp.usage_qty), 0) AS total_qty, COALESCE(SUM(mp.total_price), 0) AS total_price"). + Select("COALESCE(SUM(mdp.total_weight), 0) AS total_weight, COALESCE(SUM(mdp.usage_qty), 0) AS total_qty, COALESCE(SUM(mdp.total_price), 0) AS total_price"). Scan(&agg).Error if err != nil { return 0, 0, 0, err From 1a0261ed367b68b9771e528b40c0137199cb9221 Mon Sep 17 00:00:00 2001 From: ragilap Date: Wed, 28 Jan 2026 10:08:41 +0700 Subject: [PATCH 33/37] [FIX/BE-US] fixing stock log delete recording and purchase --- .../recordings/services/recording.service.go | 47 +++++++- .../purchases/services/purchase.service.go | 100 +++++++++++++++++- 2 files changed, 144 insertions(+), 3 deletions(-) diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 25103c2f..0499bb17 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -711,6 +711,11 @@ func (s recordingService) Approval(c *fiber.Ctx, req *validation.Approve) ([]ent func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { ctx := c.Context() + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return err + } + note := fmt.Sprintf("Recording-Delete#%d", id) return s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { oldDepletions, err := s.Repository.ListDepletions(tx, id) @@ -719,7 +724,7 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { return err } if s.FifoSvc != nil { - if err := s.releaseRecordingDepletions(ctx, tx, oldDepletions, "", 0); err != nil { + if err := s.releaseRecordingDepletions(ctx, tx, oldDepletions, note, actorID); err != nil { return err } } @@ -741,7 +746,7 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { return err } - if err := s.releaseRecordingStocks(ctx, tx, oldStocks, "", 0); err != nil { + if err := s.releaseRecordingStocks(ctx, tx, oldStocks, note, actorID); err != nil { return err } @@ -749,6 +754,10 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { return err } + if err := s.logRecordingEggRollback(ctx, tx, oldEggs, note, actorID); err != nil { + return err + } + if err := s.Repository.WithTx(tx).DeleteOne(ctx, id); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, "Recording not found") @@ -761,6 +770,40 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { }) } +func (s *recordingService) logRecordingEggRollback( + ctx context.Context, + tx *gorm.DB, + eggs []entity.RecordingEgg, + note string, + actorID uint, +) error { + if len(eggs) == 0 || s.StockLogRepo == nil { + return nil + } + if strings.TrimSpace(note) == "" || actorID == 0 { + return nil + } + + for _, egg := range eggs { + if egg.ProductWarehouseId == 0 || egg.Qty <= 0 { + continue + } + log := &entity.StockLog{ + ProductWarehouseId: egg.ProductWarehouseId, + CreatedBy: actorID, + Decrease: float64(egg.Qty), + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: egg.RecordingId, + Notes: note, + } + if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { + return err + } + } + + return nil +} + // === Persistence Helpers === func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []validation.Stock, depletions []validation.Depletion, eggs []validation.Egg) error { diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index 6b423d33..572069ed 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -18,9 +18,9 @@ import ( rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" - rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" rPurchase "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/validations" + rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" @@ -1256,6 +1256,10 @@ func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint) error { } ctx := c.Context() + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return err + } purchase, err := s.loadPurchase(ctx, id) if err != nil { return err @@ -1269,7 +1273,16 @@ func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint) error { itemsToDelete[i] = item } + note := fmt.Sprintf("Purchase-Delete#%d", purchase.Id) + if purchase.PoNumber != nil && strings.TrimSpace(*purchase.PoNumber) != "" { + note = fmt.Sprintf("%s#delete", strings.TrimSpace(*purchase.PoNumber)) + } + transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := s.rollbackPurchaseStock(ctx, tx, itemsToDelete, note, actorID); err != nil { + return err + } + approvalRepoTx := commonRepo.NewApprovalRepository(tx) if err := approvalRepoTx.DeleteByTarget(ctx, utils.ApprovalWorkflowPurchase.String(), uint(id)); err != nil { return err @@ -1305,6 +1318,91 @@ func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint) error { return nil } +func (s *purchaseService) rollbackPurchaseStock(ctx context.Context, tx *gorm.DB, items []entity.PurchaseItem, note string, actorID uint) error { + if len(items) == 0 { + return nil + } + + pwRepoTx := rProductWarehouse.NewProductWarehouseRepository(tx) + stockLogRepoTx := rStockLogs.NewStockLogRepository(tx) + deltas := make(map[uint]float64) + affected := make(map[uint]struct{}) + logEntries := make([]struct { + pwID uint + qty float64 + }, 0, len(items)) + + for _, item := range items { + if item.ProductWarehouseId == nil || *item.ProductWarehouseId == 0 { + continue + } + if item.TotalQty == 0 { + continue + } + pwID := *item.ProductWarehouseId + qty := item.TotalQty + + if s.FifoSvc != nil { + if err := s.FifoSvc.AdjustStockableQuantity(ctx, commonSvc.StockAdjustRequest{ + StockableKey: fifo.StockableKeyPurchaseItems, + StockableID: item.Id, + ProductWarehouseID: pwID, + Quantity: -qty, + Tx: tx, + }); err != nil { + return err + } + logEntries = append(logEntries, struct { + pwID uint + qty float64 + }{pwID: pwID, qty: qty}) + continue + } + + deltas[pwID] -= qty + affected[pwID] = struct{}{} + logEntries = append(logEntries, struct { + pwID uint + qty float64 + }{pwID: pwID, qty: qty}) + } + + if s.FifoSvc == nil && len(deltas) > 0 { + if err := pwRepoTx.AdjustQuantities(ctx, deltas, nil); err != nil { + return err + } + if len(affected) > 0 { + if err := pwRepoTx.CleanupEmpty(ctx, affected); err != nil { + return err + } + } + } + + if strings.TrimSpace(note) != "" && actorID != 0 && len(logEntries) > 0 { + logs := make([]*entity.StockLog, 0, len(logEntries)) + for _, entry := range logEntries { + if entry.pwID == 0 || entry.qty <= 0 { + continue + } + logs = append(logs, &entity.StockLog{ + ProductWarehouseId: entry.pwID, + CreatedBy: actorID, + Decrease: entry.qty, + LoggableType: string(utils.StockLogTypePurchase), + LoggableId: items[0].PurchaseId, + Notes: note, + }) + } + if len(logs) > 0 { + if err := stockLogRepoTx.CreateMany(ctx, logs, nil); err != nil { + return err + } + } + } + + return nil +} + func (s *purchaseService) createPurchaseApproval( ctx context.Context, db *gorm.DB, From 2c169e7f8344aeb828169412408a2538b11680cf Mon Sep 17 00:00:00 2001 From: "Hafizh A. Y" Date: Wed, 28 Jan 2026 10:26:13 +0700 Subject: [PATCH 34/37] fix(BE): s3 prefix path --- internal/config/config.go | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/internal/config/config.go b/internal/config/config.go index 8660704b..71fb430c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -73,6 +73,7 @@ var ( S3SecretKey string S3ForcePathStyle bool S3PublicBaseURL string + S3EnvPrefix string S3DocumentKeyPrefix string ) @@ -123,7 +124,12 @@ func init() { S3SecretKey = strings.TrimSpace(viper.GetString("S3_SECRET_KEY")) S3ForcePathStyle = viper.GetBool("S3_FORCE_PATH_STYLE") S3PublicBaseURL = strings.TrimSuffix(strings.TrimSpace(viper.GetString("S3_PUBLIC_BASE_URL")), "/") - S3DocumentKeyPrefix = defaultString(strings.Trim(strings.TrimSpace(viper.GetString("S3_DOCUMENT_PREFIX")), "/"), "docs") + S3EnvPrefix = defaultString(strings.Trim(strings.TrimSpace(viper.GetString("S3_ENV_PREFIX")), "/"), "local") + docPrefix := strings.Trim(strings.TrimSpace(viper.GetString("S3_DOCUMENT_PREFIX")), "/") + if docPrefix == "" { + docPrefix = "docs" + } + S3DocumentKeyPrefix = joinPath(S3EnvPrefix, docPrefix) // SSO integration SSOIssuer = viper.GetString("SSO_ISSUER") @@ -242,6 +248,17 @@ func defaultString(v, def string) string { return v } +func joinPath(parts ...string) string { + out := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.Trim(part, "/") + if part != "" { + out = append(out, part) + } + } + return strings.Join(out, "/") +} + func ensureProdConfig() { if SSOAuthorizeURL == "" || !strings.HasPrefix(SSOAuthorizeURL, "https://") { panic("SSO_AUTHORIZE_URL must be https in production") From 641b7ebd3870b8d5ec8cc297c72dbb8074f122b6 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 28 Jan 2026 11:01:27 +0700 Subject: [PATCH 35/37] [FIX] fixing tab not appear when expense attached to location only --- internal/modules/expenses/dto/expense.dto.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/modules/expenses/dto/expense.dto.go b/internal/modules/expenses/dto/expense.dto.go index 30cecd99..4972a979 100644 --- a/internal/modules/expenses/dto/expense.dto.go +++ b/internal/modules/expenses/dto/expense.dto.go @@ -328,6 +328,7 @@ func ToKandangGroupDTO(pengajuans []ExpenseNonstockDTO, realisasi []ExpenseReali } kandangMap[kandangId].Realisasi = append(kandangMap[kandangId].Realisasi, r) } else { + directRealisasi = append(directRealisasi, r) } } From 43f9b660ce5ba0d2eba84975b80bece7937baa0a Mon Sep 17 00:00:00 2001 From: "Hafizh A. Y" Date: Wed, 28 Jan 2026 11:12:22 +0700 Subject: [PATCH 36/37] fix(BE): bug path s3 --- internal/common/service/common.document.service.go | 14 ++++++++++---- ...0128035908_alter_documents_path_length.down.sql | 7 +++++++ ...260128035908_alter_documents_path_length.up.sql | 7 +++++++ internal/entities/document.go | 2 +- 4 files changed, 25 insertions(+), 5 deletions(-) create mode 100644 internal/database/migrations/20260128035908_alter_documents_path_length.down.sql create mode 100644 internal/database/migrations/20260128035908_alter_documents_path_length.up.sql diff --git a/internal/common/service/common.document.service.go b/internal/common/service/common.document.service.go index 44f2c116..a57aaa85 100644 --- a/internal/common/service/common.document.service.go +++ b/internal/common/service/common.document.service.go @@ -363,13 +363,19 @@ func (s *documentService) generateObjectKey(ext string) (string, error) { } u := uuid.New().String() - key := fmt.Sprintf("%s/%s%s", strings.Trim(s.keyPrefix, "/"), u, normalizedExt) - if s.keyPrefix == "" { - key = fmt.Sprintf("%s%s", u, normalizedExt) + keyPrefix := strings.Trim(s.keyPrefix, "/") + key := fmt.Sprintf("%s%s", u, normalizedExt) + if keyPrefix != "" { + key = fmt.Sprintf("%s/%s%s", keyPrefix, u, normalizedExt) } if len(key) > s.maxPathLength { - key = fmt.Sprintf("%s%s", u, normalizedExt) + compact := strings.ReplaceAll(u, "-", "") + if keyPrefix != "" { + key = fmt.Sprintf("%s/%s%s", keyPrefix, compact, normalizedExt) + } else { + key = fmt.Sprintf("%s%s", compact, normalizedExt) + } } if len(key) > s.maxPathLength { diff --git a/internal/database/migrations/20260128035908_alter_documents_path_length.down.sql b/internal/database/migrations/20260128035908_alter_documents_path_length.down.sql new file mode 100644 index 00000000..70a1e119 --- /dev/null +++ b/internal/database/migrations/20260128035908_alter_documents_path_length.down.sql @@ -0,0 +1,7 @@ +BEGIN; + +-- Migration: revert documents.path length +ALTER TABLE documents + ALTER COLUMN path TYPE VARCHAR(50); + +COMMIT; diff --git a/internal/database/migrations/20260128035908_alter_documents_path_length.up.sql b/internal/database/migrations/20260128035908_alter_documents_path_length.up.sql new file mode 100644 index 00000000..80ed2c06 --- /dev/null +++ b/internal/database/migrations/20260128035908_alter_documents_path_length.up.sql @@ -0,0 +1,7 @@ +BEGIN; + +-- Migration: extend documents.path length for environment prefixes +ALTER TABLE documents + ALTER COLUMN path TYPE VARCHAR(255); + +COMMIT; diff --git a/internal/entities/document.go b/internal/entities/document.go index 54974a02..a13dee0b 100644 --- a/internal/entities/document.go +++ b/internal/entities/document.go @@ -7,7 +7,7 @@ type Document struct { DocumentableType string `gorm:"size:50;not null;index:documents_documentable_polymorphic,priority:1"` DocumentableId uint64 `gorm:"not null;index:documents_documentable_polymorphic,priority:2"` Type string `gorm:"size:50;not null"` - Path string `gorm:"size:50;not null"` + Path string `gorm:"size:255;not null"` Name string `gorm:"size:50;not null"` Ext string `gorm:"size:50;not null"` Size float64 `gorm:"type:numeric(15,3);not null"` From 2127e9d1f41ff45ce9d011991f0878e4031a8b18 Mon Sep 17 00:00:00 2001 From: "Hafizh A. Y" Date: Wed, 28 Jan 2026 11:21:47 +0700 Subject: [PATCH 37/37] fix(BE): limit path to 255 --- internal/common/service/common.document.service.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/common/service/common.document.service.go b/internal/common/service/common.document.service.go index a57aaa85..ed9499d4 100644 --- a/internal/common/service/common.document.service.go +++ b/internal/common/service/common.document.service.go @@ -20,7 +20,7 @@ import ( ) const ( - defaultDocumentPathLimit = 50 + defaultDocumentPathLimit = 255 defaultDocumentKeyPrefix = "docs" maxDocumentNameLength = 50 )