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/common/repository/common.hpp.repository.go b/internal/common/repository/common.hpp.repository.go
index c005e24e..97ad3800 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.approval.service.go b/internal/common/service/common.approval.service.go
index 569a7cc6..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) ([]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,9 +70,14 @@ func (s *approvalService) List(
approvableID *uint,
page, limit int,
search string,
+ orderByDate string,
) ([]entity.Approval, int64, error) {
module = strings.TrimSpace(strings.ToUpper(module))
search = strings.TrimSpace(search)
+ orderByDate = strings.TrimSpace(strings.ToUpper(orderByDate))
+ if orderByDate != "ASC" && orderByDate != "DESC" {
+ orderByDate = "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 " + orderByDate).
Preload("ActionUser")
if approvableID != nil {
diff --git a/internal/common/service/common.document.service.go b/internal/common/service/common.document.service.go
index 44f2c116..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
)
@@ -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/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/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")
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/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/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..ee02950a
--- /dev/null
+++ b/internal/database/migrations/20260127114036_change_type_data_column_id_daily_checklist.down.sql
@@ -0,0 +1,56 @@
+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',
+ 'daily_checklists',
+ '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..3c1913cb
--- /dev/null
+++ b/internal/database/migrations/20260127114036_change_type_data_column_id_daily_checklist.up.sql
@@ -0,0 +1,59 @@
+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',
+ 'daily_checklists',
+ '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;
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/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/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"`
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/middleware/auth.go b/internal/middleware/auth.go
index a831c25b..b7229382 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"
)
@@ -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
new file mode 100644
index 00000000..8430b6fc
--- /dev/null
+++ b/internal/middleware/role_scope.go
@@ -0,0 +1,636 @@
+package middleware
+
+import (
+ "errors"
+
+ "github.com/gofiber/fiber/v2"
+ "gorm.io/gorm"
+
+ 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 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 {
+ return roleScope{}, nil
+ }
+
+ 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
+ }
+
+ return roleScope{}, 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 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)
+}
+
+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 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")
+ }
+ 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_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)
+ 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/approvals/controllers/approval.controller.go b/internal/modules/approvals/controllers/approval.controller.go
index 94a66afd..1ec32ff2 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", ""))
+ orderByDate := strings.TrimSpace(c.Query("order_by_date", ""))
+ if orderByDate == "" {
+ orderByDate = "DESC"
+ } else {
+ orderByDate = strings.ToUpper(orderByDate)
+ if orderByDate != "ASC" && orderByDate != "DESC" {
+ return fiber.NewError(fiber.StatusBadRequest, "order_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,
+ OrderByDate: orderByDate,
}
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.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 7338550e..bd067d71 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"`
+ OrderByDate string `query:"order_by_date" validate:"omitempty,oneof=ASC DESC"`
}
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/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
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/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/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go
index daff5d35..04391332 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(mdp.total_price), 0) AS total_price").
Scan(&agg).Error
if err != nil {
return 0, 0, 0, err
@@ -709,6 +710,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 +763,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(
@@ -779,11 +798,11 @@ func (r *ClosingRepositoryImpl) detailQuery(
) *gorm.DB {
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 product_warehouses pw ON " + pwJoinCond).
+ 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...)
}
@@ -888,7 +907,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,
@@ -907,7 +926,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()).
@@ -918,7 +936,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).
@@ -930,10 +947,11 @@ 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,
+ 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
`)
@@ -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) {
@@ -1017,14 +1035,14 @@ 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").
- 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/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..bd068843 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,6 +99,11 @@ 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 := ""
if params.ProjectStatus != nil {
@@ -111,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)
}
@@ -150,6 +162,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")
@@ -161,6 +177,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 {
@@ -174,8 +197,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 {
@@ -259,7 +282,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")
@@ -321,8 +344,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 {
@@ -344,14 +367,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)
@@ -537,7 +552,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
}
@@ -580,6 +595,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
@@ -668,8 +691,12 @@ func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint, projectFl
}
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)
@@ -834,7 +861,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), 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)
diff --git a/internal/modules/closings/services/closingKeuangan.service.go b/internal/modules/closings/services/closingKeuangan.service.go
index 0f3351f7..ca76c67e 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.GetClosingPenjualanByCategory(c.Context(), projectFlock.Id, projectFlockKandangID, projectFlock.Category)
+ } else {
+ 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")
+ }
- // 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/daily-checklists/services/daily-checklist.service.go b/internal/modules/daily-checklists/services/daily-checklist.service.go
index f306c74d..e2974039 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"
@@ -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 {
@@ -456,7 +554,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")
}
@@ -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,6 +1073,15 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report
return nil, 0, err
}
+ 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
+ }
+
offset := (params.Page - 1) * params.Limit
buildBase := func() *gorm.DB {
@@ -962,6 +1098,9 @@ 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, 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/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 {
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/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..0662a0de 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
@@ -551,18 +572,23 @@ 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(`((r.day - 1) / 7 + 1) 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").
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..928205d2 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"
@@ -17,26 +18,34 @@ 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 {
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,
}
}
+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
@@ -592,13 +601,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 +855,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 +902,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/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)
}
}
diff --git a/internal/modules/expenses/repositories/expense_realization.repository.go b/internal/modules/expenses/repositories/expense_realization.repository.go
index 0ccab661..68890c9a 100644
--- a/internal/modules/expenses/repositories/expense_realization.repository.go
+++ b/internal/modules/expenses/repositories/expense_realization.repository.go
@@ -139,9 +139,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 8b42fbdf..5e6fc420 100644
--- a/internal/modules/expenses/services/expense.service.go
+++ b/internal/modules/expenses/services/expense.service.go
@@ -87,16 +87,22 @@ func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expens
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, 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
@@ -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)
+ var scopeErr error
+
+ expense, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
+ db = s.withRelations(db)
+ 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/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/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..0cad4ff4 100644
--- a/internal/modules/inventory/adjustments/services/adjustment.service.go
+++ b/internal/modules/inventory/adjustments/services/adjustment.service.go
@@ -70,11 +70,15 @@ 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)
+ if err := m.EnsureStockLogAccess(c, s.StockLogsRepository.DB(), id); err != nil {
+ return nil, err
+ }
+
+ 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")
@@ -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},
@@ -164,13 +171,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 +186,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 +193,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 +228,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 +306,36 @@ 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")
+
+ 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").
+ 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))
}
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..63ae97ac 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"
@@ -36,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 {
@@ -61,17 +92,40 @@ func (s productStockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e
return nil, 0, err
}
+ locationScope, areaScope, err := m.ResolveLocationAreaScopes(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 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 (
+ 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 (? 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
+ FROM product_warehouses pw
+ WHERE pw.product_id = products.id
+ AND pw.qty > 0
+ )`)
+ }
- db = s.withRelations(db)
+ db = s.withRelations(db, locationScope, areaScope)
if params.Search != "" {
db = db.Where("products.name ILIKE ?", "%"+params.Search+"%")
}
@@ -86,7 +140,34 @@ func (s productStockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e
}
func (s productStockService) GetOne(c *fiber.Ctx, id uint) (*entity.Product, error) {
- product, err := s.ProductRepository.GetByID(c.Context(), id, s.withRelations)
+ locationScope, areaScope, err := m.ResolveLocationAreaScopes(c, s.ProductRepository.DB())
+ if err != nil {
+ return nil, err
+ }
+
+ 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
+ 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("(? 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
+ }
+ if count == 0 {
+ return nil, fiber.NewError(fiber.StatusNotFound, "Product not found")
+ }
+ }
+
+ 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 5b89808c..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,6 +7,7 @@ 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"
@@ -53,6 +54,19 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query)
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 {
isProductExist, err := s.Repository.IsProductExist(c.Context(), params.ProductId)
if err != nil {
@@ -90,6 +104,17 @@ 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)
+ 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)
+ }
+ }
+
if params.ProductId != 0 {
db = db.Where("product_id = ?", params.ProductId)
}
@@ -116,7 +141,33 @@ 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)
+ 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)
+ 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)
+ }
+ }
+ return db
+ })
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "ProductWarehouse not found")
}
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/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 ea1041ea..e74332bc 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 != "" {
searchTerm := "%" + strings.TrimSpace(params.Search) + "%"
db = db.Joins("LEFT JOIN warehouses AS from_warehouses ON from_warehouses.id = stock_transfers.from_warehouse_id").
@@ -116,6 +130,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)
@@ -196,6 +232,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) {
@@ -235,6 +276,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
@@ -309,9 +351,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,
@@ -405,6 +454,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,10 +489,28 @@ 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 {
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"`
}
diff --git a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go
index 1ec0bddf..231c00d4 100644
--- a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go
+++ b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go
@@ -15,6 +15,7 @@ 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)
+ 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)
@@ -60,6 +61,7 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetClosingPenjualan(ctx context
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").
@@ -91,6 +93,64 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetClosingPenjualan(ctx context
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).
+ 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").
+ Distinct("marketing_delivery_products.*")
+
+ if projectFlockKandangID != nil {
+ 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").
+ 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) GetByMarketingId(ctx context.Context, marketingId uint) ([]entity.MarketingDeliveryProduct, error) {
var deliveryProducts []entity.MarketingDeliveryProduct
@@ -140,7 +200,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 +250,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 +261,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 7e60662d..2022cc78 100644
--- a/internal/modules/marketing/services/deliveryorder.service.go
+++ b/internal/modules/marketing/services/deliveryorder.service.go
@@ -71,6 +71,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")
@@ -95,6 +99,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 {
@@ -106,6 +115,23 @@ 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.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 {
return db.Where("id = ?", params.MarketingId)
}
@@ -134,6 +160,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) {
@@ -173,6 +202,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 {
@@ -323,6 +356,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 dbf99219..9d950307 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
}
}
}
@@ -414,6 +428,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) {
@@ -478,6 +496,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/areas/services/area.service.go b/internal/modules/master/areas/services/area.service.go
index e6f9205c..6110aaef 100644
--- a/internal/modules/master/areas/services/area.service.go
+++ b/internal/modules/master/areas/services/area.service.go
@@ -47,16 +47,22 @@ func (s areaService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Ar
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)
+ 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
@@ -65,7 +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) {
- area, err := s.Repository.GetByID(c.Context(), id, s.withRelations)
+ var scopeErr error
+
+ area, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
+ db = s.withRelations(db)
+ 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/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)
}
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/kandangs/services/kandang.service.go b/internal/modules/master/kandangs/services/kandang.service.go
index 9f83f0ce..159bc410 100644
--- a/internal/modules/master/kandangs/services/kandang.service.go
+++ b/internal/modules/master/kandangs/services/kandang.service.go
@@ -49,10 +49,13 @@ func (s kandangService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity
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, scopeErr = m.ApplyLocationScope(c, db, "kandangs.location_id")
if params.Search != "" {
return db.Where("name ILIKE ?", "%"+params.Search+"%")
}
@@ -65,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
@@ -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)
+ var scopeErr error
+
+ kandang, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
+ db = s.withRelations(db)
+ 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")
}
@@ -88,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)
@@ -162,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) {
@@ -253,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/locations/services/location.service.go b/internal/modules/master/locations/services/location.service.go
index 3a1d1e23..03f6cf45 100644
--- a/internal/modules/master/locations/services/location.service.go
+++ b/internal/modules/master/locations/services/location.service.go
@@ -47,10 +47,13 @@ func (s locationService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit
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)
+ db, scopeErr = m.ApplyLocationScope(c, db, "locations.id")
if params.Search != "" {
db = db.Where("name ILIKE ?", "%"+params.Search+"%")
}
@@ -60,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
@@ -68,7 +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) {
- location, err := s.Repository.GetByID(c.Context(), id, s.withRelations)
+ var scopeErr error
+
+ location, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
+ db = s.withRelations(db)
+ 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/controllers/warehouse.controller.go b/internal/modules/master/warehouses/controllers/warehouse.controller.go
index a7cfac94..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,12 +25,20 @@ 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),
Search: c.Query("search", ""),
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 {
@@ -55,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 0b9dfc18..9d6321b5 100644
--- a/internal/modules/master/warehouses/services/warehouse.service.go
+++ b/internal/modules/master/warehouses/services/warehouse.service.go
@@ -48,16 +48,29 @@ func (s warehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
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)
+ 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+"%")
}
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 (
@@ -75,9 +88,15 @@ 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")
})
+ if scopeErr != nil {
+ return nil, 0, scopeErr
+ }
if err != nil {
s.Log.Errorf("Failed to get warehouses: %+v", err)
return nil, 0, err
@@ -86,7 +105,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)
+ var scopeErr error
+
+ warehouse, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
+ db = s.withRelations(db)
+ db, scopeErr = m.ApplyLocationAreaScope(c, db, "warehouses.location_id", "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")
}
@@ -117,6 +145,19 @@ func (s *warehouseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
if err := validateWarehouseTypeRequirements(typ, &req.AreaId, &req.LocationId, &req.KandangId, createValidationOpts); 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(),
@@ -155,6 +196,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 {
@@ -245,6 +301,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/master/warehouses/validations/warehouse.validation.go b/internal/modules/master/warehouses/validations/warehouse.validation.go
index 1e305520..2a2a9f87 100644
--- a/internal/modules/master/warehouses/validations/warehouse.validation.go
+++ b/internal/modules/master/warehouses/validations/warehouse.validation.go
@@ -21,5 +21,8 @@ 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"`
+ 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-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/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/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/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..9f5bb0e2 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)
@@ -85,6 +86,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
}
@@ -196,6 +198,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 21925a24..96e4b6b0 100644
--- a/internal/modules/production/project_flocks/services/projectflock.service.go
+++ b/internal/modules/production/project_flocks/services/projectflock.service.go
@@ -117,9 +117,20 @@ 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
+ }
+ 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
- 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)
@@ -193,7 +204,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/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/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
+}
diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go
index d490185d..61f96e81 100644
--- a/internal/modules/production/recordings/services/recording.service.go
+++ b/internal/modules/production/recordings/services/recording.service.go
@@ -108,6 +108,13 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
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
+ }
+ }
+
limit := params.Limit
if limit == 0 {
limit = 10
@@ -120,6 +127,10 @@ 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)
+ 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)
}
@@ -127,6 +138,9 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
return db.Order("recordings.record_datetime DESC").Order("recordings.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
@@ -141,6 +155,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)
})
@@ -164,6 +182,9 @@ func (s recordingService) GetNextDay(c *fiber.Ctx, projectFlockKandangId uint, r
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
+ }
if recordTime.IsZero() {
recordTime = time.Now().UTC()
@@ -181,6 +202,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()
@@ -287,6 +311,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 +326,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
@@ -350,6 +376,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)
@@ -465,6 +494,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 +510,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
@@ -629,6 +660,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
@@ -706,7 +742,15 @@ 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()
+ 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)
@@ -715,7 +759,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
}
}
@@ -737,7 +781,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
}
@@ -745,6 +789,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")
@@ -757,6 +805,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 {
@@ -929,6 +1011,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 +1151,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 +1323,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 +1362,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,
@@ -1360,6 +1480,7 @@ func ensureRecordingEggsUnused(eggs []entity.RecordingEgg) error {
}
func stocksMatch(existing []entity.RecordingStock, incoming []validation.Stock) bool {
+
existingUsage := make(map[uint]float64)
for _, stock := range existing {
var usage float64
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..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 {
@@ -218,3 +221,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..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 {
@@ -94,70 +72,26 @@ 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 {
- 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,
}
}
@@ -172,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,
}
}
@@ -192,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,
}
}
@@ -211,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 {
@@ -227,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{
@@ -260,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
}
@@ -285,3 +275,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/repositories/laying_transfer.repository.go b/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go
index 3dab5120..ebf63252 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,114 @@ 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
+ LocationIDs []uint
+ LocationRestrict bool
+}
+
+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 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
+ }
+
+ 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/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..310391c6 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 {
@@ -106,37 +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 {
- // 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)
- }
-
- 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
- })
+ filterParams := &repository.GetAllFilterParams{
+ Search: params.Search,
+ StartDate: params.StartDate,
+ EndDate: params.EndDate,
+ 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)
if err != nil {
s.Log.Errorf("Failed to get transferLayings: %+v", err)
return nil, 0, err
@@ -181,6 +170,11 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create)
if err := s.Validate.Struct(req); 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)
if err != nil {
@@ -409,6 +403,11 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update,
if err := s.Validate.Struct(req); 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 {
return db.Preload("Sources.ProductWarehouse").Preload("Targets")
@@ -584,6 +583,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")
@@ -654,6 +656,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
@@ -663,9 +671,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)
@@ -700,23 +708,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,
})
@@ -730,6 +743,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 {
@@ -738,7 +764,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,
@@ -751,10 +777,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")
+ }
}
}
}
@@ -888,3 +927,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
+}
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..a2fef4a1 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,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/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 79e4d3e7..1e4ccbd5 100644
--- a/internal/modules/production/uniformities/services/uniformity.service.go
+++ b/internal/modules/production/uniformities/services/uniformity.service.go
@@ -87,9 +87,20 @@ func (s uniformityService) GetAll(c *fiber.Ctx, params *validation.Query) ([]ent
return nil, 0, err
}
- offset := (params.Page - 1) * params.Limit
- uniformitys, total, err := s.Repository.GetAllWithFilters(c.Context(), offset, params.Limit, params)
+ 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 {
+ db = db.
+ 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
+ })
+
+ if scopeErr != nil {
+ return nil, 0, scopeErr
+ }
if err != nil {
s.Log.Errorf("Failed to get uniformitys: %+v", err)
return nil, 0, err
@@ -101,6 +112,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 +341,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")
}
@@ -484,6 +502,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
@@ -699,6 +720,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")
@@ -729,6 +754,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/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
+}
diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go
index 6b423d33..b789ef34 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"
@@ -125,6 +125,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)
@@ -148,6 +153,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 (
@@ -202,7 +222,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) {
@@ -1256,6 +1311,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 +1328,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 +1373,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,
diff --git a/internal/modules/repports/controllers/repport.controller.go b/internal/modules/repports/controllers/repport.controller.go
index c1982279..9becdf87 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")
}
@@ -92,6 +108,29 @@ 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
+ }
+ areaScope, err := m.ResolveAreaScope(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 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")
}
@@ -132,6 +171,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")
}
@@ -184,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")
}
@@ -320,6 +382,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")
}
@@ -367,3 +433,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/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/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/repositories/purchase_supplier.repository.go b/internal/modules/repports/repositories/purchase_supplier.repository.go
index 6a07c555..3206eaa5 100644
--- a/internal/modules/repports/repositories/purchase_supplier.repository.go
+++ b/internal/modules/repports/repositories/purchase_supplier.repository.go
@@ -74,10 +74,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 != "" {
@@ -189,10 +197,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 2b6ab745..d45cba62 100644
--- a/internal/modules/repports/services/repport.service.go
+++ b/internal/modules/repports/services/repport.service.go
@@ -11,6 +11,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"
@@ -45,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
@@ -99,7 +101,7 @@ func NewRepportService(
return &repportService{
Log: utils.Log,
Validate: validate,
- DB: db,
+ db: db,
ExpenseRealizationRepo: expenseRealizationRepo,
MarketingDeliveryRepo: marketingDeliveryRepo,
PurchaseRepo: purchaseRepo,
@@ -118,6 +120,10 @@ 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 {
return nil, 0, err
@@ -405,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 {
@@ -428,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
}
@@ -454,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)
@@ -801,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).
@@ -1516,18 +1580,16 @@ 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].AverageWeightEggPerPiece = egg.AverageWeightEggPerPiece
+ // }
+ // }
}
costMap := make(map[uint]HppCostAggregate, len(costRows))
@@ -1613,21 +1675,25 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes
}
var eggPiecesFloatRemaining float64
- eggRemainingWeightFloatRemaining := row.EggProductionWeightKgRemaining
+ 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)
+ hppCost, err := s.HppSvc.CalculateHppCost(row.ProjectFlockKandangID, &periodDate)
if err != nil {
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
+ if eggTotalPiecesFloat > 0 {
+ avgWeight = eggWeightFloat / eggTotalPiecesFloat
+ }
+ eggRemainingWeightFloatRemaining = avgWeight * eggPiecesFloatRemaining
}
}
if math.IsNaN(eggPiecesFloatRemaining) || math.IsInf(eggPiecesFloatRemaining, 0) {
@@ -1642,11 +1708,10 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes
if math.IsNaN(eggWeightFloat) || math.IsInf(eggWeightFloat, 0) {
eggWeightFloat = 0
}
-
- avgWeight := 0.0
- if eggTotalPiecesFloat > 0 {
- avgWeight = eggWeightFloat / eggTotalPiecesFloat
+ if math.IsNaN(avgWeight) || math.IsInf(avgWeight, 0) {
+ avgWeight = 0
}
+
if params.WeightMin != nil && avgWeight < *params.WeightMin {
continue
}
@@ -1885,6 +1950,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())
@@ -1951,6 +2046,51 @@ 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 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 48024dbc..37c581d9 100644
--- a/internal/modules/repports/validations/repport.validation.go
+++ b/internal/modules/repports/validations/repport.validation.go
@@ -1,59 +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/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 85%
rename from internal/sso/profile.go
rename to internal/modules/sso/verifier/profile.go
index a211fc74..e3cd40ca 100644
--- a/internal/sso/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.
@@ -145,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.
@@ -183,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
}
@@ -260,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"`
@@ -284,6 +304,46 @@ 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"`
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
diff --git a/internal/utils/constant.go b/internal/utils/constant.go
index d27b07ef..9abd6a30 100644
--- a/internal/utils/constant.go
+++ b/internal/utils/constant.go
@@ -109,12 +109,23 @@ 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"
+)
+
+// -------------------------------------------------------------------
+// Transfer context
+// -------------------------------------------------------------------
+
+const (
+ TransferContextKey = "transfer_context"
+ TransferContextInventoryTransfer = "inventory_transfer"
+ TransferContextTransferToLaying = "transfer_to_laying"
)
// -------------------------------------------------------------------
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
+}
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
-}