diff --git a/.DS_Store b/.DS_Store index 762745b8..4c14efd8 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index 5c388314..82524f71 100644 --- a/.gitignore +++ b/.gitignore @@ -16,7 +16,7 @@ docker-compose.yaml Dockerfile.local # Go build cache .gocache/ -vendor/ +vendor # Logs & reports *.log diff --git a/Makefile b/Makefile deleted file mode 100644 index 02876da1..00000000 --- a/Makefile +++ /dev/null @@ -1,59 +0,0 @@ -# =============================== -# LTI-API Makefile (Docker Setup) -# =============================== - -APP_NAME := lti-api -COMPOSE := docker compose -f docker-compose.yaml -NETWORK := lti-network -ENV_FILE := .env.lti-api - -include $(ENV_FILE) -export $(shell sed 's/=.*//' $(ENV_FILE)) - -MIGRATIONS_DIR := ./migrations -MIGRATE_IMAGE := migrate/migrate:v4.15.2 -DB_URL := postgres://$(DB_USER):$(DB_PASSWORD)@lti-postgres:5432/$(DB_NAME)?sslmode=disable - -# --- Docker --- -docker-local: - @echo "🚀 Starting $(APP_NAME) with local PostgreSQL & Redis..." - @$(COMPOSE) up --build -d - -docker-down: - @$(COMPOSE) down --remove-orphans - -docker-nuke: - @echo "💣 Removing all containers, images, and volumes..." - @$(COMPOSE) down --rmi all --volumes --remove-orphans - -# --- Database / Migration --- - -wait-db: - @echo "⏳ Waiting for database lti-postgres to be ready (inside Docker network)..." - @$(COMPOSE) run --rm app sh -c 'until nc -z lti-postgres 5432; do echo "Waiting for DB..."; sleep 2; done; echo "✅ Database is ready!"' - -migrate-up: wait-db - @echo "⬆️ Running migrations..." - @docker run --rm -v $(MIGRATIONS_DIR):/migrations --network $(NETWORK) \ - $(MIGRATE_IMAGE) -path=/migrations/ -database "$(DB_URL)" up - -migrate-down: wait-db - @echo "⬇️ Rolling back all migrations..." - @docker run --rm -v $(MIGRATIONS_DIR):/migrations --network $(NETWORK) \ - $(MIGRATE_IMAGE) -path=/migrations/ -database "$(DB_URL)" down -all - -seed: - @echo "🌱 Running seed script..." - @$(COMPOSE) run --rm app go run cmd/seed/main.go - -psql: - @docker exec -it lti-postgres psql -U $(DB_USER) -d $(DB_NAME) - -logs: - @$(COMPOSE) logs -f app - -restart: - @$(COMPOSE) restart - -status: - @$(COMPOSE) ps diff --git a/Makefile.local b/Makefile.local new file mode 100644 index 00000000..5533dc7f --- /dev/null +++ b/Makefile.local @@ -0,0 +1,120 @@ +# --- Load .env kalau ada, dan export ke shell child --- +ifneq (,$(wildcard .env)) +include .env +export +endif + +# --- Konfigurasi umum --- +COMPOSE ?= docker compose -f docker-compose.local.yml +NETWORK ?= lti-api_go-network +MIGRATE_IMAGE ?= migrate/migrate +MIGRATIONS_DIR := $(PWD)/internal/database/migrations + +# Fallback agar tetap jalan meski .env kosong +DB_HOST ?= postgresdb +DB_PORT ?= 5432 +DB_USER ?= postgres +DB_PASSWORD ?= postgres +DB_NAME ?= db_lti_erp + +DB_URL := postgres://$(DB_USER):$(DB_PASSWORD)@$(DB_HOST):$(DB_PORT)/$(DB_NAME)?sslmode=disable + +# Tunggu DB ready memakai pg_isready dari image postgres +WAIT_DB := docker run --rm --network $(NETWORK) postgres:alpine \ + sh -c 'until pg_isready -h $(DB_HOST) -p $(DB_PORT) -U $(DB_USER) -d $(DB_NAME); do echo "waiting for postgres..."; sleep 1; done' + +# Default target +.DEFAULT_GOAL := start + +# --- Daftar phony targets --- +.PHONY: start build test lint gen \ + db-up wait-db \ + migration-% migrate-up migrate-down migrate-fresh \ + seed \ + docker-local docker-down docker-nuke docker-cache psql + +# --- Go workflow --- +start: + @go run cmd/api/main.go + +build: + @go build -o tmp/app ./cmd/api + +test: + @go test ./test/... + +lint: + @golangci-lint run + +# --- Compose / DB helpers --- +db-up: + @$(COMPOSE) up -d postgresdb + +wait-db: + @$(WAIT_DB) + +# --- Migration (pembuatan file) --- +# Contoh: make migration-create_users_table +# ":" akan diubah ke "_" (biar aman untuk nama file) +migration-%: + @migrate create -ext sql -dir $(MIGRATIONS_DIR) $(subst :,_,$*) + +# --- Migration (apply via docker image 'migrate') --- +migrate-up: db-up wait-db + @docker run --rm -v $(MIGRATIONS_DIR):/migrations --network $(NETWORK) \ + $(MIGRATE_IMAGE) -path=/migrations/ -database "$(DB_URL)" up + +# Contoh: +# make migrate-down step=2 → rollback 2 step +# make migrate-down → rollback semua + +migrate-down: db-up wait-db + @if [ -n "$(step)" ]; then \ + echo "⬇️ Migrating down $(step) step(s)..."; \ + docker run --rm -v $(MIGRATIONS_DIR):/migrations --network $(NETWORK) \ + $(MIGRATE_IMAGE) -path=/migrations/ -database "$(DB_URL)" down $(step); \ + else \ + echo "⬇️ Migrating down ALL steps..."; \ + docker run --rm -v $(MIGRATIONS_DIR):/migrations --network $(NETWORK) \ + $(MIGRATE_IMAGE) -path=/migrations/ -database "$(DB_URL)" down -all; \ + fi + +migrate-fresh: migrate-down migrate-up + @true + +# Pakai: make migrate-force v=20250917120000 +migrate-force: + @docker run --rm -v $(MIGRATIONS_DIR):/migrations --network $(NETWORK) \ + $(MIGRATE_IMAGE) -path=/migrations/ -database "$(DB_URL)" force $(v) + + +# --- Seeder --- +seed: db-up wait-db + @$(COMPOSE) run --rm app go run cmd/seed/main.go + +# --- Docker orchestration convenience --- +docker-local: + @$(COMPOSE) up --build -d + +docker-down: + @$(COMPOSE) down --remove-orphans + +# ⚠️ Akan menghapus container, images dan volumes. +docker-nuke: + @$(COMPOSE) down --rmi all --volumes --remove-orphans + +docker-cache: + @docker builder prune -f + +# --- PSQL shell ke DB di container --- +psql: db-up + @$(COMPOSE) exec -it postgresdb psql -U $(DB_USER) -d $(DB_NAME) + +# Single feature +# example: make gen feat=product-category + +# Sub feature +# make gen feat=master/area +gen: + @go run tools/gen.go $(feat) +# @goimports -w internal diff --git a/cmd/api/main.go b/cmd/api/main.go index 05645dfd..a7c278d7 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -62,9 +62,34 @@ func setupRedis() *redis.Client { } func setupSSO(ctx context.Context, rdb *redis.Client) { - if err := sso.Init(ctx, config.SSOJWKSURL, config.SSOIssuer, config.SSOAllowedAudiences); err != nil { - utils.Log.Fatalf("SSO initialization failed: %v", err) + const ( + maxAttempts = 12 + retryDelay = 5 * time.Second + ) + + var lastErr error + for attempt := 1; attempt <= maxAttempts; attempt++ { + if err := sso.Init(ctx, config.SSOJWKSURL, config.SSOIssuer, config.SSOAllowedAudiences); err != nil { + lastErr = err + utils.Log.WithError(err).Warnf("SSO initialization attempt %d/%d failed", attempt, maxAttempts) + select { + case <-ctx.Done(): + utils.Log.Fatalf("SSO initialization aborted: %v", ctx.Err()) + case <-time.After(retryDelay): + } + continue + } + lastErr = nil + if attempt > 1 { + utils.Log.Infof("SSO initialization succeeded after %d attempts", attempt) + } + break } + + if lastErr != nil { + utils.Log.Fatalf("SSO initialization failed: %v", lastErr) + } + if rdb != nil { session.SetRevocationStore(session.NewRevocationStore(rdb, config.SSOTokenBlacklistPrefix)) } else { diff --git a/docker-compose.local.yml b/docker-compose.local.yml index cdc4652d..64f71c70 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -41,8 +41,6 @@ services: working_dir: /lti-api volumes: - .:/lti-api - - ./internal/config/jwtRS256.key:/run/keys/jwtRS256.key - - ./internal/config/jwtRS256.key.pub:/run/keys/jwtRS256.key.pub command: air -c .air.toml env_file: - .env diff --git a/internal/capabilities/capabilities.go b/internal/capabilities/capabilities.go new file mode 100644 index 00000000..742d7acb --- /dev/null +++ b/internal/capabilities/capabilities.go @@ -0,0 +1,44 @@ +package capabilities + +import ( + "strings" + + recordings "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings" +) + +// FromPermissions returns a filtered map of capabilities that the frontend can use +// to toggle features. Only permissions recognized by the application are exposed. +func FromPermissions(perms []string) map[string]bool { + if len(perms) == 0 { + return nil + } + + out := make(map[string]bool) + for _, perm := range perms { + if key, ok := normalizeAndAllow(perm); ok { + out[key] = true + } + } + if len(out) == 0 { + return nil + } + return out +} + +func normalizeAndAllow(perm string) (string, bool) { + perm = strings.ToLower(strings.TrimSpace(perm)) + if perm == "" { + return "", false + } + if _, ok := allowed[perm]; !ok { + return "", false + } + return perm, true +} + +var allowed = map[string]struct{}{ + recordings.PermissionRecordingRead: {}, + recordings.PermissionRecordingCreate: {}, + recordings.PermissionRecordingUpdate: {}, + recordings.PermissionRecordingDelete: {}, +} diff --git a/internal/common/repository/common.exists.repository.go b/internal/common/repository/common.exists.repository.go index ef371330..c6bc11f0 100644 --- a/internal/common/repository/common.exists.repository.go +++ b/internal/common/repository/common.exists.repository.go @@ -2,6 +2,7 @@ package repository import ( "context" + "fmt" "gorm.io/gorm" ) @@ -32,3 +33,21 @@ func ExistsByName[T any](ctx context.Context, db *gorm.DB, name string, excludeI } return count > 0, nil } + +func ExistsByField[T any](ctx context.Context, db *gorm.DB, field string, value any, excludeID *uint) (bool, error) { + if field == "" { + return false, fmt.Errorf("field is required") + } + var count int64 + q := db.WithContext(ctx). + Model(new(T)). + Where(fmt.Sprintf("%s = ?", field), value). + Where("deleted_at IS NULL") + if excludeID != nil { + q = q.Where("id <> ?", *excludeID) + } + if err := q.Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} diff --git a/internal/config/config.go b/internal/config/config.go index 71c13cf9..2554bf57 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,9 +12,9 @@ import ( ) type SSOClientConfig struct { - PublicID string `json:"public_id"` - RedirectURI string `json:"redirect_uri"` - Scope string `json:"scope"` + PublicID string `json:"public_id"` + RedirectURI string `json:"redirect_uri"` + Scope string `json:"scope"` // Prompt string `json:"prompt"` DefaultReturnURI string `json:"default_return_uri"` AllowedReturnOrigins []string `json:"allowed_return_origins"` @@ -32,6 +32,10 @@ var ( DBPassword string DBName string DBPort int + DBSSLMode string + DBSSLRootCert string + DBSSLCert string + DBSSLKey string JWTSecret string JWTAccessExp int JWTRefreshExp int @@ -79,6 +83,10 @@ func init() { DBPassword = viper.GetString("DB_PASSWORD") DBName = viper.GetString("DB_NAME") DBPort = viper.GetInt("DB_PORT") + DBSSLMode = defaultString(viper.GetString("DB_SSLMODE"), "disable") + DBSSLRootCert = strings.TrimSpace(viper.GetString("DB_SSLROOTCERT")) + DBSSLCert = strings.TrimSpace(viper.GetString("DB_SSLCERT")) + DBSSLKey = strings.TrimSpace(viper.GetString("DB_SSLKEY")) // jwt configuration JWTSecret = viper.GetString("JWT_SECRET") diff --git a/internal/database/database.go b/internal/database/database.go index 4d6b2551..95991810 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -2,6 +2,7 @@ package database import ( "fmt" + "strings" "time" "gitlab.com/mbugroup/lti-api.git/internal/config" @@ -13,10 +14,25 @@ import ( ) func Connect(dbHost, dbName string) *gorm.DB { - dsn := fmt.Sprintf( - "host=%s user=%s password=%s dbname=%s port=%d sslmode=disable TimeZone=Asia/Shanghai", - dbHost, config.DBUser, config.DBPassword, dbName, config.DBPort, - ) + parts := []string{ + fmt.Sprintf("host=%s", dbHost), + fmt.Sprintf("user=%s", config.DBUser), + fmt.Sprintf("password=%s", config.DBPassword), + fmt.Sprintf("dbname=%s", dbName), + fmt.Sprintf("port=%d", config.DBPort), + fmt.Sprintf("sslmode=%s", config.DBSSLMode), + "TimeZone=Asia/Shanghai", + } + if config.DBSSLRootCert != "" { + parts = append(parts, fmt.Sprintf("sslrootcert=%s", config.DBSSLRootCert)) + } + if config.DBSSLCert != "" { + parts = append(parts, fmt.Sprintf("sslcert=%s", config.DBSSLCert)) + } + if config.DBSSLKey != "" { + parts = append(parts, fmt.Sprintf("sslkey=%s", config.DBSSLKey)) + } + dsn := strings.Join(parts, " ") db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ Logger: logger.Default.LogMode(logger.Info), diff --git a/internal/database/migrations/20251022143726_recording-tables.down.sql b/internal/database/migrations/20251022143726_recording-tables.down.sql new file mode 100644 index 00000000..1e710e78 --- /dev/null +++ b/internal/database/migrations/20251022143726_recording-tables.down.sql @@ -0,0 +1,24 @@ +BEGIN; + +--? Child Indexes(optional, biar rapi tapi klo gada juga ilang pas di drop) +DROP INDEX IF EXISTS idx_recording_stocks_product; +DROP INDEX IF EXISTS idx_recording_stocks_recording; + + +DROP INDEX IF EXISTS idx_recording_depl_recording; + +DROP INDEX IF EXISTS idx_recording_bws_recording; + +--? Child Tables +DROP TABLE IF EXISTS recording_stocks; +DROP TABLE IF EXISTS recording_depletions; +DROP TABLE IF EXISTS recording_bws; + +--? Parent Indexes ON recordings +DROP INDEX IF EXISTS uq_recordings_flock_record_date; +DROP INDEX IF EXISTS idx_recordings_flock_datetime; + +--? Parent table +DROP TABLE IF EXISTS recordings; + +COMMIT; \ No newline at end of file diff --git a/internal/database/migrations/20251022143726_recording-tables.up.sql b/internal/database/migrations/20251022143726_recording-tables.up.sql new file mode 100644 index 00000000..b961b75d --- /dev/null +++ b/internal/database/migrations/20251022143726_recording-tables.up.sql @@ -0,0 +1,150 @@ +BEGIN; + +--? RECORDINGS (tabel induk recording harian) +CREATE TABLE IF NOT EXISTS recordings ( + id BIGSERIAL PRIMARY KEY, + project_flock_id BIGINT NOT NULL, + record_datetime TIMESTAMPTZ NOT NULL, + record_date DATE, + status INT NOT NULL DEFAULT 0, --? 0=draft,1=submitted,2=approved,3=rejected + ontime INT NOT NULL DEFAULT 0, --? 1=ontime,0=late (pakai INT/BOOLEAN sesuai preferensi) + day INT, + total_depletion INT, + cum_depletion_rate NUMERIC(7,3), + daily_gain NUMERIC(7,3), + avg_daily_gain NUMERIC(7,3), + cum_intake INT, + fcr_value NUMERIC(7,3), + total_chick BIGINT, + daily_depletion_rate NUMERIC(7,3), + cum_depletion INT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + created_by BIGINT, + + CONSTRAINT fk_recordings_project_flock + FOREIGN KEY (project_flock_id) REFERENCES project_flock_kandangs(id), + + CONSTRAINT fk_recordings_created_by + FOREIGN KEY (created_by) REFERENCES users(id), + + + CONSTRAINT chk_recordings_status + CHECK (status IN (0,1,2,3)), + + CONSTRAINT chk_recordings_ontime + CHECK (ontime IN (0,1)), + + CONSTRAINT chk_recordings_day + CHECK (day IS NULL OR day >= 1), + + CONSTRAINT chk_recordings_nonnegatives + CHECK ( + (total_depletion IS NULL OR total_depletion >= 0) AND + (cum_depletion IS NULL OR cum_depletion >= 0) AND + (total_chick IS NULL OR total_chick >= 0) AND + (cum_intake IS NULL OR cum_intake >= 0) AND + (daily_gain IS NULL OR daily_gain >= 0) AND + (avg_daily_gain IS NULL OR avg_daily_gain >= 0) AND + (fcr_value IS NULL OR fcr_value > 0) AND + (daily_depletion_rate IS NULL OR daily_depletion_rate >= 0) AND + (cum_depletion_rate IS NULL OR cum_depletion_rate >= 0) + ) +); + +--? Set record_date otomatis berdasarkan record_datetime (pakai zona Asia/Jakarta) +CREATE OR REPLACE FUNCTION trg_set_record_date() RETURNS trigger AS $$ +BEGIN + NEW.record_date := (NEW.record_datetime AT TIME ZONE 'Asia/Jakarta')::date; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS recordings_set_record_date_trg ON recordings; +CREATE TRIGGER recordings_set_record_date_trg +BEFORE INSERT OR UPDATE OF record_datetime ON recordings +FOR EACH ROW EXECUTE FUNCTION trg_set_record_date(); + + +CREATE INDEX IF NOT EXISTS idx_recordings_flock_datetime + ON recordings (project_flock_id, record_datetime); + +--? Unique harian (1 recording per hari dan per flock) +CREATE UNIQUE INDEX IF NOT EXISTS uq_recordings_flock_record_date + ON recordings (project_flock_id, record_date) + WHERE deleted_at IS NULL; + + +--? RECORDING_BWS (BW per recording) +CREATE TABLE IF NOT EXISTS recording_bws ( + id BIGSERIAL PRIMARY KEY, + recording_id BIGINT NOT NULL, + weight NUMERIC(8,2) NOT NULL, --? bobot per ekor/kelompok + qty INT NOT NULL DEFAULT 1, --? jumlah ekor pada bobot ini + notes VARCHAR, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + + CONSTRAINT fk_recording_bws_recording + FOREIGN KEY (recording_id) REFERENCES recordings(id) ON DELETE CASCADE, + + CONSTRAINT chk_recording_bws_nonneg + CHECK (weight >= 0 AND qty >= 1) +); + +CREATE INDEX IF NOT EXISTS idx_recording_bws_recording + ON recording_bws (recording_id); + +--? RECORDING_DEPLETIONS +CREATE TABLE IF NOT EXISTS recording_depletions ( + id BIGSERIAL PRIMARY KEY, + recording_id BIGINT NOT NULL, + product_warehouse_id BIGINT NOT NULL, + total BIGINT NOT NULL, + notes VARCHAR, + + CONSTRAINT fk_recording_depl_recording + FOREIGN KEY (recording_id) REFERENCES recordings(id) ON DELETE CASCADE, + + CONSTRAINT fk_recording_depl_prodwh + FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses(id), + + CONSTRAINT chk_recording_depl_total + CHECK (total >= 0) +); + +CREATE INDEX IF NOT EXISTS idx_recording_depl_recording + ON recording_depletions (recording_id); + +--? RECORDING_STOCKS +CREATE TABLE IF NOT EXISTS recording_stocks ( + id BIGSERIAL PRIMARY KEY, + recording_id BIGINT NOT NULL, + product_warehouse_id BIGINT NOT NULL, + increase NUMERIC(10,3), --? penambahan (boleh NULL) + decrease NUMERIC(10,3), --? pengurangan (boleh NULL) + usage_amount BIGINT, --? pemakaian (opsional, jika konsep dipisah dari decrease) + notes VARCHAR, + + CONSTRAINT fk_recording_stocks_recording + FOREIGN KEY (recording_id) REFERENCES recordings(id) ON DELETE CASCADE, + + CONSTRAINT fk_recording_stocks_prodwh + FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses(id), + + CONSTRAINT chk_recording_stocks_nonneg + CHECK ( + (increase IS NULL OR increase >= 0) AND + (decrease IS NULL OR decrease >= 0) AND + (usage_amount IS NULL OR usage_amount >= 0) + ) +); + +CREATE INDEX IF NOT EXISTS idx_recording_stocks_recording + ON recording_stocks (recording_id); + +CREATE INDEX IF NOT EXISTS idx_recording_stocks_product + ON recording_stocks (product_warehouse_id); + +COMMIT; diff --git a/internal/database/migrations/20251024092758_deleted-project_flock_id_in_kandangs.up.sql b/internal/database/migrations/20251024092758_deleted-project_flock_id_in_kandangs.up.sql new file mode 100644 index 00000000..14e6dd0a --- /dev/null +++ b/internal/database/migrations/20251024092758_deleted-project_flock_id_in_kandangs.up.sql @@ -0,0 +1,22 @@ + +ALTER TABLE kandangs + DROP CONSTRAINT IF EXISTS kandangs_project_flock_id_fkey; + +ALTER TABLE kandangs + DROP COLUMN IF EXISTS project_flock_id; + +ALTER TABLE project_chickins + DROP CONSTRAINT fk_project_flock_kandang_id, + ADD CONSTRAINT fk_project_flock_kandang_id + FOREIGN KEY (project_flock_kandang_id) + REFERENCES project_flock_kandangs(id) + ON UPDATE CASCADE + ON DELETE CASCADE; + +ALTER TABLE project_flock_populations + DROP CONSTRAINT fk_project_flock_kandang_id, + ADD CONSTRAINT fk_project_flock_kandang_id + FOREIGN KEY (project_flock_kandang_id) + REFERENCES project_flock_kandangs(id) + ON UPDATE CASCADE + ON DELETE CASCADE; diff --git a/internal/database/migrations/20251028110309_adjustments_flock_project_table.down.sql b/internal/database/migrations/20251028110309_adjustments_flock_project_table.down.sql new file mode 100644 index 00000000..fb46f61e --- /dev/null +++ b/internal/database/migrations/20251028110309_adjustments_flock_project_table.down.sql @@ -0,0 +1,98 @@ +BEGIN; + +DROP INDEX IF EXISTS project_flocks_base_period_unique; + +ALTER TABLE project_flocks + ADD COLUMN IF NOT EXISTS flock_id BIGINT; + +WITH normalized AS ( + SELECT + pf.id, + COALESCE( + NULLIF(TRIM(regexp_replace(pf.flock_name, '\\s+\\d+(\\s+\\d+)*$', '', 'g')), ''), + CONCAT('Project Flock ', pf.id) + ) AS normalized_name, + COALESCE(NULLIF(pf.created_by, 0), 1) AS created_by + FROM project_flocks pf +), +seed_flocks AS ( + SELECT DISTINCT + n.normalized_name, + MIN(n.created_by) AS created_by + FROM normalized n + GROUP BY n.normalized_name +) +INSERT INTO flocks (name, created_by, created_at, updated_at) +SELECT sf.normalized_name, sf.created_by, NOW(), NOW() +FROM seed_flocks sf +ON CONFLICT DO NOTHING; + +WITH normalized AS ( + SELECT + pf.id, + COALESCE( + NULLIF(TRIM(regexp_replace(pf.flock_name, '\\s+\\d+(\\s+\\d+)*$', '', 'g')), ''), + CONCAT('Project Flock ', pf.id) + ) AS normalized_name + FROM project_flocks pf +), +resolved AS ( + SELECT + n.id, + f.id AS flock_id + FROM normalized n + JOIN flocks f ON LOWER(f.name) = LOWER(n.normalized_name) +) +UPDATE project_flocks pf +SET flock_id = resolved.flock_id +FROM resolved +WHERE pf.id = resolved.id; + +WITH missing AS ( + SELECT + pf.id, + COALESCE( + NULLIF(TRIM(regexp_replace(pf.flock_name, '\\s+\\d+(\\s+\\d+)*$', '', 'g')), ''), + CONCAT('Project Flock ', pf.id) + ) AS normalized_name, + COALESCE(NULLIF(pf.created_by, 0), 1) AS created_by + FROM project_flocks pf + WHERE pf.flock_id IS NULL +), +seed_missing AS ( + SELECT DISTINCT normalized_name, created_by FROM missing +) +INSERT INTO flocks (name, created_by, created_at, updated_at) +SELECT sm.normalized_name, sm.created_by, NOW(), NOW() +FROM seed_missing sm +ON CONFLICT DO NOTHING; + +WITH missing AS ( + SELECT + pf.id, + COALESCE( + NULLIF(TRIM(regexp_replace(pf.flock_name, '\\s+\\d+(\\s+\\d+)*$', '', 'g')), ''), + CONCAT('Project Flock ', pf.id) + ) AS normalized_name + FROM project_flocks pf + WHERE pf.flock_id IS NULL +) +UPDATE project_flocks pf +SET flock_id = f.id +FROM missing m +JOIN flocks f ON LOWER(f.name) = LOWER(m.normalized_name) +WHERE pf.id = m.id; + +ALTER TABLE project_flocks + ALTER COLUMN flock_id SET NOT NULL; + +DROP INDEX IF EXISTS project_flocks_flock_name_unique; + +ALTER TABLE project_flocks + DROP COLUMN IF EXISTS flock_name; + +CREATE UNIQUE INDEX IF NOT EXISTS project_flocks_flock_period_unique + ON project_flocks (flock_id, period) + WHERE deleted_at IS NULL; + +COMMIT; diff --git a/internal/database/migrations/20251028110309_adjustments_flock_project_table.up.sql b/internal/database/migrations/20251028110309_adjustments_flock_project_table.up.sql new file mode 100644 index 00000000..febc92d2 --- /dev/null +++ b/internal/database/migrations/20251028110309_adjustments_flock_project_table.up.sql @@ -0,0 +1,55 @@ +BEGIN; + +ALTER TABLE project_flocks + ADD COLUMN IF NOT EXISTS flock_name VARCHAR(255); + +WITH generated_names AS ( + SELECT + pf.id, + COALESCE(f.name, CONCAT('Project Flock ', pf.id)) AS base_name, + pf.period, + ROW_NUMBER() OVER (PARTITION BY COALESCE(f.name, CONCAT('Project Flock ', pf.id)) ORDER BY pf.id) AS rn + FROM project_flocks pf + LEFT JOIN flocks f ON f.id = pf.flock_id +) +UPDATE project_flocks pf +SET flock_name = CASE + WHEN gn.period IS NOT NULL THEN + CASE + WHEN gn.rn = 1 THEN CONCAT(gn.base_name, ' ', gn.period) + ELSE CONCAT(gn.base_name, ' ', gn.period, ' ', gn.rn) + END + ELSE + CASE + WHEN gn.rn = 1 THEN gn.base_name + ELSE CONCAT(gn.base_name, ' ', gn.rn) + END + END +FROM generated_names gn +WHERE pf.id = gn.id + AND (pf.flock_name IS NULL OR pf.flock_name = ''); + +UPDATE project_flocks +SET flock_name = CONCAT('Project Flock ', id) +WHERE flock_name IS NULL OR flock_name = ''; + +ALTER TABLE project_flocks + ALTER COLUMN flock_name SET NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS project_flocks_flock_name_unique + ON project_flocks (flock_name) + WHERE deleted_at IS NULL; + +DROP INDEX IF EXISTS project_flocks_flock_period_unique; + +CREATE UNIQUE INDEX IF NOT EXISTS project_flocks_base_period_unique + ON project_flocks ( + LOWER(TRIM(regexp_replace(flock_name, '\\s+\\d+(\\s+\\d+)*$', '', 'g'))), + period + ) + WHERE deleted_at IS NULL; + +ALTER TABLE project_flocks + DROP COLUMN IF EXISTS flock_id; + +COMMIT; diff --git a/internal/database/migrations/20251029070455_update_recording_schema.down.sql b/internal/database/migrations/20251029070455_update_recording_schema.down.sql new file mode 100644 index 00000000..2c7b558f --- /dev/null +++ b/internal/database/migrations/20251029070455_update_recording_schema.down.sql @@ -0,0 +1,143 @@ +BEGIN; + +-- Drop newly introduced egg tables +DROP TABLE IF EXISTS grading_eggs; +DROP TABLE IF EXISTS recording_eggs; + +-- Revert recording_stocks structure +ALTER TABLE recording_stocks + DROP CONSTRAINT IF EXISTS chk_recording_stocks_nonneg; + +ALTER TABLE recording_stocks + DROP COLUMN IF EXISTS usage_qty, + DROP COLUMN IF EXISTS pending_qty; + +ALTER TABLE recording_stocks + ADD COLUMN increase NUMERIC(10,3), + ADD COLUMN decrease NUMERIC(10,3), + ADD COLUMN usage_amount BIGINT, + ADD COLUMN notes VARCHAR; + +ALTER TABLE recording_stocks + ADD CONSTRAINT chk_recording_stocks_nonneg CHECK ( + (increase IS NULL OR increase >= 0) AND + (decrease IS NULL OR decrease >= 0) AND + (usage_amount IS NULL OR usage_amount >= 0) + ); + +-- Revert recording_depletions structure +ALTER TABLE recording_depletions + DROP CONSTRAINT IF EXISTS chk_recording_depl_qty; + +ALTER TABLE recording_depletions + ALTER COLUMN qty TYPE BIGINT USING COALESCE(qty, 0)::BIGINT; + +ALTER TABLE recording_depletions + RENAME COLUMN qty TO total; + +ALTER TABLE recording_depletions + ADD COLUMN notes VARCHAR; + +ALTER TABLE recording_depletions + ADD CONSTRAINT chk_recording_depl_total CHECK (total >= 0); + +-- Revert recording_bws structure +ALTER TABLE recording_bws + DROP CONSTRAINT IF EXISTS chk_recording_bws_nonneg; + +ALTER TABLE recording_bws + ALTER COLUMN qty TYPE INT USING COALESCE(qty, 0)::INT; + +ALTER TABLE recording_bws + DROP COLUMN IF EXISTS total_weight; + +ALTER TABLE recording_bws + ALTER COLUMN avg_weight TYPE NUMERIC(8,2) USING COALESCE(avg_weight, 0)::NUMERIC(8,2); + +ALTER TABLE recording_bws + RENAME COLUMN avg_weight TO weight; + +ALTER TABLE recording_bws + ADD COLUMN notes VARCHAR; + +UPDATE recording_bws +SET qty = GREATEST(qty, 1); + +ALTER TABLE recording_bws + ADD CONSTRAINT chk_recording_bws_nonneg CHECK (weight >= 0 AND qty >= 1); + +-- Revert recordings header +DROP INDEX IF EXISTS idx_recordings_flock_datetime; + +ALTER TABLE recordings + DROP CONSTRAINT IF EXISTS fk_recordings_project_flock_kandang, + DROP CONSTRAINT IF EXISTS chk_recordings_nonnegatives_v2; + +ALTER TABLE recordings + ALTER COLUMN total_depletion_qty TYPE INT USING COALESCE(total_depletion_qty, 0)::INT, + ALTER COLUMN total_chick_qty TYPE BIGINT USING COALESCE(total_chick_qty, 0)::BIGINT; + +ALTER TABLE recordings + RENAME COLUMN total_depletion_qty TO total_depletion; + +ALTER TABLE recordings + RENAME COLUMN total_chick_qty TO total_chick; + +ALTER TABLE recordings + ADD COLUMN record_date DATE, + ADD COLUMN status INT NOT NULL DEFAULT 0, + ADD COLUMN ontime INT NOT NULL DEFAULT 0, + ADD COLUMN daily_depletion_rate NUMERIC(7,3), + ADD COLUMN cum_depletion INT; + +ALTER TABLE recordings + RENAME COLUMN project_flock_kandangs_id TO project_flock_id; + +ALTER TABLE recordings + ADD CONSTRAINT fk_recordings_project_flock + FOREIGN KEY (project_flock_id) REFERENCES project_flock_kandangs(id); + +ALTER TABLE recordings + ADD CONSTRAINT chk_recordings_status CHECK (status IN (0,1,2,3)); + +ALTER TABLE recordings + ADD CONSTRAINT chk_recordings_ontime CHECK (ontime IN (0,1)); + +ALTER TABLE recordings + ADD CONSTRAINT chk_recordings_nonnegatives CHECK ( + (total_depletion IS NULL OR total_depletion >= 0) AND + (cum_depletion IS NULL OR cum_depletion >= 0) AND + (total_chick IS NULL OR total_chick >= 0) AND + (cum_intake IS NULL OR cum_intake >= 0) AND + (daily_gain IS NULL OR daily_gain >= 0) AND + (avg_daily_gain IS NULL OR avg_daily_gain >= 0) AND + (fcr_value IS NULL OR fcr_value > 0) AND + (daily_depletion_rate IS NULL OR daily_depletion_rate >= 0) AND + (cum_depletion_rate IS NULL OR cum_depletion_rate >= 0) + ); + +-- Ensure new columns carry derived data +UPDATE recordings +SET record_date = (record_datetime AT TIME ZONE 'Asia/Jakarta')::date +WHERE record_date IS NULL; + +-- Restore helper trigger/function and indexes +CREATE OR REPLACE FUNCTION trg_set_record_date() RETURNS trigger AS $$ +BEGIN + NEW.record_date := (NEW.record_datetime AT TIME ZONE 'Asia/Jakarta')::date; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER recordings_set_record_date_trg +BEFORE INSERT OR UPDATE OF record_datetime ON recordings +FOR EACH ROW EXECUTE FUNCTION trg_set_record_date(); + +CREATE INDEX idx_recordings_flock_datetime + ON recordings (project_flock_id, record_datetime); + +CREATE UNIQUE INDEX uq_recordings_flock_record_date + ON recordings (project_flock_id, record_date) + WHERE deleted_at IS NULL; + +COMMIT; diff --git a/internal/database/migrations/20251029070455_update_recording_schema.up.sql b/internal/database/migrations/20251029070455_update_recording_schema.up.sql new file mode 100644 index 00000000..89bcd511 --- /dev/null +++ b/internal/database/migrations/20251029070455_update_recording_schema.up.sql @@ -0,0 +1,168 @@ +BEGIN; + +-- Drop trigger & helper function tied to record_date before removing the column +DROP TRIGGER IF EXISTS recordings_set_record_date_trg ON recordings; +DROP FUNCTION IF EXISTS trg_set_record_date(); + +-- Drop indexes and constraints that reference legacy columns +DROP INDEX IF EXISTS uq_recordings_flock_record_date; +DROP INDEX IF EXISTS idx_recordings_flock_datetime; + +ALTER TABLE recordings + DROP CONSTRAINT IF EXISTS fk_recordings_project_flock, + DROP CONSTRAINT IF EXISTS chk_recordings_status, + DROP CONSTRAINT IF EXISTS chk_recordings_ontime, + DROP CONSTRAINT IF EXISTS chk_recordings_nonnegatives; + +-- Align recordings header with the new schema +ALTER TABLE recordings + RENAME COLUMN project_flock_id TO project_flock_kandangs_id; + +ALTER TABLE recordings + DROP COLUMN IF EXISTS record_date, + DROP COLUMN IF EXISTS status, + DROP COLUMN IF EXISTS ontime, + DROP COLUMN IF EXISTS daily_depletion_rate, + DROP COLUMN IF EXISTS cum_depletion; + +ALTER TABLE recordings + RENAME COLUMN total_depletion TO total_depletion_qty; + +ALTER TABLE recordings + RENAME COLUMN total_chick TO total_chick_qty; + +ALTER TABLE recordings + ALTER COLUMN total_depletion_qty TYPE NUMERIC(15,3) USING COALESCE(total_depletion_qty, 0)::NUMERIC(15,3), + ALTER COLUMN total_chick_qty TYPE NUMERIC(15,3) USING COALESCE(total_chick_qty, 0)::NUMERIC(15,3), + ALTER COLUMN cum_intake TYPE INT USING COALESCE(cum_intake, 0)::INT; + +ALTER TABLE recordings + ADD CONSTRAINT fk_recordings_project_flock_kandang + FOREIGN KEY (project_flock_kandangs_id) REFERENCES project_flock_kandangs(id); + +ALTER TABLE recordings + ADD CONSTRAINT chk_recordings_nonnegatives_v2 CHECK ( + (total_depletion_qty IS NULL OR total_depletion_qty >= 0) AND + (cum_depletion_rate IS NULL OR cum_depletion_rate >= 0) AND + (daily_gain IS NULL OR daily_gain >= 0) AND + (avg_daily_gain IS NULL OR avg_daily_gain >= 0) AND + (cum_intake IS NULL OR cum_intake >= 0) AND + (fcr_value IS NULL OR fcr_value >= 0) AND + (total_chick_qty IS NULL OR total_chick_qty >= 0) + ); + +CREATE INDEX idx_recordings_flock_datetime + ON recordings (project_flock_kandangs_id, record_datetime); + +-- recording_bws reshape +ALTER TABLE recording_bws + RENAME COLUMN weight TO avg_weight; + +ALTER TABLE recording_bws + ALTER COLUMN avg_weight TYPE NUMERIC(8,2) USING COALESCE(avg_weight, 0)::NUMERIC(8,2); + +ALTER TABLE recording_bws + ADD COLUMN total_weight NUMERIC(10,3); + +UPDATE recording_bws +SET total_weight = COALESCE(avg_weight, 0) * COALESCE(qty, 0); + +ALTER TABLE recording_bws + ALTER COLUMN total_weight SET NOT NULL; + +ALTER TABLE recording_bws + ALTER COLUMN qty TYPE NUMERIC(15,3) USING COALESCE(qty, 0)::NUMERIC(15,3); + +ALTER TABLE recording_bws + DROP COLUMN IF EXISTS notes; + +ALTER TABLE recording_bws + DROP CONSTRAINT IF EXISTS chk_recording_bws_nonneg; + +ALTER TABLE recording_bws + ADD CONSTRAINT chk_recording_bws_nonneg CHECK ( + avg_weight >= 0 AND qty >= 0 AND total_weight >= 0 + ); + +-- recording_depletions reshape +ALTER TABLE recording_depletions + RENAME COLUMN total TO qty; + +ALTER TABLE recording_depletions + ALTER COLUMN qty TYPE NUMERIC(15,3) USING COALESCE(qty, 0)::NUMERIC(15,3); + +ALTER TABLE recording_depletions + DROP COLUMN IF EXISTS notes; + +ALTER TABLE recording_depletions + DROP CONSTRAINT IF EXISTS chk_recording_depl_total; + +ALTER TABLE recording_depletions + ADD CONSTRAINT chk_recording_depl_qty CHECK (qty >= 0); + +-- recording_stocks reshape +ALTER TABLE recording_stocks + DROP CONSTRAINT IF EXISTS chk_recording_stocks_nonneg; + +ALTER TABLE recording_stocks + DROP COLUMN IF EXISTS increase, + DROP COLUMN IF EXISTS decrease, + DROP COLUMN IF EXISTS usage_amount, + DROP COLUMN IF EXISTS notes; + +ALTER TABLE recording_stocks + ADD COLUMN usage_qty NUMERIC(15,3), + ADD COLUMN pending_qty NUMERIC(15,3); + +ALTER TABLE recording_stocks + ADD CONSTRAINT chk_recording_stocks_nonneg CHECK ( + (usage_qty IS NULL OR usage_qty >= 0) AND + (pending_qty IS NULL OR pending_qty >= 0) + ); + +-- recording_eggs table +CREATE TABLE recording_eggs ( + id BIGSERIAL PRIMARY KEY, + recording_id BIGINT NOT NULL, + product_warehouse_id BIGINT NOT NULL, + qty INT NOT NULL, + created_by BIGINT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + + CONSTRAINT fk_recording_eggs_recording + FOREIGN KEY (recording_id) REFERENCES recordings(id) ON DELETE CASCADE, + CONSTRAINT fk_recording_eggs_product_warehouse + FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses(id), + CONSTRAINT fk_recording_eggs_created_by + FOREIGN KEY (created_by) REFERENCES users(id), + CONSTRAINT chk_recording_eggs_qty CHECK (qty >= 0) +); + +CREATE INDEX idx_recording_eggs_recording + ON recording_eggs (recording_id); + +CREATE INDEX idx_recording_eggs_product + ON recording_eggs (product_warehouse_id); + +-- grading_eggs table +CREATE TABLE grading_eggs ( + id BIGSERIAL PRIMARY KEY, + recording_egg_id BIGINT NOT NULL, + qty NUMERIC(15,3) NOT NULL, + grade VARCHAR, + created_by BIGINT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + + CONSTRAINT fk_grading_eggs_recording_egg + FOREIGN KEY (recording_egg_id) REFERENCES recording_eggs(id) ON DELETE CASCADE, + CONSTRAINT fk_grading_eggs_created_by + FOREIGN KEY (created_by) REFERENCES users(id), + CONSTRAINT chk_grading_eggs_qty CHECK (qty >= 0) +); + +CREATE INDEX idx_grading_eggs_recording_egg + ON grading_eggs (recording_egg_id); + +COMMIT; diff --git a/internal/database/seed/seeder.go b/internal/database/seed/seeder.go index 791cfddb..24425917 100644 --- a/internal/database/seed/seeder.go +++ b/internal/database/seed/seeder.go @@ -8,7 +8,6 @@ import ( entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gitlab.com/mbugroup/lti-api.git/internal/utils" - approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" "gorm.io/gorm" ) @@ -41,22 +40,15 @@ func Run(db *gorm.DB) error { return err } - flocks, err := seedFlocks(tx, adminID) - if err != nil { + if _, err := seedFlocks(tx, adminID); err != nil { return err } - fcrs, err := seedFcr(tx, adminID) - if err != nil { + if _, err := seedFcr(tx, adminID); err != nil { return err } - projectFlocks, err := seedProjectFlocks(tx, adminID, flocks, areas, fcrs, locations) - if err != nil { - return err - } - - kandangs, err := seedKandangs(tx, adminID, locations, users, projectFlocks) + kandangs, err := seedKandangs(tx, adminID, locations, users) if err != nil { return err } @@ -93,10 +85,6 @@ func Run(db *gorm.DB) error { if err := seedTransferStock(tx, adminID); err != nil { return err } - if err := seedChickin(tx, adminID); err != nil { - return err - } - fmt.Println("✅ Master data seeding completed") return nil }) @@ -243,159 +231,16 @@ func seedFlocks(tx *gorm.DB, createdBy uint) (map[string]uint, error) { return result, nil } -func seedProjectFlocks(tx *gorm.DB, createdBy uint, flocks, areas, fcrs, locations map[string]uint) (map[string]uint, error) { +func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users map[string]uint) (map[string]uint, error) { seeds := []struct { - Key string - Flock string - Area string - Category utils.ProjectFlockCategory - Fcr string + Name string + Status utils.KandangStatus Location string - Period int + PicKey string }{ - { - Key: "Singaparna Period 1", - Flock: "Flock Priangan", - Area: "Priangan", - Category: utils.ProjectFlockCategoryGrowing, - Fcr: "FCR Layer", - Location: "Singaparna", - Period: 1, - }, - { - Key: "Cikaum Period 1", - Flock: "Flock Banten", - Area: "Banten", - Category: utils.ProjectFlockCategoryGrowing, - Fcr: "FCR Layer", - Location: "Cikaum", - Period: 1, - }, - } - - result := make(map[string]uint, len(seeds)) - - for _, seed := range seeds { - flockID, ok := flocks[seed.Flock] - if !ok { - return nil, fmt.Errorf("floc %s not seeded", seed.Flock) - } - areaID, ok := areas[seed.Area] - if !ok { - return nil, fmt.Errorf("area %s not seeded", seed.Area) - } - fcrID, ok := fcrs[seed.Fcr] - if !ok { - return nil, fmt.Errorf("fcr %s not seeded", seed.Fcr) - } - locationID, ok := locations[seed.Location] - if !ok { - return nil, fmt.Errorf("location %s not seeded", seed.Location) - } - - var projectFlock entity.ProjectFlock - err := tx.Where("flock_id = ? AND area_id = ? AND category = ? AND fcr_id = ? AND location_id = ? AND period = ?", - flockID, areaID, seed.Category, fcrID, locationID, seed.Period).First(&projectFlock).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - projectFlock = entity.ProjectFlock{ - FlockId: flockID, - AreaId: areaID, - Category: string(seed.Category), - FcrId: fcrID, - LocationId: locationID, - Period: seed.Period, - CreatedBy: createdBy, - } - if err := tx.Create(&projectFlock).Error; err != nil { - return nil, err - } - } else if err != nil { - return nil, err - } else { - if err := tx.Model(&entity.ProjectFlock{}).Where("id = ?", projectFlock.Id).Updates(map[string]any{ - "flock_id": flockID, - "area_id": areaID, - "category": string(seed.Category), - "fcr_id": fcrID, - "location_id": locationID, - "period": seed.Period, - }).Error; err != nil { - return nil, err - } - } - - if err := ensureProjectFlockApprovals(tx, projectFlock.Id, createdBy); err != nil { - return nil, err - } - result[seed.Key] = projectFlock.Id - } - - return result, nil -} - -func ensureProjectFlockApprovals(tx *gorm.DB, projectFlockID uint, actorID uint) error { - if projectFlockID == 0 || actorID == 0 { - return nil - } - - workflow := utils.ApprovalWorkflowProjectFlock.String() - - steps := []struct { - step approvalutils.ApprovalStep - action entity.ApprovalAction - }{ - {step: utils.ProjectFlockStepPengajuan, action: entity.ApprovalActionCreated}, - {step: utils.ProjectFlockStepAktif, action: entity.ApprovalActionApproved}, - } - - for _, cfg := range steps { - var count int64 - if err := tx.Model(&entity.Approval{}). - Where("approvable_type = ? AND approvable_id = ? AND step_number = ?", workflow, projectFlockID, uint16(cfg.step)). - Count(&count).Error; err != nil { - return err - } - if count > 0 { - continue - } - - stepName, ok := utils.ProjectFlockApprovalSteps[cfg.step] - if !ok || strings.TrimSpace(stepName) == "" { - stepName = fmt.Sprintf("Step %d", cfg.step) - } - - var actionPtr *entity.ApprovalAction - action := cfg.action - actionPtr = &action - - record := entity.Approval{ - ApprovableType: workflow, - ApprovableId: projectFlockID, - StepNumber: uint16(cfg.step), - StepName: stepName, - Action: actionPtr, - ActionBy: uintPtr(actorID), - } - - if err := tx.Create(&record).Error; err != nil { - return err - } - } - - return nil -} - -func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users map[string]uint, projectFlocks map[string]uint) (map[string]uint, error) { - seeds := []struct { - Name string - Status utils.KandangStatus - Location string - PicKey string - ProjectFlockKey *string - }{ - {Name: "Singaparna 1", Status: utils.KandangStatusActive, Location: "Singaparna", PicKey: "admin", ProjectFlockKey: strPtr("Singaparna Period 1")}, + {Name: "Singaparna 1", Status: utils.KandangStatusNonActive, Location: "Singaparna", PicKey: "admin"}, {Name: "Singaparna 2", Status: utils.KandangStatusNonActive, Location: "Singaparna", PicKey: "admin"}, - {Name: "Cikaum 1", Status: utils.KandangStatusActive, Location: "Cikaum", PicKey: "admin", ProjectFlockKey: strPtr("Cikaum Period 1")}, + {Name: "Cikaum 1", Status: utils.KandangStatusNonActive, Location: "Cikaum", PicKey: "admin"}, {Name: "Cikaum 2", Status: utils.KandangStatusNonActive, Location: "Cikaum", PicKey: "admin"}, } @@ -411,32 +256,19 @@ func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users return nil, fmt.Errorf("user %s not seeded", seed.PicKey) } - var projectFlockID *uint - if seed.ProjectFlockKey != nil { - pfID, ok := projectFlocks[*seed.ProjectFlockKey] - if !ok { - return nil, fmt.Errorf("project flock %s not seeded", *seed.ProjectFlockKey) - } - projectFlockID = uintPtr(pfID) - } - var kandang entity.Kandang err := tx.Where("name = ?", seed.Name).First(&kandang).Error if errors.Is(err, gorm.ErrRecordNotFound) { kandang = entity.Kandang{ - Name: seed.Name, - Status: string(seed.Status), - LocationId: locID, - PicId: picID, - ProjectFlockId: projectFlockID, - CreatedBy: createdBy, + Name: seed.Name, + Status: string(seed.Status), + LocationId: locID, + PicId: picID, + CreatedBy: createdBy, } if err := tx.Create(&kandang).Error; err != nil { return nil, err } - if err := syncPivotRelation(tx, projectFlockID, kandang.Id); err != nil { - return nil, err - } } else if err != nil { return nil, err } else { @@ -445,17 +277,9 @@ func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users "pic_id": picID, "status": string(seed.Status), } - if projectFlockID != nil { - updates["project_flock_id"] = *projectFlockID - } else { - updates["project_flock_id"] = nil - } if err := tx.Model(&entity.Kandang{}).Where("id = ?", kandang.Id).Updates(updates).Error; err != nil { return nil, err } - if err := syncPivotRelation(tx, projectFlockID, kandang.Id); err != nil { - return nil, err - } } result[seed.Name] = kandang.Id } @@ -463,38 +287,6 @@ func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users return result, nil } -func syncPivotRelation(tx *gorm.DB, projectFlockID *uint, kandangID uint) error { - if err := detachActivePivot(tx, kandangID); err != nil { - return err - } - if projectFlockID == nil { - return nil - } - return ensureActivePivot(tx, *projectFlockID, kandangID) -} - -func detachActivePivot(tx *gorm.DB, kandangID uint) error { - return tx.Where("kandang_id = ?", kandangID). - Delete(&entity.ProjectFlockKandang{}).Error -} - -func ensureActivePivot(tx *gorm.DB, projectFlockID, kandangID uint) error { - var pivot entity.ProjectFlockKandang - err := tx.Where("project_flock_id = ? AND kandang_id = ?", projectFlockID, kandangID). - First(&pivot).Error - if err == nil { - return nil - } - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - newRecord := entity.ProjectFlockKandang{ - ProjectFlockId: projectFlockID, - KandangId: kandangID, - } - return tx.Create(&newRecord).Error -} - func seedWarehouses(tx *gorm.DB, createdBy uint, areas map[string]uint, locations map[string]uint, kandangs map[string]uint) error { seeds := []struct { Name string @@ -571,8 +363,10 @@ func seedProductCategories(tx *gorm.DB, createdBy uint) (map[string]uint, error) Name string Code string }{ + {"Pullet", "PLT"}, {"Bahan Baku", "RAW"}, {"Day Old Chick", "DOC"}, + {"Telur", "EGG"}, } result := make(map[string]uint, len(seeds)) @@ -776,6 +570,54 @@ func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories Suppliers: []string{"PT CHAROEN POKPHAND INDONESIA Tbk"}, Flags: []utils.FlagType{utils.FlagDOC}, }, + { + Name: "Ayam Afkir", + Brand: "-", + Sku: "1", + Uom: "Ekor", + Category: "Day Old Chick", + Price: 1, + + + }, + { + Name: "Ayam Mati", + Brand: "-", + Sku: "2", + Uom: "Ekor", + Category: "Day Old Chick", + Price: 1, + + + }, + { + Name: "Ayam Culling", + Brand: "-", + Sku: "3", + Uom: "Ekor", + Category: "Day Old Chick", + Price: 1, + + + }, + { + Name: "Telur Konsumsi Baik", + Brand: "-", + Sku: "4", + Uom: "Unit", + Category: "Telur", + Price: 1, + + }, + { + Name: "Telur Pecah", + Brand: "-", + Sku: "5", + Uom: "Unit", + Category: "Telur", + Price: 1, + + }, { Name: "281 SPECIAL STARTER", Brand: "281 STARTER", @@ -1026,25 +868,44 @@ func seedBanks(tx *gorm.DB, createdBy uint) error { } func seedProductWarehouse(tx *gorm.DB, createdBy uint) error { - seeds := []struct { - ProductID uint - WarehouseID uint - Quantity float64 + ProductName string + WarehouseName string + Quantity float64 }{ - {ProductID: 1, WarehouseID: 1, Quantity: 100}, - {ProductID: 2, WarehouseID: 2, Quantity: 200}, - {ProductID: 2, WarehouseID: 1, Quantity: 300}, - {ProductID: 1, WarehouseID: 3, Quantity: 5000}, + {ProductName: "DOC Broiler", WarehouseName: "Gudang Priangan", Quantity: 100}, + {ProductName: "281 SPECIAL STARTER", WarehouseName: "Gudang Singaparna", Quantity: 200}, + {ProductName: "281 SPECIAL STARTER", WarehouseName: "Gudang Banten", Quantity: 300}, + {ProductName: "DOC Broiler", WarehouseName: "Gudang Singaparna 1", Quantity: 5000}, + {ProductName: "Telur Konsumsi Baik", WarehouseName: "Gudang Singaparna 1", Quantity: 600}, + {ProductName: "Telur Pecah", WarehouseName: "Gudang Singaparna 1", Quantity: 80}, + {ProductName: "Telur Konsumsi Baik", WarehouseName: "Gudang Cikaum 1", Quantity: 450}, + {ProductName: "Telur Pecah", WarehouseName: "Gudang Cikaum 1", Quantity: 60}, } for _, seed := range seeds { + var product entity.Product + if err := tx.Where("name = ?", seed.ProductName).First(&product).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("product %q not found for product warehouse seeding", seed.ProductName) + } + return err + } + + var warehouse entity.Warehouse + if err := tx.Where("name = ?", seed.WarehouseName).First(&warehouse).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("warehouse %q not found for product warehouse seeding", seed.WarehouseName) + } + return err + } + var productWarehouse entity.ProductWarehouse - err := tx.Where("product_id = ? AND warehouse_id = ?", seed.ProductID, seed.WarehouseID).First(&productWarehouse).Error + err := tx.Where("product_id = ? AND warehouse_id = ?", product.Id, warehouse.Id).First(&productWarehouse).Error if errors.Is(err, gorm.ErrRecordNotFound) { productWarehouse = entity.ProductWarehouse{ - ProductId: seed.ProductID, - WarehouseId: seed.WarehouseID, + ProductId: product.Id, + WarehouseId: warehouse.Id, Quantity: seed.Quantity, CreatedBy: createdBy, } @@ -1053,6 +914,12 @@ func seedProductWarehouse(tx *gorm.DB, createdBy uint) error { } } else if err != nil { return err + } else { + if err := tx.Model(&productWarehouse).Updates(map[string]any{ + "quantity": seed.Quantity, + }).Error; err != nil { + return err + } } } @@ -1133,153 +1000,6 @@ func seedTransferStock(tx *gorm.DB, createdBy uint) error { return nil } - -func seedChickin(tx *gorm.DB, createdBy uint) error { - seeds := []struct { - ProjectFlockKandangId uint - ChickInDate string - Quantity float64 - Note string - }{ - {ProjectFlockKandangId: 1, ChickInDate: "2025-10-20", Quantity: 100, Note: "Seeder chickin 1"}, - {ProjectFlockKandangId: 2, ChickInDate: "2025-10-21", Quantity: 200, Note: "Seeder chickin 2"}, - } - - for _, seed := range seeds { - chickinDate, err := time.Parse("2006-01-02", seed.ChickInDate) - if err != nil { - return err - } - - // Insert ProjectChickin jika belum ada - var chickin entity.ProjectChickin - err = tx.Where("project_flock_kandang_id = ? AND chick_in_date = ?", seed.ProjectFlockKandangId, chickinDate). - First(&chickin).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - chickin = entity.ProjectChickin{ - ProjectFlockKandangId: seed.ProjectFlockKandangId, - ChickInDate: chickinDate, - Quantity: seed.Quantity, - Note: seed.Note, - CreatedBy: createdBy, - } - if err := tx.Create(&chickin).Error; err != nil { - return err - } - } else if err != nil { - return err - } - - var population entity.ProjectFlockPopulation - err = tx.Where("project_flock_kandang_id = ?", seed.ProjectFlockKandangId).First(&population).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - population = entity.ProjectFlockPopulation{ - ProjectFlockKandangId: seed.ProjectFlockKandangId, - InitialQuantity: seed.Quantity, - CurrentQuantity: seed.Quantity, - ReservedQuantity: 0, - CreatedBy: createdBy, - } - if err := tx.Create(&population).Error; err != nil { - return err - } - } else if err != nil { - return err - } else { - // Update population quantities - if err := tx.Model(&entity.ProjectFlockPopulation{}). - Where("id = ?", population.Id). - Updates(map[string]any{ - "initial_quantity": population.InitialQuantity + seed.Quantity, - "current_quantity": population.CurrentQuantity + seed.Quantity, - "reserved_quantity": 0, - }).Error; err != nil { - return err - } - } - - var pfk entity.ProjectFlockKandang - if err := tx.Where("id = ?", seed.ProjectFlockKandangId).First(&pfk).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - // no pivot found; skip creating details - continue - } - return err - } - - var warehouse entity.Warehouse - if err := tx.Where("kandang_id = ?", pfk.KandangId).First(&warehouse).Error; err != nil { - // if warehouse not found, cannot create details - if errors.Is(err, gorm.ErrRecordNotFound) { - continue - } - return err - } - - var productWarehouses []entity.ProductWarehouse - err = tx.Table("product_warehouses"). - Select("product_warehouses.*"). - Joins("JOIN products ON products.id = product_warehouses.product_id"). - Joins("JOIN product_categories ON product_categories.id = products.product_category_id"). - Where("product_categories.code = ? AND product_warehouses.warehouse_id = ?", "DOC", warehouse.Id). - Order("product_warehouses.created_at DESC"). - Find(&productWarehouses).Error - if err != nil { - return err - } - - // If no product warehouses found, keep existing chickin.Quantity and skip details - if len(productWarehouses) == 0 { - continue - } - - // sum all pw quantities and set chickin.Quantity to that total (mimic CreateOne) - totalQty := 0.0 - for _, pw := range productWarehouses { - totalQty += pw.Quantity - } - - if chickin.Quantity != totalQty { - if err := tx.Model(&entity.ProjectChickin{}).Where("id = ?", chickin.Id).Update("quantity", totalQty).Error; err != nil { - return err - } - chickin.Quantity = totalQty - } - - for _, pw := range productWarehouses { - // ensure detail exists or create it with full pw.Quantity - var detail entity.ProjectChickinDetail - err = tx.Where("project_chickin_id = ? AND product_warehouse_id = ?", chickin.Id, pw.Id).First(&detail).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - detail = entity.ProjectChickinDetail{ - ProjectChickinId: chickin.Id, - ProductWarehouseId: pw.Id, - Quantity: pw.Quantity, - CreatedBy: createdBy, - } - if err := tx.Create(&detail).Error; err != nil { - return err - } - } else if err != nil { - return err - } else { - if detail.Quantity != pw.Quantity { - if err := tx.Model(&entity.ProjectChickinDetail{}).Where("id = ?", detail.Id).Update("quantity", pw.Quantity).Error; err != nil { - return err - } - } - } - - // zero out pw quantity - if err := tx.Model(&entity.ProductWarehouse{}).Where("id = ?", pw.Id).Update("quantity", 0).Error; err != nil { - return err - } - } - } - - return nil -} - func ptr[T any](v T) *T { return &v } diff --git a/internal/entities/kandang.go b/internal/entities/kandang.go index c71382da..178681f0 100644 --- a/internal/entities/kandang.go +++ b/internal/entities/kandang.go @@ -7,18 +7,17 @@ import ( ) type Kandang struct { - Id uint `gorm:"primaryKey"` - Name string `gorm:"not null;uniqueIndex:kandangs_name_unique,where:deleted_at IS NULL"` - Status string `gorm:"type:varchar(50);not null"` - LocationId uint `gorm:"not null"` - PicId uint `gorm:"not null"` - ProjectFlockId *uint `gorm:"column:project_flock_id"` - CreatedBy uint `gorm:"not null"` - CreatedAt time.Time `gorm:"autoCreateTime"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` - Location Location `gorm:"foreignKey:LocationId;references:Id"` - Pic User `gorm:"foreignKey:PicId;references:Id"` - ProjectFlock *ProjectFlock `gorm:"foreignKey:ProjectFlockId;references:Id"` + Id uint `gorm:"primaryKey"` + Name string `gorm:"not null;uniqueIndex:kandangs_name_unique,where:deleted_at IS NULL"` + Status string `gorm:"type:varchar(50);not null"` + LocationId uint `gorm:"not null"` + PicId uint `gorm:"not null"` + CreatedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + Location Location `gorm:"foreignKey:LocationId;references:Id"` + Pic User `gorm:"foreignKey:PicId;references:Id"` + ProjectFlockKandangs []ProjectFlockKandang `gorm:"foreignKey:KandangId;references:Id" json:"-"` } diff --git a/internal/entities/project_chickin.go b/internal/entities/project_chickin.go index 95a658c8..5dd22f1a 100644 --- a/internal/entities/project_chickin.go +++ b/internal/entities/project_chickin.go @@ -10,7 +10,7 @@ const () type ProjectChickin struct { Id uint `gorm:"primaryKey"` - ProjectFlockKandangId uint `gorm:"not null"` + ProjectFlockKandangId uint `gorm:"not null;index;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` ChickInDate time.Time `gorm:"not null"` Quantity float64 `gorm:"not null"` Note string `gorm:"type:text"` diff --git a/internal/entities/project_flock_population.go b/internal/entities/project_flock_population.go index 184ace65..6cd3a214 100644 --- a/internal/entities/project_flock_population.go +++ b/internal/entities/project_flock_population.go @@ -8,7 +8,7 @@ import ( type ProjectFlockPopulation struct { Id uint `gorm:"primaryKey"` - ProjectFlockKandangId uint `gorm:"not null"` + ProjectFlockKandangId uint `gorm:"not null;index;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` InitialQuantity float64 `gorm:"type:numeric(15,3);not null"` CurrentQuantity float64 `gorm:"type:numeric(15,3);not null"` ReservedQuantity float64 `gorm:"type:numeric(15,3)"` @@ -18,5 +18,6 @@ type ProjectFlockPopulation struct { DeletedAt gorm.DeletedAt `gorm:"index"` ProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:ProjectFlockKandangId;references:Id"` + CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` } diff --git a/internal/entities/projectflock.go b/internal/entities/projectflock.go index c840892f..0507d9f3 100644 --- a/internal/entities/projectflock.go +++ b/internal/entities/projectflock.go @@ -8,23 +8,23 @@ import ( type ProjectFlock struct { Id uint `gorm:"primaryKey"` - FlockId uint `gorm:"not null;uniqueIndex:project_flocks_flock_period_unique"` + FlockName string `gorm:"type:varchar(255);not null;uniqueIndex"` AreaId uint `gorm:"not null"` Category string `gorm:"type:varchar(20);not null"` FcrId uint `gorm:"not null"` LocationId uint `gorm:"not null"` - Period int `gorm:"not null;uniqueIndex:project_flocks_flock_period_unique"` + Period int `gorm:"not null"` CreatedBy uint `gorm:"not null"` CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - Flock Flock `gorm:"foreignKey:FlockId;references:Id"` Area Area `gorm:"foreignKey:AreaId;references:Id"` Fcr Fcr `gorm:"foreignKey:FcrId;references:Id"` Location Location `gorm:"foreignKey:LocationId;references:Id"` CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` - Kandangs []Kandang `gorm:"foreignKey:ProjectFlockId;references:Id"` - KandangHistory []ProjectFlockKandang `gorm:"foreignKey:ProjectFlockId;references:Id"` - LatestApproval *Approval `gorm:"-" json:"-"` + Kandangs []Kandang `gorm:"many2many:project_flock_kandangs;joinTableForeignKey:project_flock_id;joinTableReferences:kandang_id" json:"kandangs,omitempty"` + KandangHistory []ProjectFlockKandang `gorm:"foreignKey:ProjectFlockId;references:Id" json:"-"` + + LatestApproval *Approval `gorm:"-" json:"-"` } diff --git a/internal/entities/projectflock_kandang.go b/internal/entities/projectflock_kandang.go index 1c29c22e..26238980 100644 --- a/internal/entities/projectflock_kandang.go +++ b/internal/entities/projectflock_kandang.go @@ -7,6 +7,9 @@ type ProjectFlockKandang struct { ProjectFlockId uint `gorm:"not null;index:idx_project_flock_kandangs_project;uniqueIndex:idx_project_flock_kandangs_unique"` KandangId uint `gorm:"not null;index:idx_project_flock_kandangs_kandang;uniqueIndex:idx_project_flock_kandangs_unique"` CreatedAt time.Time `gorm:"autoCreateTime"` - ProjectFlock ProjectFlock `gorm:"foreignKey:ProjectFlockId;references:Id"` - Kandang Kandang `gorm:"foreignKey:KandangId;references:Id"` + + + ProjectFlock ProjectFlock `gorm:"foreignKey:ProjectFlockId;references:Id"` + Kandang Kandang `gorm:"foreignKey:KandangId;references:Id"` + } diff --git a/internal/entities/recording.go b/internal/entities/recording.go index a6cf61b0..42535365 100644 --- a/internal/entities/recording.go +++ b/internal/entities/recording.go @@ -7,12 +7,28 @@ import ( ) type Recording struct { - Id uint `gorm:"primaryKey"` - Name string `gorm:"not null;uniqueIndex:idx_name,where:deleted_at IS NULL"` - CreatedBy uint `gorm:"not null"` - CreatedAt time.Time `gorm:"autoCreateTime"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + Id uint `gorm:"primaryKey"` + ProjectFlockKandangId uint `gorm:"column:project_flock_kandangs_id;not null;index"` + RecordDatetime time.Time `gorm:"column:record_datetime;not null"` + Day *int `gorm:"column:day"` + TotalDepletionQty *float64 `gorm:"column:total_depletion_qty"` + CumDepletionRate *float64 `gorm:"column:cum_depletion_rate"` + DailyGain *float64 `gorm:"column:daily_gain"` + AvgDailyGain *float64 `gorm:"column:avg_daily_gain"` + CumIntake *int `gorm:"column:cum_intake"` + FcrValue *float64 `gorm:"column:fcr_value"` + TotalChickQty *float64 `gorm:"column:total_chick_qty"` + CreatedBy uint `gorm:"column:created_by"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + ProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:ProjectFlockKandangId;references:Id"` + CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` + BodyWeights []RecordingBW `gorm:"foreignKey:RecordingId;references:Id"` + Depletions []RecordingDepletion `gorm:"foreignKey:RecordingId;references:Id"` + Stocks []RecordingStock `gorm:"foreignKey:RecordingId;references:Id"` + Eggs []RecordingEgg `gorm:"foreignKey:RecordingId;references:Id"` + + LatestApproval *Approval `gorm:"-" json:"-"` } diff --git a/internal/entities/recording_bw.go b/internal/entities/recording_bw.go new file mode 100644 index 00000000..041df0f6 --- /dev/null +++ b/internal/entities/recording_bw.go @@ -0,0 +1,15 @@ +package entities + +import "time" + +type RecordingBW struct { + Id uint `gorm:"primaryKey"` + RecordingId uint `gorm:"column:recording_id;not null;index"` + AvgWeight float64 `gorm:"column:avg_weight;not null"` + Qty float64 `gorm:"column:qty;not null"` + TotalWeight float64 `gorm:"column:total_weight;not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + + Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` +} diff --git a/internal/entities/recording_depletion.go b/internal/entities/recording_depletion.go new file mode 100644 index 00000000..53af300d --- /dev/null +++ b/internal/entities/recording_depletion.go @@ -0,0 +1,11 @@ +package entities + +type RecordingDepletion struct { + Id uint `gorm:"primaryKey"` + RecordingId uint `gorm:"column:recording_id;not null;index"` + ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` + Qty float64 `gorm:"column:qty;not null"` + + Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` + ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` +} diff --git a/internal/entities/recording_egg.go b/internal/entities/recording_egg.go new file mode 100644 index 00000000..28eafeb7 --- /dev/null +++ b/internal/entities/recording_egg.go @@ -0,0 +1,30 @@ +package entities + +import "time" + +type RecordingEgg struct { + Id uint `gorm:"primaryKey"` + RecordingId uint `gorm:"column:recording_id;not null;index"` + ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` + Qty int `gorm:"column:qty;not null"` + CreatedBy uint `gorm:"column:created_by"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + GradingEggs []GradingEgg `gorm:"foreignKey:RecordingEggId;references:Id"` + ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` + CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` + Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` +} + +type GradingEgg struct { + Id uint `gorm:"primaryKey"` + RecordingEggId uint `gorm:"column:recording_egg_id;not null;index"` + Qty float64 `gorm:"column:qty;not null"` + Grade string `gorm:"column:grade;type:varchar(50)"` + CreatedBy uint `gorm:"column:created_by"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + + RecordingEgg RecordingEgg `gorm:"foreignKey:RecordingEggId;references:Id"` + CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` +} diff --git a/internal/entities/recording_stock.go b/internal/entities/recording_stock.go new file mode 100644 index 00000000..982bba37 --- /dev/null +++ b/internal/entities/recording_stock.go @@ -0,0 +1,12 @@ +package entities + +type RecordingStock struct { + Id uint `gorm:"primaryKey"` + RecordingId uint `gorm:"column:recording_id;not null;index"` + ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` + UsageQty *float64 `gorm:"column:usage_qty"` + PendingQty *float64 `gorm:"column:pending_qty"` + + Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` + ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index d89dcb31..10f9a3f8 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -1,101 +1,193 @@ package middleware -// import ( -// "strings" +import ( + "strings" -// "gitlab.com/mbugroup/lti-api.git/internal/config" -// service "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" -// "gitlab.com/mbugroup/lti-api.git/internal/utils" + "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" + 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" -// "github.com/gofiber/fiber/v2" -// ) + "github.com/gofiber/fiber/v2" +) -// func Auth(userService service.UserService, requiredRights ...string) fiber.Handler { -// return func(c *fiber.Ctx) error { -// authHeader := c.Get("Authorization") -// token := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer ")) +const ( + authContextLocalsKey = "auth.context" + authUserLocalsKey = "auth.user" +) -// if token == "" { -// return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") -// } +// AuthContext keeps authentication details captured by the middleware. +type AuthContext struct { + Token string + Verification *sso.VerificationResult + User *entity.User + Roles []sso.Role + Permissions map[string]struct{} +} -// userID, err := utils.VerifyToken(token, config.JWTSecret, config.TokenTypeAccess) -// if err != nil { -// return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") -// } +// Auth validates the incoming request against the central SSO access token and +// loads the corresponding local user. Optional scopes can be provided to enforce +// fine-grained authorization using the SSO access token scopes. +func Auth(userService service.UserService, requiredScopes ...string) fiber.Handler { + return func(c *fiber.Ctx) error { + token := bearerToken(c) + if token == "" { + token = strings.TrimSpace(c.Cookies(config.SSOAccessCookieName)) + } + if token == "" { + return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") + } -// // Only end-user subjects are allowed by this middleware. Service tokens -// if verification.UserID == 0 { -// return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") -// } + verification, err := sso.VerifyAccessToken(token) + if err != nil { + utils.Log.WithError(err).Warn("auth: token verification failed") + return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") + } -// // Fail-closed on revocation check errors for stricter security posture. -// if revoker := session.GetRevocationStore(); revoker != nil { -// if fingerprint := session.TokenFingerprint(token); fingerprint != "" { -// revoked, err := revoker.IsRevoked(c.Context(), fingerprint) -// if err != nil { -// utils.Log.WithError(err).Warn("failed to check token revocation") -// return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") -// } -// if revoked { -// return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") -// } -// } -// } + if verification.UserID == 0 { + return fiber.NewError(fiber.StatusForbidden, "Service authentication is not permitted for this endpoint") + } -// user, err := userService.GetBySSOUserID(c, verification.UserID) -// if err != nil || user == nil { -// return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") -// } + if err := ensureNotRevoked(c, token, verification); err != nil { + return err + } -// if len(requiredRights) > 0 && verification.Claims != nil { -// if !hasAllScopes(verification.Claims.Scopes(), requiredRights) { -// return fiber.NewError(fiber.StatusForbidden, "Insufficient scope") -// } -// } + user, err := userService.GetBySSOUserID(c, verification.UserID) + if err != nil || user == nil { + utils.Log.WithError(err).Warn("auth: failed to resolve user from repository") + return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") + } -// c.Locals("user", user) + if len(requiredScopes) > 0 { + if verification.Claims == nil || !hasAllScopes(verification.Claims.Scopes(), requiredScopes) { + return fiber.NewError(fiber.StatusForbidden, "Insufficient scope") + } + } -// // if len(requiredRights) > 0 { -// // userRights, hasRights := config.RoleRights[user.Role] -// // if (!hasRights || !hasAllRights(userRights, requiredRights)) && c.Params("userId") != userID { -// // return fiber.NewError(fiber.StatusForbidden, "You don't have permission to access this resource") -// // } -// // } + var roles []sso.Role + permissions := make(map[string]struct{}) + if verification.UserID != 0 { + if profile, 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{}{} + } + } + } + } -// return c.Next() -// } -// } + ctx := &AuthContext{ + Token: token, + Verification: verification, + User: user, + Roles: roles, + Permissions: permissions, + } -// // bearerToken extracts a Bearer token from the Authorization header using -// // case-insensitive scheme matching and tolerant whitespace handling. -// func bearerToken(c *fiber.Ctx) string { -// parts := strings.Fields(c.Get("Authorization")) -// if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") { -// return strings.TrimSpace(parts[1]) -// } -// return "" -// } + c.Locals(authContextLocalsKey, ctx) + c.Locals(authUserLocalsKey, user) -// func hasAllScopes(have, required []string) bool { -// if len(required) == 0 { -// return true -// } -// set := make(map[string]struct{}, len(have)) -// for _, s := range have { -// s = strings.ToLower(strings.TrimSpace(s)) -// if s != "" { -// set[s] = struct{}{} -// } -// } -// for _, r := range required { -// r = strings.ToLower(strings.TrimSpace(r)) -// if r == "" { -// continue -// } -// if _, ok := set[r]; !ok { -// return false -// } -// } -// return true -// } + return c.Next() + } +} + +// AuthenticatedUser returns the authenticated user populated by Auth. +func AuthenticatedUser(c *fiber.Ctx) (*entity.User, bool) { + value := c.Locals(authUserLocalsKey) + if user, ok := value.(*entity.User); ok && user != nil { + return user, true + } + return nil, false +} + +// AuthDetails returns the full authentication context (token, claims, user). +func AuthDetails(c *fiber.Ctx) (*AuthContext, bool) { + value := c.Locals(authContextLocalsKey) + if ctx, ok := value.(*AuthContext); ok && ctx != nil { + return ctx, true + } + return nil, false +} + +// ensureNotRevoked ensures the token is not revoked or superseded by a forced logout. +func ensureNotRevoked(c *fiber.Ctx, token string, verification *sso.VerificationResult) error { + revoker := session.GetRevocationStore() + if revoker == nil { + return nil + } + + if fingerprint := session.TokenFingerprint(token); fingerprint != "" { + revoked, err := revoker.IsRevoked(c.Context(), fingerprint) + if err != nil { + utils.Log.WithError(err).Warn("auth: token revocation check failed") + return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") + } + if revoked { + return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") + } + } + + if verification.UserID == 0 { + return nil + } + + logoutAt, err := revoker.UserLogoutTime(c.Context(), verification.UserID) + if err != nil { + utils.Log.WithError(err).Warn("auth: failed to load user logout marker") + return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") + } + if logoutAt.IsZero() { + return nil + } + + claims := verification.Claims + if claims == nil || claims.IssuedAt == nil { + return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") + } + + issuedAt := claims.IssuedAt.Time + // Treat tokens issued at or before the forced logout timestamp as invalid. + if !issuedAt.After(logoutAt) { + return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") + } + + return nil +} + +// bearerToken extracts a Bearer token from the Authorization header using +// case-insensitive scheme matching and tolerant whitespace handling. +func bearerToken(c *fiber.Ctx) string { + parts := strings.Fields(c.Get("Authorization")) + if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") { + return strings.TrimSpace(parts[1]) + } + return "" +} + +func hasAllScopes(have, required []string) bool { + if len(required) == 0 { + return true + } + set := make(map[string]struct{}, len(have)) + for _, s := range have { + s = strings.ToLower(strings.TrimSpace(s)) + if s != "" { + set[s] = struct{}{} + } + } + for _, r := range required { + r = strings.ToLower(strings.TrimSpace(r)) + if r == "" { + continue + } + if _, ok := set[r]; !ok { + return false + } + } + return true +} diff --git a/internal/middleware/permissions.go b/internal/middleware/permissions.go new file mode 100644 index 00000000..3ebe6866 --- /dev/null +++ b/internal/middleware/permissions.go @@ -0,0 +1,75 @@ +package middleware + +import ( + "strings" + + "github.com/gofiber/fiber/v2" +) + +// RequirePermissions ensures the authenticated user possesses all specified permissions. +func RequirePermissions(perms ...string) fiber.Handler { + required := canonicalPermissions(perms) + return func(c *fiber.Ctx) error { + if len(required) == 0 { + return c.Next() + } + + ctx, ok := AuthDetails(c) + if !ok || ctx == nil { + return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") + } + + userPerms := ctx.permissionSet() + if len(userPerms) == 0 { + return fiber.NewError(fiber.StatusForbidden, "Insufficient permission") + } + + for _, perm := range required { + if _, has := userPerms[perm]; !has { + return fiber.NewError(fiber.StatusForbidden, "Insufficient permission") + } + } + + return c.Next() + } +} + +// HasPermission reports whether the current request context includes the given permission. +func HasPermission(c *fiber.Ctx, perm string) bool { + ctx, ok := AuthDetails(c) + if !ok || ctx == nil { + return false + } + perm = canonicalPermission(perm) + if perm == "" { + return false + } + _, has := ctx.permissionSet()[perm] + return has +} + +func (a *AuthContext) permissionSet() map[string]struct{} { + if a == nil || a.Permissions == nil { + return nil + } + return a.Permissions +} + +func canonicalPermissions(perms []string) []string { + out := make([]string, 0, len(perms)) + seen := make(map[string]struct{}, len(perms)) + for _, perm := range perms { + if canonical := canonicalPermission(perm); canonical != "" { + if _, ok := seen[canonical]; ok { + continue + } + seen[canonical] = struct{}{} + out = append(out, canonical) + } + } + return out +} + +func canonicalPermission(perm string) string { + return strings.ToLower(strings.TrimSpace(perm)) +} diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index 7a2d06bc..e1c4166d 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -202,21 +202,7 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu if query.TransactionType != "" { db = db.Where("transaction_type = ?", strings.ToUpper(query.TransactionType)) } - if query.ProductID > 0 { - db = db.Joins("JOIN product_warehouses ON product_warehouses.id = stock_logs.product_warehouse_id"). - Where("product_warehouses.product_id = ?", query.ProductID) - } - - if query.WarehouseID > 0 { - if query.ProductID > 0 { - - db = db.Where("product_warehouses.warehouse_id = ?", query.WarehouseID) - } else { - - db = db.Joins("JOIN product_warehouses ON product_warehouses.id = stock_logs.product_warehouse_id"). - Where("product_warehouses.warehouse_id = ?", query.WarehouseID) - } - } + db = s.StockLogsRepository.ApplyProductWarehouseFilters(db, uint(query.ProductID), uint(query.WarehouseID)) return db.Order("created_at DESC") }) 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 a0b72a4d..26f23278 100644 --- a/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go +++ b/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go @@ -28,6 +28,11 @@ func (u *ProductWarehouseController) GetAll(c *fiber.Ctx) error { Limit: c.QueryInt("limit", 10), ProductId: uint(c.QueryInt("product_id", 0)), WarehouseId: uint(c.QueryInt("warehouse_id", 0)), + Flags: c.Query("flags", ""), + } + + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") } result, totalResults, err := u.ProductWarehouseService.GetAll(c, query) @@ -71,5 +76,3 @@ func (u *ProductWarehouseController) GetOne(c *fiber.Ctx) error { Data: dto.ToProductWarehouseListDTO(*result), }) } - - diff --git a/internal/modules/inventory/product-warehouses/module.go b/internal/modules/inventory/product-warehouses/module.go index dfb72e8f..378522c5 100644 --- a/internal/modules/inventory/product-warehouses/module.go +++ b/internal/modules/inventory/product-warehouses/module.go @@ -23,4 +23,3 @@ func (ProductWarehouseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, v ProductWarehouseRoutes(router, userService, productWarehouseService) } - diff --git a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go index cc4adf64..23cabb68 100644 --- a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go +++ b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go @@ -2,6 +2,7 @@ package repository import ( "context" + "fmt" "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -16,23 +17,36 @@ type ProductWarehouseRepository interface { ProductWarehouseExistByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (bool, error) ExistsByID(ctx context.Context, id uint) (bool, error) GetProductWarehouseByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (*entity.ProductWarehouse, error) + GetByCategoryCodeAndWarehouseID(ctx context.Context, categoryCode string, warehouseId uint) ([]entity.ProductWarehouse, error) + GetLatestByCategoryCodeAndWarehouseID(ctx context.Context, categoryCode string, warehouseId uint, db *gorm.DB) (*entity.ProductWarehouse, error) + ApplyFlagsFilter(db *gorm.DB, flags []string) *gorm.DB + AdjustQuantities(ctx context.Context, deltas map[uint]float64, modifier func(*gorm.DB) *gorm.DB) error } type ProductWarehouseRepositoryImpl struct { *repository.BaseRepositoryImpl[entity.ProductWarehouse] - db *gorm.DB } func NewProductWarehouseRepository(db *gorm.DB) ProductWarehouseRepository { return &ProductWarehouseRepositoryImpl{ BaseRepositoryImpl: repository.NewBaseRepository[entity.ProductWarehouse](db), - db: db, } } +func (r *ProductWarehouseRepositoryImpl) IsProductExist(ctx context.Context, productId uint) (bool, error) { + return repository.Exists[entity.Product](ctx, r.DB(), productId) +} +func (r *ProductWarehouseRepositoryImpl) IsWarehouseExist(ctx context.Context, warehouseId uint) (bool, error) { + return repository.Exists[entity.Warehouse](ctx, r.DB(), warehouseId) +} + +func (r *ProductWarehouseRepositoryImpl) ExistsByID(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.ProductWarehouse](ctx, r.DB(), id) +} + func (r *ProductWarehouseRepositoryImpl) ProductWarehouseExists(ctx context.Context, productId, warehouseId uint, excludeID *uint) (bool, error) { var count int64 - query := r.db.WithContext(ctx).Model(&entity.ProductWarehouse{}). + query := r.DB().WithContext(ctx).Model(&entity.ProductWarehouse{}). Where("product_id = ? AND warehouse_id = ?", productId, warehouseId) if excludeID != nil { query = query.Where("id != ?", *excludeID) @@ -43,20 +57,9 @@ func (r *ProductWarehouseRepositoryImpl) ProductWarehouseExists(ctx context.Cont return count > 0, nil } -func (r *ProductWarehouseRepositoryImpl) IsProductExist(ctx context.Context, productId uint) (bool, error) { - return repository.Exists[entity.Product](ctx, r.db, productId) -} -func (r *ProductWarehouseRepositoryImpl) IsWarehouseExist(ctx context.Context, warehouseId uint) (bool, error) { - return repository.Exists[entity.Warehouse](ctx, r.db, warehouseId) -} - -func (r *ProductWarehouseRepositoryImpl) ExistsByID(ctx context.Context, id uint) (bool, error) { - return repository.Exists[entity.ProductWarehouse](ctx, r.db, id) -} - func (r *ProductWarehouseRepositoryImpl) ProductWarehouseExistByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (bool, error) { var count int64 - if err := r.db.WithContext(ctx). + if err := r.DB().WithContext(ctx). Model(&entity.ProductWarehouse{}). Where("product_id = ? AND warehouse_id = ?", productId, warehouseId). Count(&count).Error; err != nil { @@ -72,3 +75,74 @@ func (r *ProductWarehouseRepositoryImpl) GetProductWarehouseByProductAndWarehous } return &productWarehouse, nil } + +func (r *ProductWarehouseRepositoryImpl) GetByCategoryCodeAndWarehouseID(ctx context.Context, categoryCode string, warehouseId uint) ([]entity.ProductWarehouse, error) { + var productWarehouses []entity.ProductWarehouse + err := r.DB().WithContext(ctx). + Table("product_warehouses"). + Select("product_warehouses.*"). + Joins("JOIN products ON products.id = product_warehouses.product_id"). + Joins("JOIN product_categories ON product_categories.id = products.product_category_id"). + Where("product_categories.code = ? AND product_warehouses.warehouse_id = ?", categoryCode, warehouseId). + Order("product_warehouses.created_at DESC"). + Find(&productWarehouses).Error + if err != nil { + return nil, err + } + return productWarehouses, nil +} + +func (r *ProductWarehouseRepositoryImpl) GetLatestByCategoryCodeAndWarehouseID(ctx context.Context, categoryCode string, warehouseId uint, db *gorm.DB) (*entity.ProductWarehouse, error) { + var productWarehouse entity.ProductWarehouse + query := r.DB() + if db != nil { + query = db + } + fmt.Println(warehouseId) + err := query.WithContext(ctx). + Table("product_warehouses"). + Select("product_warehouses.*"). + Joins("JOIN products ON products.id = product_warehouses.product_id"). + Joins("JOIN product_categories ON product_categories.id = products.product_category_id"). + Where("product_categories.code = ? AND product_warehouses.warehouse_id = ?", categoryCode, warehouseId). + Order("product_warehouses.created_at DESC"). + First(&productWarehouse).Error + if err != nil { + return nil, err + } + return &productWarehouse, nil +} + +func (r *ProductWarehouseRepositoryImpl) ApplyFlagsFilter(db *gorm.DB, flags []string) *gorm.DB { + if len(flags) == 0 { + return db + } + + return db. + Joins("JOIN products ON products.id = product_warehouses.product_id"). + Joins("JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = ?", "products"). + Where("flags.name IN ?", flags) +} + +func (r *ProductWarehouseRepositoryImpl) AdjustQuantities(ctx context.Context, deltas map[uint]float64, modifier func(*gorm.DB) *gorm.DB) error { + if len(deltas) == 0 { + return nil + } + + base := r.DB().WithContext(ctx) + if modifier != nil { + base = modifier(base) + } + + for id, delta := range deltas { + if delta == 0 { + continue + } + if err := base.Model(&entity.ProductWarehouse{}). + Where("id = ?", id). + Update("quantity", gorm.Expr("COALESCE(quantity,0) + ?", delta)).Error; err != nil { + return err + } + } + return nil +} diff --git a/internal/modules/inventory/product-warehouses/route.go b/internal/modules/inventory/product-warehouses/route.go index 429c1d16..9c6c8e2b 100644 --- a/internal/modules/inventory/product-warehouses/route.go +++ b/internal/modules/inventory/product-warehouses/route.go @@ -1,7 +1,7 @@ package productWarehouses import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/controllers" productWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,12 +13,7 @@ func ProductWarehouseRoutes(v1 fiber.Router, u user.UserService, s productWareho ctrl := controller.NewProductWarehouseController(s) route := v1.Group("/product-warehouses") - - // route.Get("/", m.Auth(u), ctrl.GetAll) - // route.Post("/", m.Auth(u), ctrl.CreateOne) - // route.Get("/:id", m.Auth(u), ctrl.GetOne) - // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) - // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) route.Get("/:id", ctrl.GetOne) 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 4fad5dc5..cc925970 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -49,8 +49,30 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) return nil, 0, err } + if params.ProductId > 0 { + isProductExist, err := s.Repository.IsProductExist(c.Context(), params.ProductId) + if err != nil { + return nil, 0, err + } + if !isProductExist { + return nil, 0, fiber.NewError(fiber.StatusNotFound, "Product not found") + } + } + + if params.WarehouseId > 0 { + isWarehouseExist, err := s.Repository.IsWarehouseExist(c.Context(), params.WarehouseId) + if err != nil { + return nil, 0, err + } + if !isWarehouseExist { + return nil, 0, fiber.NewError(fiber.StatusNotFound, "Warehouse not found") + } + } + offset := (params.Page - 1) * params.Limit + cleanFlags := utils.ParseFlags(params.Flags) + productWarehouses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) @@ -62,6 +84,8 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) db = db.Where("warehouse_id = ?", params.WarehouseId) } + db = s.Repository.ApplyFlagsFilter(db, cleanFlags) + return db.Order("created_at DESC").Order("updated_at DESC") }) 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 02648300..3a3acb28 100644 --- a/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go +++ b/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go @@ -13,8 +13,9 @@ 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"` + 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"` } diff --git a/internal/modules/inventory/route.go b/internal/modules/inventory/route.go index fcb7881a..a0e98154 100644 --- a/internal/modules/inventory/route.go +++ b/internal/modules/inventory/route.go @@ -7,8 +7,8 @@ import ( "github.com/gofiber/fiber/v2" "gorm.io/gorm" - productWarehouses "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses" adjustments "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments" + productWarehouses "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses" transfers "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers" // MODULE IMPORTS ) diff --git a/internal/modules/inventory/transfers/route.go b/internal/modules/inventory/transfers/route.go index 544a0674..f608af42 100644 --- a/internal/modules/inventory/transfers/route.go +++ b/internal/modules/inventory/transfers/route.go @@ -1,7 +1,7 @@ package transfers import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/controllers" transfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,12 +13,7 @@ func TransferRoutes(v1 fiber.Router, u user.UserService, s transfer.TransferServ ctrl := controller.NewTransferController(s) route := v1.Group("/transfers") - - // route.Get("/", m.Auth(u), ctrl.GetAll) - // route.Post("/", m.Auth(u), ctrl.CreateOne) - // route.Get("/:id", m.Auth(u), ctrl.GetOne) - // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) - // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) route.Post("/", ctrl.CreateOne) diff --git a/internal/modules/master/areas/controllers/area.controller.go b/internal/modules/master/areas/controllers/area.controller.go index e08dba7d..252bc769 100644 --- a/internal/modules/master/areas/controllers/area.controller.go +++ b/internal/modules/master/areas/controllers/area.controller.go @@ -29,6 +29,10 @@ func (u *AreaController) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.AreaService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/areas/module.go b/internal/modules/master/areas/module.go index 0d9d4f4e..8ef790e8 100644 --- a/internal/modules/master/areas/module.go +++ b/internal/modules/master/areas/module.go @@ -23,4 +23,3 @@ func (AreaModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *val AreaRoutes(router, userService, areaService) } - diff --git a/internal/modules/master/areas/route.go b/internal/modules/master/areas/route.go index 71d4980d..755a542e 100644 --- a/internal/modules/master/areas/route.go +++ b/internal/modules/master/areas/route.go @@ -1,7 +1,7 @@ package areas import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/controllers" area "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,12 +13,7 @@ func AreaRoutes(v1 fiber.Router, u user.UserService, s area.AreaService) { ctrl := controller.NewAreaController(s) route := v1.Group("/areas") - - // route.Get("/", m.Auth(u), ctrl.GetAll) - // route.Post("/", m.Auth(u), ctrl.CreateOne) - // route.Get("/:id", m.Auth(u), ctrl.GetOne) - // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) - // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) route.Post("/", ctrl.CreateOne) diff --git a/internal/modules/master/banks/controllers/bank.controller.go b/internal/modules/master/banks/controllers/bank.controller.go index 7625d078..ffe61cea 100644 --- a/internal/modules/master/banks/controllers/bank.controller.go +++ b/internal/modules/master/banks/controllers/bank.controller.go @@ -29,6 +29,10 @@ func (u *BankController) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.BankService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/banks/module.go b/internal/modules/master/banks/module.go index cb2f4540..c7283d93 100644 --- a/internal/modules/master/banks/module.go +++ b/internal/modules/master/banks/module.go @@ -23,4 +23,3 @@ func (BankModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *val BankRoutes(router, userService, bankService) } - diff --git a/internal/modules/master/banks/repositories/bank.repository.go b/internal/modules/master/banks/repositories/bank.repository.go index 53d27713..d309d3c1 100644 --- a/internal/modules/master/banks/repositories/bank.repository.go +++ b/internal/modules/master/banks/repositories/bank.repository.go @@ -11,6 +11,7 @@ import ( type BankRepository interface { repository.BaseRepository[entity.Bank] NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) + AccountNumberExists(ctx context.Context, accountNumber string, excludeID *uint) (bool, error) } type BankRepositoryImpl struct { @@ -28,3 +29,7 @@ func NewBankRepository(db *gorm.DB) BankRepository { func (r *BankRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) { return repository.ExistsByName[entity.Bank](ctx, r.db, name, excludeID) } + +func (r *BankRepositoryImpl) AccountNumberExists(ctx context.Context, accountNumber string, excludeID *uint) (bool, error) { + return repository.ExistsByField[entity.Bank](ctx, r.db, "account_number", accountNumber, excludeID) +} diff --git a/internal/modules/master/banks/route.go b/internal/modules/master/banks/route.go index 00b7694d..2e5bed3b 100644 --- a/internal/modules/master/banks/route.go +++ b/internal/modules/master/banks/route.go @@ -1,7 +1,7 @@ package banks import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks/controllers" bank "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,12 +13,7 @@ func BankRoutes(v1 fiber.Router, u user.UserService, s bank.BankService) { ctrl := controller.NewBankController(s) route := v1.Group("/banks") - - // route.Get("/", m.Auth(u), ctrl.GetAll) - // route.Post("/", m.Auth(u), ctrl.CreateOne) - // route.Get("/:id", m.Auth(u), ctrl.GetOne) - // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) - // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) route.Post("/", ctrl.CreateOne) diff --git a/internal/modules/master/banks/services/bank.service.go b/internal/modules/master/banks/services/bank.service.go index b62bf864..83d3029d 100644 --- a/internal/modules/master/banks/services/bank.service.go +++ b/internal/modules/master/banks/services/bank.service.go @@ -87,6 +87,13 @@ func (s *bankService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.B return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Bank with name %s already exists", req.Name)) } + if exists, err := s.Repository.AccountNumberExists(c.Context(), req.AccountNumber, nil); err != nil { + s.Log.Errorf("Failed to check bank account number: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check bank account number") + } else if exists { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Bank with account number %s already exists", req.AccountNumber)) + } + createBody := &entity.Bank{ Name: req.Name, Alias: req.Alias, diff --git a/internal/modules/master/customers/controllers/customer.controller.go b/internal/modules/master/customers/controllers/customer.controller.go index 2f9c0ed4..02805f6f 100644 --- a/internal/modules/master/customers/controllers/customer.controller.go +++ b/internal/modules/master/customers/controllers/customer.controller.go @@ -29,6 +29,10 @@ func (u *CustomerController) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.CustomerService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/customers/module.go b/internal/modules/master/customers/module.go index 21262bfa..6d541539 100644 --- a/internal/modules/master/customers/module.go +++ b/internal/modules/master/customers/module.go @@ -23,4 +23,3 @@ func (CustomerModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate CustomerRoutes(router, userService, customerService) } - diff --git a/internal/modules/master/customers/route.go b/internal/modules/master/customers/route.go index 54df1345..d361e167 100644 --- a/internal/modules/master/customers/route.go +++ b/internal/modules/master/customers/route.go @@ -1,7 +1,7 @@ package customers import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/controllers" customer "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,12 +13,7 @@ func CustomerRoutes(v1 fiber.Router, u user.UserService, s customer.CustomerServ ctrl := controller.NewCustomerController(s) route := v1.Group("/customers") - - // route.Get("/", m.Auth(u), ctrl.GetAll) - // route.Post("/", m.Auth(u), ctrl.CreateOne) - // route.Get("/:id", m.Auth(u), ctrl.GetOne) - // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) - // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) route.Post("/", ctrl.CreateOne) diff --git a/internal/modules/master/fcrs/controllers/fcr.controller.go b/internal/modules/master/fcrs/controllers/fcr.controller.go index 33353ffa..52db463d 100644 --- a/internal/modules/master/fcrs/controllers/fcr.controller.go +++ b/internal/modules/master/fcrs/controllers/fcr.controller.go @@ -29,6 +29,10 @@ func (u *FcrController) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.FcrService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/fcrs/route.go b/internal/modules/master/fcrs/route.go index 27863784..60633f16 100644 --- a/internal/modules/master/fcrs/route.go +++ b/internal/modules/master/fcrs/route.go @@ -1,7 +1,7 @@ package fcrs import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs/controllers" fcr "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,12 +13,7 @@ func FcrRoutes(v1 fiber.Router, u user.UserService, s fcr.FcrService) { ctrl := controller.NewFcrController(s) route := v1.Group("/fcrs") - - // route.Get("/", m.Auth(u), ctrl.GetAll) - // route.Post("/", m.Auth(u), ctrl.CreateOne) - // route.Get("/:id", m.Auth(u), ctrl.GetOne) - // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) - // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) route.Post("/", ctrl.CreateOne) diff --git a/internal/modules/master/flocks/controllers/flock.controller.go b/internal/modules/master/flocks/controllers/flock.controller.go index 8265f3e4..f8df0587 100644 --- a/internal/modules/master/flocks/controllers/flock.controller.go +++ b/internal/modules/master/flocks/controllers/flock.controller.go @@ -29,6 +29,10 @@ func (u *FlockController) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.FlockService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/flocks/dto/flock.dto.go b/internal/modules/master/flocks/dto/flock.dto.go index 10e6f555..8038ddb0 100644 --- a/internal/modules/master/flocks/dto/flock.dto.go +++ b/internal/modules/master/flocks/dto/flock.dto.go @@ -43,9 +43,9 @@ func ToFlockListDTO(e entity.Flock) FlockListDTO { return FlockListDTO{ FlockBaseDTO: ToFlockBaseDTO(e), - CreatedAt: e.CreatedAt, - UpdatedAt: e.UpdatedAt, - CreatedUser: createdUser, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, } } diff --git a/internal/modules/master/flocks/repositories/flock.repository.go b/internal/modules/master/flocks/repositories/flock.repository.go index 006fe541..5c7e7ca8 100644 --- a/internal/modules/master/flocks/repositories/flock.repository.go +++ b/internal/modules/master/flocks/repositories/flock.repository.go @@ -11,6 +11,7 @@ import ( type FlockRepository interface { repository.BaseRepository[entity.Flock] NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) + GetByName(ctx context.Context, name string) (*entity.Flock, error) } type FlockRepositoryImpl struct { @@ -28,3 +29,15 @@ func NewFlockRepository(db *gorm.DB) FlockRepository { func (r *FlockRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) { return repository.ExistsByName[entity.Flock](ctx, r.db, name, excludeID) } + +func (r *FlockRepositoryImpl) GetByName(ctx context.Context, name string) (*entity.Flock, error) { + var flock entity.Flock + err := r.db.WithContext(ctx). + Where("LOWER(name) = LOWER(?)", name). + Where("deleted_at IS NULL"). + First(&flock).Error + if err != nil { + return nil, err + } + return &flock, nil +} diff --git a/internal/modules/master/flocks/route.go b/internal/modules/master/flocks/route.go index 6d93827d..429d8dcd 100644 --- a/internal/modules/master/flocks/route.go +++ b/internal/modules/master/flocks/route.go @@ -1,7 +1,7 @@ package flocks import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/controllers" flock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,12 +13,7 @@ func FlockRoutes(v1 fiber.Router, u user.UserService, s flock.FlockService) { ctrl := controller.NewFlockController(s) route := v1.Group("/flocks") - - // route.Get("/", m.Auth(u), ctrl.GetAll) - // route.Post("/", m.Auth(u), ctrl.CreateOne) - // route.Get("/:id", m.Auth(u), ctrl.GetOne) - // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) - // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) route.Post("/", ctrl.CreateOne) diff --git a/internal/modules/master/flocks/validations/floc.validation.go b/internal/modules/master/flocks/validations/floc.validation.go index 95505746..56bbd601 100644 --- a/internal/modules/master/flocks/validations/floc.validation.go +++ b/internal/modules/master/flocks/validations/floc.validation.go @@ -1,11 +1,11 @@ package validation type Create struct { - Name string `json:"name" validate:"required_strict,min=3"` + Name string `json:"name" validate:"required_strict,min=3"` } type Update struct { - Name *string `json:"name,omitempty" validate:"omitempty"` + Name *string `json:"name,omitempty" validate:"omitempty"` } type Query struct { diff --git a/internal/modules/master/kandangs/controllers/kandang.controller.go b/internal/modules/master/kandangs/controllers/kandang.controller.go index 23d22334..b1d016df 100644 --- a/internal/modules/master/kandangs/controllers/kandang.controller.go +++ b/internal/modules/master/kandangs/controllers/kandang.controller.go @@ -31,6 +31,10 @@ func (u *KandangController) GetAll(c *fiber.Ctx) error { PicId: c.QueryInt("pic_id", 0), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.KandangService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/kandangs/module.go b/internal/modules/master/kandangs/module.go index b831e322..005cc1a8 100644 --- a/internal/modules/master/kandangs/module.go +++ b/internal/modules/master/kandangs/module.go @@ -23,4 +23,3 @@ func (KandangModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * KandangRoutes(router, userService, kandangService) } - diff --git a/internal/modules/master/kandangs/repositories/kandang.repository.go b/internal/modules/master/kandangs/repositories/kandang.repository.go index 22546339..8f32a7b2 100644 --- a/internal/modules/master/kandangs/repositories/kandang.repository.go +++ b/internal/modules/master/kandangs/repositories/kandang.repository.go @@ -2,6 +2,7 @@ package repository import ( "context" + "errors" "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -18,6 +19,8 @@ type KandangRepository interface { GetFirstByProjectFlockID(ctx context.Context, projectFlockID uint) (*entity.Kandang, error) HasActiveKandangForProjectFlock(ctx context.Context, projectFlockID uint, excludeID *uint) (bool, error) UpdateStatusByProjectFlockID(ctx context.Context, projectFlockID uint, status utils.KandangStatus) error + UpsertProjectFlockKandang(ctx context.Context, projectFlockID, kandangID uint) error + UpdateStatusByIDs(ctx context.Context, kandangIDs []uint, status utils.KandangStatus) error } type KandangRepositoryImpl struct { @@ -59,12 +62,13 @@ func (r *KandangRepositoryImpl) ProjectFlockExists(ctx context.Context, projectF func (r *KandangRepositoryImpl) HasActiveKandangForProjectFlock(ctx context.Context, projectFlockID uint, excludeID *uint) (bool, error) { var count int64 q := r.db.WithContext(ctx). - Model(&entity.Kandang{}). - Where("project_flock_id = ?", projectFlockID). - Where("status = ?", utils.KandangStatusActive). - Where("deleted_at IS NULL") + Table("kandangs k"). + Joins("JOIN project_flock_kandangs pfk ON pfk.kandang_id = k.id"). + Where("pfk.project_flock_id = ?", projectFlockID). + Where("k.status = ?", utils.KandangStatusActive). + Where("k.deleted_at IS NULL") if excludeID != nil { - q = q.Where("id <> ?", *excludeID) + q = q.Where("k.id <> ?", *excludeID) } if err := q.Count(&count).Error; err != nil { return false, err @@ -75,17 +79,58 @@ func (r *KandangRepositoryImpl) HasActiveKandangForProjectFlock(ctx context.Cont func (r *KandangRepositoryImpl) GetFirstByProjectFlockID(ctx context.Context, projectFlockID uint) (*entity.Kandang, error) { kandang := new(entity.Kandang) err := r.db.WithContext(ctx). - Where("project_flock_id = ?", projectFlockID). - First(kandang).Error + Table("kandangs k"). + Joins("JOIN project_flock_kandangs pfk ON pfk.kandang_id = k.id"). + Where("pfk.project_flock_id = ?", projectFlockID). + Where("k.deleted_at IS NULL"). + Order("k.id ASC"). + Limit(1). + Find(kandang).Error if err != nil { return nil, err } + if kandang.Id == 0 { + return nil, gorm.ErrRecordNotFound + } + return kandang, nil } func (r *KandangRepositoryImpl) UpdateStatusByProjectFlockID(ctx context.Context, projectFlockID uint, status utils.KandangStatus) error { + sub := r.db.WithContext(ctx). + Table("project_flock_kandangs"). + Select("kandang_id"). + Where("project_flock_id = ?", projectFlockID) + return r.db.WithContext(ctx). Model(&entity.Kandang{}). - Where("project_flock_id = ?", projectFlockID). + Where("id IN (?)", sub). + Where("deleted_at IS NULL"). + Update("status", string(status)).Error +} + +func (r *KandangRepositoryImpl) UpsertProjectFlockKandang(ctx context.Context, projectFlockID, kandangID uint) error { + var link entity.ProjectFlockKandang + err := r.db.WithContext(ctx). + Where("project_flock_id = ? AND kandang_id = ?", projectFlockID, kandangID). + First(&link).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + link = entity.ProjectFlockKandang{ + ProjectFlockId: projectFlockID, + KandangId: kandangID, + } + return r.db.WithContext(ctx).Create(&link).Error + } + return err +} + +func (r *KandangRepositoryImpl) UpdateStatusByIDs(ctx context.Context, kandangIDs []uint, status utils.KandangStatus) error { + if len(kandangIDs) == 0 { + return nil + } + return r.db.WithContext(ctx). + Model(&entity.Kandang{}). + Where("id IN ?", kandangIDs). + Where("deleted_at IS NULL"). Update("status", string(status)).Error } diff --git a/internal/modules/master/kandangs/route.go b/internal/modules/master/kandangs/route.go index bf41b4ee..6a425b64 100644 --- a/internal/modules/master/kandangs/route.go +++ b/internal/modules/master/kandangs/route.go @@ -1,7 +1,7 @@ package kandangs import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/controllers" kandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,12 +13,7 @@ func KandangRoutes(v1 fiber.Router, u user.UserService, s kandang.KandangService ctrl := controller.NewKandangController(s) route := v1.Group("/kandangs") - - // route.Get("/", m.Auth(u), ctrl.GetAll) - // route.Post("/", m.Auth(u), ctrl.CreateOne) - // route.Get("/:id", m.Auth(u), ctrl.GetOne) - // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) - // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) route.Post("/", ctrl.CreateOne) diff --git a/internal/modules/master/kandangs/services/kandang.service.go b/internal/modules/master/kandangs/services/kandang.service.go index 6e836170..1c0eed6a 100644 --- a/internal/modules/master/kandangs/services/kandang.service.go +++ b/internal/modules/master/kandangs/services/kandang.service.go @@ -40,7 +40,8 @@ func NewKandangService(repo repository.KandangRepository, validate *validator.Va } func (s kandangService) withRelations(db *gorm.DB) *gorm.DB { - return db.Preload("CreatedUser").Preload("Location").Preload("Pic") + return db.Preload("CreatedUser").Preload("Location").Preload("Pic").Preload("ProjectFlockKandangs.ProjectFlock") + } func (s kandangService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Kandang, int64, error) { @@ -110,7 +111,6 @@ func (s *kandangService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid kandang status") } - var projectFlockID *uint if req.ProjectFlockId != nil { if exists, err := s.Repository.ProjectFlockExists(c.Context(), *req.ProjectFlockId); err != nil { s.Log.Errorf("Failed to check project flock existence: %+v", err) @@ -128,18 +128,15 @@ func (s *kandangService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit } } - idCopy := *req.ProjectFlockId - projectFlockID = &idCopy } //TODO: created by dummy createBody := &entity.Kandang{ - Name: req.Name, - LocationId: req.LocationId, - Status: status, - PicId: req.PicId, - ProjectFlockId: projectFlockID, - CreatedBy: 1, + Name: req.Name, + LocationId: req.LocationId, + Status: status, + PicId: req.PicId, + CreatedBy: 1, } if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { @@ -147,6 +144,12 @@ func (s *kandangService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit return nil, err } + if req.ProjectFlockId != nil { + if err := s.Repository.UpsertProjectFlockKandang(c.Context(), *req.ProjectFlockId, createBody.Id); err != nil { + s.Log.Errorf("Failed to link kandang to project_flock via pivot: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to link kandang to project flock") + } + } return s.GetOne(c, createBody.Id) } @@ -201,7 +204,6 @@ func (s kandangService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) finalStatus = status } - projectFlockIDToUse := existing.ProjectFlockId if req.ProjectFlockId != nil { if exists, err := s.Repository.ProjectFlockExists(c.Context(), *req.ProjectFlockId); err != nil { s.Log.Errorf("Failed to check project flock existence: %+v", err) @@ -209,30 +211,33 @@ func (s kandangService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) } else if !exists { return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Project flock with id %d not found", *req.ProjectFlockId)) } - idCopy := *req.ProjectFlockId - projectFlockIDToUse = &idCopy - updateBody["project_flock_id"] = idCopy - } - if projectFlockIDToUse != nil && finalStatus == string(utils.KandangStatusActive) { - if active, err := s.Repository.HasActiveKandangForProjectFlock(c.Context(), *projectFlockIDToUse, &id); err != nil { - s.Log.Errorf("Failed to check kandang activity for project flock %d: %+v", *projectFlockIDToUse, err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check active kandang for project flock") - } else if active { - return nil, fiber.NewError(fiber.StatusConflict, "Project flock already has an active kandang") + // Kalau status jadi ACTIVE, pastikan tidak ada kandang aktif lain pada project flock tsb (hitung via pivot) + if finalStatus == string(utils.KandangStatusActive) { + if active, err := s.Repository.HasActiveKandangForProjectFlock(c.Context(), *req.ProjectFlockId, &id); err != nil { + s.Log.Errorf("Failed to check kandang activity for project flock %d: %+v", *req.ProjectFlockId, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check active kandang for project flock") + } else if active { + return nil, fiber.NewError(fiber.StatusConflict, "Project flock already has an active kandang") + } } } - if len(updateBody) == 0 { - return s.GetOne(c, id) + if len(updateBody) > 0 { + if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Kandang not found") + } + s.Log.Errorf("Failed to update kandang: %+v", err) + return nil, err + } } - if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Kandang not found") + if req.ProjectFlockId != nil { + if err := s.Repository.UpsertProjectFlockKandang(c.Context(), *req.ProjectFlockId, id); err != nil { + s.Log.Errorf("Failed to upsert pivot kandang-project_flock: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to link kandang to project flock") } - s.Log.Errorf("Failed to update kandang: %+v", err) - return nil, err } return s.GetOne(c, id) diff --git a/internal/modules/master/locations/controllers/location.controller.go b/internal/modules/master/locations/controllers/location.controller.go index 8f8211d7..f360a9c9 100644 --- a/internal/modules/master/locations/controllers/location.controller.go +++ b/internal/modules/master/locations/controllers/location.controller.go @@ -30,6 +30,10 @@ func (u *LocationController) GetAll(c *fiber.Ctx) error { AreaId: c.QueryInt("area_id", 0), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.LocationService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/locations/module.go b/internal/modules/master/locations/module.go index c8a9303f..3e8c658d 100644 --- a/internal/modules/master/locations/module.go +++ b/internal/modules/master/locations/module.go @@ -23,4 +23,3 @@ func (LocationModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate LocationRoutes(router, userService, locationService) } - diff --git a/internal/modules/master/locations/route.go b/internal/modules/master/locations/route.go index 99d22289..68bce594 100644 --- a/internal/modules/master/locations/route.go +++ b/internal/modules/master/locations/route.go @@ -1,7 +1,7 @@ package locations import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/controllers" location "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,12 +13,7 @@ func LocationRoutes(v1 fiber.Router, u user.UserService, s location.LocationServ ctrl := controller.NewLocationController(s) route := v1.Group("/locations") - - // route.Get("/", m.Auth(u), ctrl.GetAll) - // route.Post("/", m.Auth(u), ctrl.CreateOne) - // route.Get("/:id", m.Auth(u), ctrl.GetOne) - // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) - // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) route.Post("/", ctrl.CreateOne) diff --git a/internal/modules/master/nonstocks/controllers/nonstock.controller.go b/internal/modules/master/nonstocks/controllers/nonstock.controller.go index d8b688b7..d991c4da 100644 --- a/internal/modules/master/nonstocks/controllers/nonstock.controller.go +++ b/internal/modules/master/nonstocks/controllers/nonstock.controller.go @@ -29,6 +29,10 @@ func (u *NonstockController) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.NonstockService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/nonstocks/module.go b/internal/modules/master/nonstocks/module.go index 167d432b..148c9c16 100644 --- a/internal/modules/master/nonstocks/module.go +++ b/internal/modules/master/nonstocks/module.go @@ -23,4 +23,3 @@ func (NonstockModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate NonstockRoutes(router, userService, nonstockService) } - diff --git a/internal/modules/master/nonstocks/route.go b/internal/modules/master/nonstocks/route.go index 155096f0..2aa7b838 100644 --- a/internal/modules/master/nonstocks/route.go +++ b/internal/modules/master/nonstocks/route.go @@ -1,7 +1,7 @@ package nonstocks import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/controllers" nonstock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,12 +13,7 @@ func NonstockRoutes(v1 fiber.Router, u user.UserService, s nonstock.NonstockServ ctrl := controller.NewNonstockController(s) route := v1.Group("/nonstocks") - - // route.Get("/", m.Auth(u), ctrl.GetAll) - // route.Post("/", m.Auth(u), ctrl.CreateOne) - // route.Get("/:id", m.Auth(u), ctrl.GetOne) - // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) - // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) route.Post("/", ctrl.CreateOne) diff --git a/internal/modules/master/product-categories/controllers/product-category.controller.go b/internal/modules/master/product-categories/controllers/product-category.controller.go index 778a3188..e4531a1f 100644 --- a/internal/modules/master/product-categories/controllers/product-category.controller.go +++ b/internal/modules/master/product-categories/controllers/product-category.controller.go @@ -29,6 +29,10 @@ func (u *ProductCategoryController) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.ProductCategoryService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/product-categories/route.go b/internal/modules/master/product-categories/route.go index 349fcb78..4a2262f9 100644 --- a/internal/modules/master/product-categories/route.go +++ b/internal/modules/master/product-categories/route.go @@ -1,7 +1,7 @@ package productcategories import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/product-categories/controllers" productCategory "gitlab.com/mbugroup/lti-api.git/internal/modules/master/product-categories/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,12 +13,7 @@ func ProductCategoryRoutes(v1 fiber.Router, u user.UserService, s productCategor ctrl := controller.NewProductCategoryController(s) route := v1.Group("/product-categories") - - // route.Get("/", m.Auth(u), ctrl.GetAll) - // route.Post("/", m.Auth(u), ctrl.CreateOne) - // route.Get("/:id", m.Auth(u), ctrl.GetOne) - // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) - // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) route.Post("/", ctrl.CreateOne) diff --git a/internal/modules/master/products/controllers/product.controller.go b/internal/modules/master/products/controllers/product.controller.go index ee2c95f8..197a6b5f 100644 --- a/internal/modules/master/products/controllers/product.controller.go +++ b/internal/modules/master/products/controllers/product.controller.go @@ -30,6 +30,10 @@ func (u *ProductController) GetAll(c *fiber.Ctx) error { ProductCategoryID: c.QueryInt("product_category_id", 0), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.ProductService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/products/module.go b/internal/modules/master/products/module.go index 87c6fb46..f42182d6 100644 --- a/internal/modules/master/products/module.go +++ b/internal/modules/master/products/module.go @@ -23,4 +23,3 @@ func (ProductModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * ProductRoutes(router, userService, productService) } - diff --git a/internal/modules/master/products/route.go b/internal/modules/master/products/route.go index ffa75dfa..369d6ea8 100644 --- a/internal/modules/master/products/route.go +++ b/internal/modules/master/products/route.go @@ -1,7 +1,7 @@ package products import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/controllers" product "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,12 +13,7 @@ func ProductRoutes(v1 fiber.Router, u user.UserService, s product.ProductService ctrl := controller.NewProductController(s) route := v1.Group("/products") - - // route.Get("/", m.Auth(u), ctrl.GetAll) - // route.Post("/", m.Auth(u), ctrl.CreateOne) - // route.Get("/:id", m.Auth(u), ctrl.GetOne) - // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) - // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) route.Post("/", ctrl.CreateOne) diff --git a/internal/modules/master/route.go b/internal/modules/master/route.go index 88e17a98..44702e1a 100644 --- a/internal/modules/master/route.go +++ b/internal/modules/master/route.go @@ -11,6 +11,7 @@ import ( banks "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks" customers "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers" fcrs "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs" + flocks "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks" kandangs "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs" locations "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations" nonstocks "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks" @@ -19,7 +20,6 @@ import ( suppliers "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers" uoms "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms" warehouses "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses" - flocks "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks" // MODULE IMPORTS ) diff --git a/internal/modules/master/suppliers/controllers/supplier.controller.go b/internal/modules/master/suppliers/controllers/supplier.controller.go index a76904a9..5d70e43e 100644 --- a/internal/modules/master/suppliers/controllers/supplier.controller.go +++ b/internal/modules/master/suppliers/controllers/supplier.controller.go @@ -29,6 +29,10 @@ func (u *SupplierController) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.SupplierService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/suppliers/module.go b/internal/modules/master/suppliers/module.go index f4619a0d..4d9e67e4 100644 --- a/internal/modules/master/suppliers/module.go +++ b/internal/modules/master/suppliers/module.go @@ -23,4 +23,3 @@ func (SupplierModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate SupplierRoutes(router, userService, supplierService) } - diff --git a/internal/modules/master/suppliers/repositories/supplier.repository.go b/internal/modules/master/suppliers/repositories/supplier.repository.go index 46fb2983..6b5a0ae2 100644 --- a/internal/modules/master/suppliers/repositories/supplier.repository.go +++ b/internal/modules/master/suppliers/repositories/supplier.repository.go @@ -11,7 +11,7 @@ import ( type SupplierRepository interface { repository.BaseRepository[entity.Supplier] NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) - + AliasExists(ctx context.Context, alias string, excludeID *uint) (bool, error) } type SupplierRepositoryImpl struct { @@ -29,3 +29,7 @@ func NewSupplierRepository(db *gorm.DB) SupplierRepository { func (r *SupplierRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) { return repository.ExistsByName[entity.Supplier](ctx, r.db, name, excludeID) } + +func (r *SupplierRepositoryImpl) AliasExists(ctx context.Context, alias string, excludeID *uint) (bool, error) { + return repository.ExistsByField[entity.Supplier](ctx, r.db, "alias", alias, excludeID) +} diff --git a/internal/modules/master/suppliers/route.go b/internal/modules/master/suppliers/route.go index b176c40c..17271d4a 100644 --- a/internal/modules/master/suppliers/route.go +++ b/internal/modules/master/suppliers/route.go @@ -1,7 +1,7 @@ package suppliers import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/controllers" supplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,12 +13,7 @@ func SupplierRoutes(v1 fiber.Router, u user.UserService, s supplier.SupplierServ ctrl := controller.NewSupplierController(s) route := v1.Group("/suppliers") - - // route.Get("/", m.Auth(u), ctrl.GetAll) - // route.Post("/", m.Auth(u), ctrl.CreateOne) - // route.Get("/:id", m.Auth(u), ctrl.GetOne) - // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) - // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) route.Post("/", ctrl.CreateOne) diff --git a/internal/modules/master/suppliers/services/supplier.service.go b/internal/modules/master/suppliers/services/supplier.service.go index f8422350..99e15b29 100644 --- a/internal/modules/master/suppliers/services/supplier.service.go +++ b/internal/modules/master/suppliers/services/supplier.service.go @@ -88,6 +88,13 @@ func (s *supplierService) CreateOne(c *fiber.Ctx, req *validation.Create) (*enti return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Supplier with name %s already exists", req.Name)) } + if exists, err := s.Repository.AliasExists(c.Context(), strings.TrimSpace(strings.ToUpper(req.Alias)), nil); err != nil { + s.Log.Errorf("Failed to check supplier alias: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check supplier alias") + } else if exists { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Supplier with alias %s already exists", strings.TrimSpace(strings.ToUpper(req.Alias)))) + } + typ := strings.ToUpper(req.Type) if !utils.IsValidCustomerSupplierType(typ) { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid supplier type") @@ -143,6 +150,12 @@ func (s supplierService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint } if req.Alias != nil { + if exists, err := s.Repository.AliasExists(c.Context(), strings.TrimSpace(strings.ToUpper(*req.Alias)), &id); err != nil { + s.Log.Errorf("Failed to check supplier alias: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check supplier alias") + } else if exists { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Supplier with alias %s already exists", strings.TrimSpace(strings.ToUpper(*req.Alias)))) + } updateBody["alias"] = strings.TrimSpace(strings.ToUpper(*req.Alias)) } diff --git a/internal/modules/master/uoms/controllers/uom.controller.go b/internal/modules/master/uoms/controllers/uom.controller.go index 0bd3a382..ecef1f69 100644 --- a/internal/modules/master/uoms/controllers/uom.controller.go +++ b/internal/modules/master/uoms/controllers/uom.controller.go @@ -29,6 +29,10 @@ func (u *UomController) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.UomService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/uoms/module.go b/internal/modules/master/uoms/module.go index 25919045..2c02ea7f 100644 --- a/internal/modules/master/uoms/module.go +++ b/internal/modules/master/uoms/module.go @@ -23,4 +23,3 @@ func (UomModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *vali UomRoutes(router, userService, uomService) } - diff --git a/internal/modules/master/uoms/route.go b/internal/modules/master/uoms/route.go index 6c8b29cc..53faa239 100644 --- a/internal/modules/master/uoms/route.go +++ b/internal/modules/master/uoms/route.go @@ -1,7 +1,7 @@ package uoms import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms/controllers" uom "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,12 +13,7 @@ func UomRoutes(v1 fiber.Router, u user.UserService, s uom.UomService) { ctrl := controller.NewUomController(s) route := v1.Group("/uoms") - - // route.Get("/", m.Auth(u), ctrl.GetAll) - // route.Post("/", m.Auth(u), ctrl.CreateOne) - // route.Get("/:id", m.Auth(u), ctrl.GetOne) - // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) - // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) route.Post("/", ctrl.CreateOne) diff --git a/internal/modules/master/warehouses/controllers/warehouse.controller.go b/internal/modules/master/warehouses/controllers/warehouse.controller.go index b841d4ef..afa90660 100644 --- a/internal/modules/master/warehouses/controllers/warehouse.controller.go +++ b/internal/modules/master/warehouses/controllers/warehouse.controller.go @@ -30,6 +30,10 @@ func (u *WarehouseController) GetAll(c *fiber.Ctx) error { AreaId: c.QueryInt("area_id", 0), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.WarehouseService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/warehouses/module.go b/internal/modules/master/warehouses/module.go index bb331862..92ad45b2 100644 --- a/internal/modules/master/warehouses/module.go +++ b/internal/modules/master/warehouses/module.go @@ -23,4 +23,3 @@ func (WarehouseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate WarehouseRoutes(router, userService, warehouseService) } - diff --git a/internal/modules/master/warehouses/route.go b/internal/modules/master/warehouses/route.go index b19657cb..8acf4452 100644 --- a/internal/modules/master/warehouses/route.go +++ b/internal/modules/master/warehouses/route.go @@ -1,7 +1,7 @@ package warehouses import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/controllers" warehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,12 +13,7 @@ func WarehouseRoutes(v1 fiber.Router, u user.UserService, s warehouse.WarehouseS ctrl := controller.NewWarehouseController(s) route := v1.Group("/warehouses") - - // route.Get("/", m.Auth(u), ctrl.GetAll) - // route.Post("/", m.Auth(u), ctrl.CreateOne) - // route.Get("/:id", m.Auth(u), ctrl.GetOne) - // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) - // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) route.Post("/", ctrl.CreateOne) diff --git a/internal/modules/production/chickins/dto/chickin.dto.go b/internal/modules/production/chickins/dto/chickin.dto.go index 193257b6..3b69d4d4 100644 --- a/internal/modules/production/chickins/dto/chickin.dto.go +++ b/internal/modules/production/chickins/dto/chickin.dto.go @@ -9,6 +9,7 @@ import ( flockBaseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/dto" kandangBaseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" locationBaseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto" + pfutils "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/utils" userBaseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) @@ -88,9 +89,9 @@ func ToUserBaseDTO(e entity.User) userBaseDTO.UserBaseDTO { func ToProjectFlockDTO(e entity.ProjectFlock) ProjectFlockDTO { var flock *flockBaseDTO.FlockBaseDTO - if e.Flock.Id != 0 { - mapped := flockBaseDTO.ToFlockBaseDTO(e.Flock) - flock = &mapped + if base := pfutils.DeriveBaseName(e.FlockName); base != "" { + summary := flockBaseDTO.FlockBaseDTO{Id: 0, Name: base} + flock = &summary } var area *areaBaseDTO.AreaBaseDTO if e.Area.Id != 0 { diff --git a/internal/modules/production/chickins/route.go b/internal/modules/production/chickins/route.go index 5fa5237a..25879bc2 100644 --- a/internal/modules/production/chickins/route.go +++ b/internal/modules/production/chickins/route.go @@ -1,7 +1,7 @@ package chickins import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/controllers" chickin "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,12 +13,7 @@ func ChickinRoutes(v1 fiber.Router, u user.UserService, s chickin.ChickinService ctrl := controller.NewChickinController(s) route := v1.Group("/chickins") - - // route.Get("/", m.Auth(u), ctrl.GetAll) - // route.Post("/", m.Auth(u), ctrl.CreateOne) - // route.Get("/:id", m.Auth(u), ctrl.GetOne) - // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) - // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) route.Post("/", ctrl.CreateOne) diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 0df1b6b5..5a6f4e71 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -63,7 +63,6 @@ func (s chickinService) withRelations(db *gorm.DB) *gorm.DB { Preload("ProjectFlockKandang.Kandang.Location.Area"). Preload("ProjectFlockKandang.Kandang.Pic"). Preload("ProjectFlockKandang.ProjectFlock"). - Preload("ProjectFlockKandang.ProjectFlock.Flock"). Preload("ProjectFlockKandang.ProjectFlock.Area"). Preload("ProjectFlockKandang.ProjectFlock.Fcr"). Preload("ProjectFlockKandang.ProjectFlock.Location"). @@ -121,14 +120,8 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit return nil, err } - var productWarehouses []entity.ProductWarehouse - err = s.ProductWarehouseRepo.DB(). - WithContext(c.Context()). - Joins("JOIN products ON products.id = product_warehouses.product_id"). - Joins("JOIN product_categories ON product_categories.id = products.product_category_id"). - Where("product_categories.code = ? AND product_warehouses.warehouse_id = ?", "DOC", warehouse.Id). - Order("created_at DESC"). - Find(&productWarehouses).Error + // move complex DB query into repository for cleaner service + productWarehouses, err := s.ProductWarehouseRepo.GetByCategoryCodeAndWarehouseID(c.Context(), "DOC", warehouse.Id) if err != nil { s.Log.Errorf("Failed to get product warehouses: %+v", err) return nil, err @@ -136,8 +129,6 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit if len(productWarehouses) == 0 { return nil, fiber.NewError(fiber.StatusNotFound, "Product Warehouse not found for the given Project Flock and Warehouse") } - - // Jumlahkan semua quantity DOC totalQuantity := 0.0 for _, pw := range productWarehouses { totalQuantity += pw.Quantity @@ -147,7 +138,6 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit return nil, fiber.NewError(fiber.StatusBadRequest, "Insufficient quantity in Product Warehouses") } - // Buat satu chickin dengan total quantity chickinDate, err := utils.ParseDateString(req.ChickInDate) if err != nil { s.Log.Errorf("Failed to parse chickin date: %+v", err) @@ -157,7 +147,7 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit ProjectFlockKandangId: projectflockkandang.Id, ChickInDate: chickinDate, Quantity: totalQuantity, - Note: "", + Note: req.Note, CreatedBy: 1, //todo: ganti dengan user login } err = s.Repository.CreateOne(c.Context(), newChickin, nil) @@ -176,7 +166,6 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit return nil, err } - // add ke detail chickin newChickinDetail := &entity.ProjectChickinDetail{ ProjectChickinId: newChickin.Id, ProductWarehouseId: pw.Id, @@ -232,6 +221,9 @@ func (s chickinService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) if req.ChickInDate != "" { updateBody["chick_in_date"] = req.ChickInDate } + if req.Note != "" { + updateBody["note"] = req.Note + } if len(updateBody) == 0 { return s.GetOne(c, id) } @@ -293,7 +285,6 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { return rollback(err) } - // helper: restore quantities from details; returns (restored bool, error) restoreFromDetails := func() (bool, error) { var details []entity.ProjectChickinDetail if err := tx.WithContext(c.Context()).Where("project_chickin_id = ?", chickin.Id).Find(&details).Error; err != nil { @@ -348,15 +339,12 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { return rollback(err) } - var productWarehouse entity.ProductWarehouse - err = tx.WithContext(c.Context()).Table("product_warehouses"). - Select("product_warehouses.*"). - Joins("JOIN products ON products.id = product_warehouses.product_id"). - Joins("JOIN product_categories ON product_categories.id = products.product_category_id"). - Where("product_categories.code = ? AND product_warehouses.warehouse_id = ?", "DOC", warehouse.Id). - Order("product_warehouses.created_at DESC"). - First(&productWarehouse).Error - + productWarehouse, err := s.ProductWarehouseRepo.GetLatestByCategoryCodeAndWarehouseID( + c.Context(), + "DOC", + warehouse.Id, + tx, + ) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return rollback(fiber.NewError(fiber.StatusNotFound, "Product Warehouse not found for the given Project Flock and Warehouse")) diff --git a/internal/modules/production/chickins/validations/chickin.validation.go b/internal/modules/production/chickins/validations/chickin.validation.go index c122c100..9747ee07 100644 --- a/internal/modules/production/chickins/validations/chickin.validation.go +++ b/internal/modules/production/chickins/validations/chickin.validation.go @@ -3,10 +3,12 @@ package validation type Create struct { ProjectFlockKandangId uint `json:"project_flock_kandang_id" validate:"required,number,min=1"` ChickInDate string `json:"chick_in_date" validate:"required,datetime=2006-01-02"` + Note string `json:"note" validate:"omitempty` } type Update struct { ChickInDate string `json:"chick_in_date" validate:"required,datetime=2006-01-02"` + Note string `json:"note" validate:"omitempty"` } type Query struct { diff --git a/internal/modules/production/project_flocks/controllers/projectflock.controller.go b/internal/modules/production/project_flocks/controllers/projectflock.controller.go index ca60d5df..d3b0061c 100644 --- a/internal/modules/production/project_flocks/controllers/projectflock.controller.go +++ b/internal/modules/production/project_flocks/controllers/projectflock.controller.go @@ -222,11 +222,11 @@ func (u *ProjectflockController) Approval(c *fiber.Ctx) error { } func (u *ProjectflockController) GetFlockPeriodSummary(c *fiber.Ctx) error { - param := c.Params("flock_id") + param := c.Params("project_flock_kandang_id") id, err := strconv.Atoi(param) if err != nil { - return fiber.NewError(fiber.StatusBadRequest, "Invalid Flock Id") + return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_kandang_id") } summary, err := u.ProjectflockService.GetFlockPeriodSummary(c, uint(id)) @@ -246,17 +246,39 @@ func (u *ProjectflockController) GetFlockPeriodSummary(c *fiber.Ctx) error { } func (u *ProjectflockController) LookupProjectFlockKandang(c *fiber.Ctx) error { - projectFlockIdStr := c.Query("project_flock_id", "") - kandangIdStr := c.Query("kandang_id", "") + projectFlockId := c.QueryInt("project_flock_id", 0) + kandangId := c.QueryInt("kandang_id", 0) - result, err := u.ProjectflockService.GetProjectFlockKandangByParams(c, "", projectFlockIdStr, kandangIdStr) + if projectFlockId == 0 || kandangId == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id or kandang_id") + } + + result, availableStock, err := u.ProjectflockService.GetProjectFlockKandangByProjectAndKandang(c, uint(projectFlockId), uint(kandangId)) if err != nil { return err } + dtoResult := dto.ToProjectFlockKandangDTO(*result) + dtoResult.AvailableQuantity = float64(availableStock) + + // populate available quantity for each kandang inside project_flock + if dtoResult.ProjectFlock != nil { + for i := range dtoResult.ProjectFlock.Kandangs { + kand := &dtoResult.ProjectFlock.Kandangs[i] + if kand.Id == 0 { + continue + } + if q, qerr := u.ProjectflockService.GetAvailableDocQuantity(c, kand.Id); qerr == nil { + kand.AvailableQuantity = q + } + } + // remove inner kandangs from project_flock to avoid duplication + dtoResult.ProjectFlock.Kandangs = nil + } + return c.Status(fiber.StatusOK). JSON(response.Success{Code: fiber.StatusOK, Status: "success", Message: "Get projectflock kandang successfully", - Data: dto.ToProjectFlockKandangDTO(*result)}) + Data: dtoResult}) } diff --git a/internal/modules/production/project_flocks/dto/projectflock.dto.go b/internal/modules/production/project_flocks/dto/projectflock.dto.go index dff3bc61..bfadf3e2 100644 --- a/internal/modules/production/project_flocks/dto/projectflock.dto.go +++ b/internal/modules/production/project_flocks/dto/projectflock.dto.go @@ -10,19 +10,21 @@ import ( flockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/dto" kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto" + // pfutils "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/utils" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" "gitlab.com/mbugroup/lti-api.git/internal/utils" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" ) type ProjectFlockBaseDTO struct { - Id uint `json:"id"` - Period int `json:"period"` + Id uint `json:"id"` + Period int `json:"period"` + FlockName string `json:"flock_name"` } type ProjectFlockListDTO struct { ProjectFlockBaseDTO - Flock *flockDTO.FlockBaseDTO `json:"flock,omitempty"` + // Flock *flockDTO.FlockBaseDTO `json:"flock,omitempty"` Area *areaDTO.AreaBaseDTO `json:"area,omitempty"` Category string `json:"category"` Fcr *fcrDTO.FcrBaseDTO `json:"fcr,omitempty"` @@ -58,11 +60,11 @@ func ToProjectFlockListDTO(e entity.ProjectFlock) ProjectFlockListDTO { } } - var flockSummary *flockDTO.FlockBaseDTO - if e.Flock.Id != 0 { - mapped := flockDTO.ToFlockBaseDTO(e.Flock) - flockSummary = &mapped - } + // var flockSummary *flockDTO.FlockBaseDTO + // if baseName := pfutils.DeriveBaseName(e.FlockName); baseName != "" { + // summary := flockDTO.FlockBaseDTO{Id: 0, Name: baseName} + // flockSummary = &summary + // } var areaSummary *areaDTO.AreaBaseDTO if e.Area.Id != 0 { @@ -90,7 +92,7 @@ func ToProjectFlockListDTO(e entity.ProjectFlock) ProjectFlockListDTO { return ProjectFlockListDTO{ ProjectFlockBaseDTO: createProjectFlockBaseDTO(e), - Flock: flockSummary, + // Flock: flockSummary, Area: areaSummary, Kandangs: kandangSummaries, Category: e.Category, @@ -144,8 +146,9 @@ func defaultProjectFlockLatestApproval(e entity.ProjectFlock) approvalDTO.Approv func createProjectFlockBaseDTO(e entity.ProjectFlock) ProjectFlockBaseDTO { return ProjectFlockBaseDTO{ - Id: e.Id, - Period: e.Period, + Id: e.Id, + Period: e.Period, + FlockName: e.FlockName, } } diff --git a/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go b/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go index ff82fba9..24e53d28 100644 --- a/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go +++ b/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go @@ -7,13 +7,13 @@ import ( flockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/dto" kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto" + pfutils "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/utils" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) -// internal DTO used only for lookup response: project flock with kandangs carrying pivot ids type KandangWithPivotDTO struct { kandangDTO.KandangBaseDTO - ProjectFlockKandangId *uint `json:"project_flock_kandang_id,omitempty"` + AvailableQuantity float64 `json:"available_quantity"` } type ProjectFlockWithPivotDTO struct { @@ -28,11 +28,13 @@ type ProjectFlockWithPivotDTO struct { } type ProjectFlockKandangDTO struct { - Id uint `json:"id"` - ProjectFlockId uint `json:"project_flock_id"` - KandangId uint `json:"kandang_id"` - Kandang *kandangDTO.KandangBaseDTO `json:"kandang,omitempty"` - ProjectFlock *ProjectFlockWithPivotDTO `json:"project_flock,omitempty"` + Id uint `json:"id"` + ProjectFlockKandangId uint `json:"project_flock_kandang_id"` + ProjectFlockId uint `json:"project_flock_id"` + KandangId uint `json:"kandang_id"` + Kandang *kandangDTO.KandangBaseDTO `json:"kandang,omitempty"` + ProjectFlock *ProjectFlockWithPivotDTO `json:"project_flock,omitempty"` + AvailableQuantity float64 `json:"available_quantity"` } func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangDTO { @@ -44,19 +46,19 @@ func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangD var pf *ProjectFlockWithPivotDTO if e.ProjectFlock.Id != 0 { - // build project flock with kandangs that include pivot ids + pfLocal := ProjectFlockWithPivotDTO{ ProjectFlockBaseDTO: ProjectFlockBaseDTO{ - Id: e.ProjectFlock.Id, - Period: e.ProjectFlock.Period, + Id: e.ProjectFlock.Id, + Period: e.ProjectFlock.Period, + FlockName: e.ProjectFlock.FlockName, }, Category: e.ProjectFlock.Category, } - // fill related small summaries - if e.ProjectFlock.Flock.Id != 0 { - mapped := ToFlockSummaryDTO(e.ProjectFlock.Flock) - pfLocal.Flock = &mapped + if base := pfutils.DeriveBaseName(e.ProjectFlock.FlockName); base != "" { + summary := flockDTO.FlockBaseDTO{Id: 0, Name: base} + pfLocal.Flock = &summary } if e.ProjectFlock.Area.Id != 0 { mapped := areaDTO.ToAreaBaseDTO(e.ProjectFlock.Area) @@ -75,23 +77,11 @@ func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangD pfLocal.CreatedUser = &mapped } - // build pivot map - pivotMap := make(map[uint]uint) - for _, ph := range e.ProjectFlock.KandangHistory { - pivotMap[ph.KandangId] = ph.Id - } - - // populate kandangs with pivot ids for _, k := range e.ProjectFlock.Kandangs { kb := kandangDTO.ToKandangBaseDTO(k) - var pid *uint - if v, ok := pivotMap[k.Id]; ok { - vv := v - pid = &vv - } pfLocal.Kandangs = append(pfLocal.Kandangs, KandangWithPivotDTO{ - KandangBaseDTO: kb, - ProjectFlockKandangId: pid, + KandangBaseDTO: kb, + AvailableQuantity: 0, }) } @@ -99,10 +89,12 @@ func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangD } return ProjectFlockKandangDTO{ - Id: e.Id, - ProjectFlockId: e.ProjectFlockId, - KandangId: e.KandangId, - Kandang: kandang, - ProjectFlock: pf, + Id: e.Id, + ProjectFlockKandangId: e.Id, + ProjectFlockId: e.ProjectFlockId, + KandangId: e.KandangId, + Kandang: kandang, + ProjectFlock: pf, + AvailableQuantity: 0, } } diff --git a/internal/modules/production/project_flocks/module.go b/internal/modules/production/project_flocks/module.go index 994eb4a4..4fd932a4 100644 --- a/internal/modules/production/project_flocks/module.go +++ b/internal/modules/production/project_flocks/module.go @@ -9,8 +9,10 @@ import ( commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" "gorm.io/gorm" + rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/repositories" rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" + rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" rProjectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" sProjectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" @@ -27,6 +29,8 @@ func (ProjectflockModule) RegisterRoutes(router fiber.Router, db *gorm.DB, valid kandangRepo := rKandang.NewKandangRepository(db) projectflockRepo := rProjectflock.NewProjectflockRepository(db) projectflockKandangRepo := rProjectflock.NewProjectFlockKandangRepository(db) + warehouseRepo := rWarehouse.NewWarehouseRepository(db) + productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) userRepo := rUser.NewUserRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db) @@ -35,7 +39,7 @@ func (ProjectflockModule) RegisterRoutes(router fiber.Router, db *gorm.DB, valid panic(fmt.Sprintf("failed to register project flock approval workflow: %v", err)) } - projectflockService := sProjectflock.NewProjectflockService(projectflockRepo, flockRepo, kandangRepo, projectflockKandangRepo, approvalService, validate) + projectflockService := sProjectflock.NewProjectflockService(projectflockRepo, flockRepo, kandangRepo, projectflockKandangRepo, warehouseRepo, productWarehouseRepo, approvalService, validate) userService := sUser.NewUserService(userRepo, validate) ProjectflockRoutes(router, userService, projectflockService) diff --git a/internal/modules/production/project_flocks/repositories/projectflock.repository.go b/internal/modules/production/project_flocks/repositories/projectflock.repository.go index 476b061b..bb653fe9 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock.repository.go @@ -3,19 +3,30 @@ package repository import ( "context" "errors" + "fmt" + "strings" "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations" "gorm.io/gorm" "gorm.io/gorm/clause" ) +const baseNameExpression = "LOWER(TRIM(regexp_replace(flock_name, '\\\\s+\\\\d+(\\\\s+\\\\d+)*$', '', 'g')))" + type ProjectflockRepository interface { repository.BaseRepository[entity.ProjectFlock] - GetAllByFlock(ctx context.Context, flockID uint) ([]entity.ProjectFlock, error) - GetActiveByFlock(ctx context.Context, flockID uint) (*entity.ProjectFlock, error) - GetMaxPeriodByFlock(ctx context.Context, flockID uint) (int, error) - GetNextPeriodForFlock(ctx context.Context, flockID uint) (int, error) + GetAllByBaseName(ctx context.Context, baseName string) ([]entity.ProjectFlock, error) + GetActiveByBaseName(ctx context.Context, baseName string) (*entity.ProjectFlock, error) + GetMaxPeriodByBaseName(ctx context.Context, baseName string) (int, error) + GetNextSequenceForBase(ctx context.Context, baseName string) (int, error) + GetAllWithFilters(ctx context.Context, offset, limit int, params *validation.Query) ([]entity.ProjectFlock, int64, error) + WithDefaultRelations() func(*gorm.DB) *gorm.DB + ExistsByFlockName(ctx context.Context, flockName string, excludeID *uint) (bool, error) + AreaExists(ctx context.Context, id uint) (bool, error) + FcrExists(ctx context.Context, id uint) (bool, error) + LocationExists(ctx context.Context, id uint) (bool, error) } type ProjectflockRepositoryImpl struct { @@ -28,11 +39,11 @@ func NewProjectflockRepository(db *gorm.DB) ProjectflockRepository { } } -func (r *ProjectflockRepositoryImpl) GetAllByFlock(ctx context.Context, flockID uint) ([]entity.ProjectFlock, error) { +func (r *ProjectflockRepositoryImpl) GetAllByBaseName(ctx context.Context, baseName string) ([]entity.ProjectFlock, error) { var records []entity.ProjectFlock if err := r.DB().WithContext(ctx). Unscoped(). - Where("flock_id = ?", flockID). + Where(baseNameExpression+" = LOWER(?)", baseName). Order("period ASC"). Find(&records).Error; err != nil { return nil, err @@ -40,10 +51,10 @@ func (r *ProjectflockRepositoryImpl) GetAllByFlock(ctx context.Context, flockID return records, nil } -func (r *ProjectflockRepositoryImpl) GetActiveByFlock(ctx context.Context, flockID uint) (*entity.ProjectFlock, error) { +func (r *ProjectflockRepositoryImpl) GetActiveByBaseName(ctx context.Context, baseName string) (*entity.ProjectFlock, error) { var record entity.ProjectFlock err := r.DB().WithContext(ctx). - Where("flock_id = ?", flockID). + Where(baseNameExpression+" = LOWER(?)", baseName). Order("period DESC"). First(&record).Error if errors.Is(err, gorm.ErrRecordNotFound) { @@ -55,11 +66,11 @@ func (r *ProjectflockRepositoryImpl) GetActiveByFlock(ctx context.Context, flock return &record, nil } -func (r *ProjectflockRepositoryImpl) GetMaxPeriodByFlock(ctx context.Context, flockID uint) (int, error) { +func (r *ProjectflockRepositoryImpl) GetMaxPeriodByBaseName(ctx context.Context, baseName string) (int, error) { var max int if err := r.DB().WithContext(ctx). Model(&entity.ProjectFlock{}). - Where("flock_id = ?", flockID). + Where(baseNameExpression+" = LOWER(?)", baseName). Select("COALESCE(MAX(period), 0)"). Scan(&max).Error; err != nil { return 0, err @@ -67,13 +78,13 @@ func (r *ProjectflockRepositoryImpl) GetMaxPeriodByFlock(ctx context.Context, fl return max, nil } -func (r *ProjectflockRepositoryImpl) GetNextPeriodForFlock(ctx context.Context, flockID uint) (int, error) { +func (r *ProjectflockRepositoryImpl) GetNextSequenceForBase(ctx context.Context, baseName string) (int, error) { var payload struct { Period int } if err := r.DB().WithContext(ctx). Model(&entity.ProjectFlock{}). - Where("flock_id = ?", flockID). + Where(baseNameExpression+" = LOWER(?)", baseName). Clauses(clause.Locking{Strength: "UPDATE"}). Order("period DESC"). Limit(1). @@ -86,3 +97,164 @@ func (r *ProjectflockRepositoryImpl) GetNextPeriodForFlock(ctx context.Context, } return payload.Period + 1, nil } + +func (r *ProjectflockRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, params *validation.Query) ([]entity.ProjectFlock, int64, error) { + return r.GetAll(ctx, offset, limit, func(db *gorm.DB) *gorm.DB { + db = r.withDefaultRelations(db) + return r.applyQueryFilters(db, params) + }) +} + +func (r *ProjectflockRepositoryImpl) WithDefaultRelations() func(*gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + return r.withDefaultRelations(db) + } +} + +func (r *ProjectflockRepositoryImpl) withDefaultRelations(db *gorm.DB) *gorm.DB { + return db. + Preload("CreatedUser"). + Preload("Area"). + Preload("Fcr"). + Preload("Location"). + Preload("Kandangs") +} + +func (r *ProjectflockRepositoryImpl) applyQueryFilters(db *gorm.DB, params *validation.Query) *gorm.DB { + if params == nil { + return db + } + + if params.AreaId > 0 { + db = db.Where("project_flocks.area_id = ?", params.AreaId) + } + if params.LocationId > 0 { + db = db.Where("project_flocks.location_id = ?", params.LocationId) + } + if params.Period > 0 { + db = db.Where("project_flocks.period = ?", params.Period) + } + if len(params.KandangIds) > 0 { + db = db.Where(` + EXISTS ( + SELECT 1 + FROM project_flock_kandangs pfk + WHERE pfk.project_flock_id = project_flocks.id + AND pfk.kandang_id IN ? + )`, params.KandangIds) + } + + db = r.applySearchFilters(db, params.Search) + + for _, expr := range r.buildOrderExpressions(params.SortBy, params.SortOrder) { + db = db.Order(expr) + } + + return db +} + +func (r *ProjectflockRepositoryImpl) applySearchFilters(db *gorm.DB, rawSearch string) *gorm.DB { + if rawSearch == "" { + return db + } + + normalized := strings.ToLower(strings.TrimSpace(rawSearch)) + if normalized == "" { + return db + } + + likeQuery := "%" + normalized + "%" + return db. + Joins("LEFT JOIN areas ON areas.id = project_flocks.area_id"). + Joins("LEFT JOIN fcrs ON fcrs.id = project_flocks.fcr_id"). + Joins("LEFT JOIN locations ON locations.id = project_flocks.location_id"). + Joins("LEFT JOIN users AS created_users ON created_users.id = project_flocks.created_by"). + Where(` + LOWER(areas.name) LIKE ? + OR LOWER(project_flocks.category) LIKE ? + OR LOWER(fcrs.name) LIKE ? + OR LOWER(locations.name) LIKE ? + OR LOWER(locations.address) LIKE ? + OR LOWER(created_users.name) LIKE ? + OR LOWER(created_users.email) LIKE ? + OR LOWER(project_flocks.flock_name) LIKE ? + OR LOWER(TRIM(regexp_replace(project_flocks.flock_name, '\\s+\\d+(\\s+\\d+)*$', '', 'g'))) LIKE ? + OR LOWER(CAST(project_flocks.period AS TEXT)) LIKE ? + OR EXISTS ( + SELECT 1 FROM kandangs + WHERE kandangs.project_flock_id = project_flocks.id + AND LOWER(kandangs.name) LIKE ? + ) + `, + likeQuery, + likeQuery, + likeQuery, + likeQuery, + likeQuery, + likeQuery, + likeQuery, + likeQuery, + likeQuery, + likeQuery, + likeQuery, + ) +} + +func (r *ProjectflockRepositoryImpl) AreaExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.Area](ctx, r.DB(), id) +} + +func (r *ProjectflockRepositoryImpl) FcrExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.Fcr](ctx, r.DB(), id) +} + +func (r *ProjectflockRepositoryImpl) LocationExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.Location](ctx, r.DB(), id) +} + +func (r *ProjectflockRepositoryImpl) buildOrderExpressions(sortBy, sortOrder string) []string { + direction := "ASC" + if strings.ToLower(sortOrder) == "desc" { + direction = "DESC" + } + + switch sortBy { + case "area": + return []string{ + fmt.Sprintf("(SELECT name FROM areas WHERE areas.id = project_flocks.area_id) %s", direction), + fmt.Sprintf("project_flocks.id %s", direction), + } + case "location": + return []string{ + fmt.Sprintf("(SELECT name FROM locations WHERE locations.id = project_flocks.location_id) %s", direction), + fmt.Sprintf("project_flocks.id %s", direction), + } + case "kandangs": + return []string{ + fmt.Sprintf("(SELECT COUNT(*) FROM project_flock_kandangs pfk WHERE pfk.project_flock_id = project_flocks.id) %s", direction), + fmt.Sprintf("project_flocks.id %s", direction), + } + case "period": + return []string{ + fmt.Sprintf("project_flocks.period %s", direction), + fmt.Sprintf("project_flocks.id %s", direction), + } + default: + return []string{ + "project_flocks.created_at DESC", + "project_flocks.updated_at DESC", + } + } +} + +func (r *ProjectflockRepositoryImpl) ExistsByFlockName(ctx context.Context, flockName string, excludeID *uint) (bool, error) { + var count int64 + q := r.DB().WithContext(ctx).Model(&entity.ProjectFlock{}).Where("flock_name = ?", flockName) + if excludeID != nil && *excludeID != 0 { + q = q.Where("id <> ?", *excludeID) + } + if err := q.Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} 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 5c78f830..e6a36c87 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go @@ -2,6 +2,7 @@ package repository import ( "context" + "strings" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gorm.io/gorm" @@ -13,6 +14,10 @@ type ProjectFlockKandangRepository interface { CreateMany(ctx context.Context, records []*entity.ProjectFlockKandang) error DeleteMany(ctx context.Context, projectFlockID uint, kandangIDs []uint) error GetAll(ctx context.Context) ([]entity.ProjectFlockKandang, error) + ListExistingKandangIDs(ctx context.Context, projectFlockID uint, kandangIDs []uint) ([]uint, error) + HasKandangsLinkedToOtherProject(ctx context.Context, kandangIDs []uint, exceptProjectID *uint) (bool, error) + FindKandangsWithRecordings(ctx context.Context, projectFlockID uint, kandangIDs []uint) ([]entity.Kandang, error) + MaxPeriodByBaseName(ctx context.Context, baseName string) (int, error) WithTx(tx *gorm.DB) ProjectFlockKandangRepository DB() *gorm.DB } @@ -21,6 +26,8 @@ type projectFlockKandangRepositoryImpl struct { db *gorm.DB } +const flockBaseNameExpression = "LOWER(TRIM(regexp_replace(project_flocks.flock_name, '\\s+\\d+(\\s+\\d+)*$', '', 'g')))" + func NewProjectFlockKandangRepository(db *gorm.DB) ProjectFlockKandangRepository { return &projectFlockKandangRepositoryImpl{db: db} } @@ -45,7 +52,6 @@ func (r *projectFlockKandangRepositoryImpl) GetAll(ctx context.Context) ([]entit var records []entity.ProjectFlockKandang if err := r.db.WithContext(ctx). Preload("ProjectFlock"). - Preload("ProjectFlock.Flock"). Preload("ProjectFlock.Fcr"). Preload("ProjectFlock.Area"). Preload("ProjectFlock.Location"). @@ -72,7 +78,6 @@ func (r *projectFlockKandangRepositoryImpl) GetByID(ctx context.Context, id uint record := new(entity.ProjectFlockKandang) if err := r.db.WithContext(ctx). Preload("ProjectFlock"). - Preload("ProjectFlock.Flock"). Preload("ProjectFlock.Fcr"). Preload("ProjectFlock.Area"). Preload("ProjectFlock.Location"). @@ -91,7 +96,6 @@ func (r *projectFlockKandangRepositoryImpl) GetByProjectFlockAndKandang(ctx cont if err := r.db.WithContext(ctx). Where("project_flock_id = ? AND kandang_id = ?", projectFlockID, kandangID). Preload("ProjectFlock"). - Preload("ProjectFlock.Flock"). Preload("ProjectFlock.Fcr"). Preload("ProjectFlock.Area"). Preload("ProjectFlock.Location"). @@ -104,3 +108,62 @@ func (r *projectFlockKandangRepositoryImpl) GetByProjectFlockAndKandang(ctx cont } return record, nil } + +func (r *projectFlockKandangRepositoryImpl) ListExistingKandangIDs(ctx context.Context, projectFlockID uint, kandangIDs []uint) ([]uint, error) { + if len(kandangIDs) == 0 { + return nil, nil + } + var existing []uint + err := r.db.WithContext(ctx). + Table("project_flock_kandangs"). + Where("project_flock_id = ? AND kandang_id IN ?", projectFlockID, kandangIDs). + Pluck("kandang_id", &existing).Error + return existing, err +} + +func (r *projectFlockKandangRepositoryImpl) HasKandangsLinkedToOtherProject(ctx context.Context, kandangIDs []uint, exceptProjectID *uint) (bool, error) { + if len(kandangIDs) == 0 { + return false, nil + } + q := r.db.WithContext(ctx). + Table("project_flock_kandangs"). + Where("kandang_id IN ?", kandangIDs) + if exceptProjectID != nil { + q = q.Where("project_flock_id <> ?", *exceptProjectID) + } + var count int64 + if err := q.Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} + +func (r *projectFlockKandangRepositoryImpl) FindKandangsWithRecordings(ctx context.Context, projectFlockID uint, kandangIDs []uint) ([]entity.Kandang, error) { + if len(kandangIDs) == 0 { + return nil, nil + } + var kandangs []entity.Kandang + err := r.db.WithContext(ctx). + Table("recordings AS r"). + Select("pfk.kandang_id AS id, COALESCE(k.name, '') AS name"). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). + Joins("LEFT JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Where("pfk.project_flock_id = ? AND pfk.kandang_id IN ?", projectFlockID, kandangIDs). + Group("pfk.kandang_id, k.name"). + Scan(&kandangs).Error + return kandangs, err +} + +func (r *projectFlockKandangRepositoryImpl) MaxPeriodByBaseName(ctx context.Context, baseName string) (int, error) { + if strings.TrimSpace(baseName) == "" { + return 0, nil + } + var max int + err := r.db.WithContext(ctx). + Table("project_flock_kandangs pfk"). + Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id"). + Where(flockBaseNameExpression+" = LOWER(?)", baseName). + Select("COALESCE(MAX(pf.period), 0)"). + Scan(&max).Error + return max, err +} diff --git a/internal/modules/production/project_flocks/route.go b/internal/modules/production/project_flocks/route.go index 4c11d3a1..8128f943 100644 --- a/internal/modules/production/project_flocks/route.go +++ b/internal/modules/production/project_flocks/route.go @@ -1,7 +1,7 @@ package project_flocks import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/controllers" projectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -12,13 +12,8 @@ import ( func ProjectflockRoutes(v1 fiber.Router, u user.UserService, s projectflock.ProjectflockService) { ctrl := controller.NewProjectflockController(s) - route := v1.Group("/project_flocks") - - // route.Get("/", m.Auth(u), ctrl.GetAll) - // route.Post("/", m.Auth(u), ctrl.CreateOne) - // route.Get("/:id", m.Auth(u), ctrl.GetOne) - // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) - // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + route := v1.Group("/project-flocks") + route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) route.Post("/", ctrl.CreateOne) @@ -27,5 +22,6 @@ func ProjectflockRoutes(v1 fiber.Router, u user.UserService, s projectflock.Proj route.Delete("/:id", ctrl.DeleteOne) route.Get("/kandangs/lookup", ctrl.LookupProjectFlockKandang) route.Post("/approvals", ctrl.Approval) - route.Get("/flocks/:flock_id/periods", ctrl.GetFlockPeriodSummary) + route.Get("/kandangs/:project_flock_kandang_id/periods", ctrl.GetFlockPeriodSummary) + } diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index f9c7881e..9497ecbb 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -10,9 +10,13 @@ import ( 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" + authmiddleware "gitlab.com/mbugroup/lti-api.git/internal/middleware" + productWarehouseRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" flockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/repositories" kandangRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" + warehouseRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + pfutils "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/utils" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations" utils "gitlab.com/mbugroup/lti-api.git/internal/utils" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" @@ -28,21 +32,24 @@ type ProjectflockService interface { GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, error) CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.ProjectFlock, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.ProjectFlock, error) + GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID uint) (float64, error) DeleteOne(ctx *fiber.Ctx, id uint) error - GetProjectFlockKandangByParams(ctx *fiber.Ctx, idStr string, projectFlockIdStr string, kandangIdStr string) (*entity.ProjectFlockKandang, error) + GetProjectFlockKandangByProjectAndKandang(ctx *fiber.Ctx, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, float64, error) GetFlockPeriodSummary(ctx *fiber.Ctx, flockID uint) (*FlockPeriodSummary, error) Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlock, error) } type projectflockService struct { - Log *logrus.Logger - Validate *validator.Validate - Repository repository.ProjectflockRepository - FlockRepo flockRepository.FlockRepository - KandangRepo kandangRepository.KandangRepository - PivotRepo repository.ProjectFlockKandangRepository - ApprovalSvc commonSvc.ApprovalService - approvalWorkflow approvalutils.ApprovalWorkflowKey + Log *logrus.Logger + Validate *validator.Validate + Repository repository.ProjectflockRepository + FlockRepo flockRepository.FlockRepository + KandangRepo kandangRepository.KandangRepository + WarehouseRepo warehouseRepository.WarehouseRepository + ProductWarehouseRepo productWarehouseRepository.ProductWarehouseRepository + PivotRepo repository.ProjectFlockKandangRepository + ApprovalSvc commonSvc.ApprovalService + approvalWorkflow approvalutils.ApprovalWorkflowKey } type FlockPeriodSummary struct { @@ -55,31 +62,25 @@ func NewProjectflockService( flockRepo flockRepository.FlockRepository, kandangRepo kandangRepository.KandangRepository, pivotRepo repository.ProjectFlockKandangRepository, + warehouseRepo warehouseRepository.WarehouseRepository, + productWarehouseRepo productWarehouseRepository.ProductWarehouseRepository, approvalSvc commonSvc.ApprovalService, validate *validator.Validate, ) ProjectflockService { return &projectflockService{ - Log: utils.Log, - Validate: validate, - Repository: repo, - FlockRepo: flockRepo, - KandangRepo: kandangRepo, - PivotRepo: pivotRepo, - ApprovalSvc: approvalSvc, - approvalWorkflow: utils.ApprovalWorkflowProjectFlock, + Log: utils.Log, + Validate: validate, + Repository: repo, + FlockRepo: flockRepo, + KandangRepo: kandangRepo, + WarehouseRepo: warehouseRepo, + ProductWarehouseRepo: productWarehouseRepo, + PivotRepo: pivotRepo, + ApprovalSvc: approvalSvc, + approvalWorkflow: utils.ApprovalWorkflowProjectFlock, } } -func (s projectflockService) withRelations(db *gorm.DB) *gorm.DB { - return db. - Preload("CreatedUser"). - Preload("Flock"). - Preload("Area"). - Preload("Fcr"). - Preload("Location"). - Preload("Kandangs") -} - func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err @@ -94,74 +95,11 @@ func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e offset := (params.Page - 1) * params.Limit - projectflocks, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { - db = s.withRelations(db) - - if params.AreaId > 0 { - db = db.Where("project_flocks.area_id = ?", params.AreaId) - } - if params.LocationId > 0 { - db = db.Where("project_flocks.location_id = ?", params.LocationId) - } - if params.Period > 0 { - db = db.Where("project_flocks.period = ?", params.Period) - } - if len(params.KandangIds) > 0 { - db = db.Where("EXISTS (SELECT 1 FROM kandangs WHERE kandangs.project_flock_id = project_flocks.id AND kandangs.id IN ?)", params.KandangIds) - } - - if params.Search != "" { - normalizedSearch := strings.ToLower(strings.TrimSpace(params.Search)) - if normalizedSearch == "" { - for _, expr := range s.buildOrderExpressions(params.SortBy, params.SortOrder) { - db = db.Order(expr) - } - return db - } - likeQuery := "%" + normalizedSearch + "%" - db = db. - Joins("LEFT JOIN flocks ON flocks.id = project_flocks.flock_id"). - Joins("LEFT JOIN areas ON areas.id = project_flocks.area_id"). - Joins("LEFT JOIN fcrs ON fcrs.id = project_flocks.fcr_id"). - Joins("LEFT JOIN locations ON locations.id = project_flocks.location_id"). - Joins("LEFT JOIN users AS created_users ON created_users.id = project_flocks.created_by"). - Where(` - LOWER(flocks.name) LIKE ? - OR LOWER(areas.name) LIKE ? - OR LOWER(project_flocks.category) LIKE ? - OR LOWER(fcrs.name) LIKE ? - OR LOWER(locations.name) LIKE ? - OR LOWER(locations.address) LIKE ? - OR LOWER(created_users.name) LIKE ? - OR LOWER(created_users.email) LIKE ? - OR LOWER(CAST(project_flocks.period AS TEXT)) LIKE ? - OR EXISTS ( - SELECT 1 FROM kandangs - WHERE kandangs.project_flock_id = project_flocks.id - AND LOWER(kandangs.name) LIKE ? - ) - `, - likeQuery, - likeQuery, - likeQuery, - likeQuery, - likeQuery, - likeQuery, - likeQuery, - likeQuery, - likeQuery, - likeQuery, - ) - } - for _, expr := range s.buildOrderExpressions(params.SortBy, params.SortOrder) { - db = db.Order(expr) - } - return db - }) + projectflocks, total, err := s.Repository.GetAllWithFilters(c.Context(), offset, params.Limit, params) if err != nil { s.Log.Errorf("Failed to get projectflocks: %+v", err) - return nil, 0, err + return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flocks") } if s.ApprovalSvc != nil && len(projectflocks) > 0 { @@ -188,13 +126,13 @@ func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e } func (s projectflockService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlock, error) { - projectflock, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + projectflock, err := s.Repository.GetByID(c.Context(), id, s.Repository.WithDefaultRelations()) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found") } if err != nil { s.Log.Errorf("Failed get projectflock by id: %+v", err) - return nil, err + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") } if s.ApprovalSvc != nil { @@ -221,6 +159,11 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* return nil, err } + actorID, err := actorIDFromContext(c) + if err != nil { + return nil, err + } + cat := strings.ToUpper(req.Category) if !utils.IsValidProjectFlockCategory(cat) { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid category") @@ -230,15 +173,28 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* return nil, fiber.NewError(fiber.StatusBadRequest, "kandang_ids is required") } + baseName := strings.TrimSpace(req.FlockName) + if baseName == "" { + return nil, fiber.NewError(fiber.StatusBadRequest, "Flock name cannot be empty") + } + if err := commonSvc.EnsureRelations(c.Context(), - commonSvc.RelationCheck{Name: "Flock", ID: &req.FlockId, Exists: relationExistsChecker[entity.Flock](s.Repository.DB())}, - commonSvc.RelationCheck{Name: "Area", ID: &req.AreaId, Exists: relationExistsChecker[entity.Area](s.Repository.DB())}, - commonSvc.RelationCheck{Name: "FCR", ID: &req.FcrId, Exists: relationExistsChecker[entity.Fcr](s.Repository.DB())}, - commonSvc.RelationCheck{Name: "Location", ID: &req.LocationId, Exists: relationExistsChecker[entity.Location](s.Repository.DB())}, + commonSvc.RelationCheck{Name: "Area", ID: &req.AreaId, Exists: s.Repository.AreaExists}, + commonSvc.RelationCheck{Name: "FCR", ID: &req.FcrId, Exists: s.Repository.FcrExists}, + commonSvc.RelationCheck{Name: "Location", ID: &req.LocationId, Exists: s.Repository.LocationExists}, ); err != nil { return nil, err } + canonicalBase := baseName + if s.FlockRepo != nil { + baseFlock, err := s.ensureFlockByName(c.Context(), actorID, baseName) + if err != nil { + return nil, err + } + canonicalBase = baseFlock.Name + } + kandangIDs := uniqueUintSlice(req.KandangIds) kandangs, err := s.KandangRepo.GetByIDs(c.Context(), kandangIDs, nil) if err != nil { @@ -250,29 +206,34 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* if len(kandangs) != len(kandangIDs) { return nil, fiber.NewError(fiber.StatusNotFound, "Some kandangs not found") } - for _, kandang := range kandangs { - if kandang.ProjectFlockId != nil { - return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Kandang %s sudah memiliki project flock", kandang.Name)) - } + // larang kalau ada yg sudah terikat ke project lain + if linked, err := s.pivotRepo().HasKandangsLinkedToOtherProject(c.Context(), kandangIDs, nil); err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate kandangs linkage") + } else if linked { + return nil, fiber.NewError(fiber.StatusConflict, "Beberapa kandang sudah terikat dengan project flock lain") } createBody := &entity.ProjectFlock{ - FlockId: req.FlockId, AreaId: req.AreaId, Category: cat, FcrId: req.FcrId, LocationId: req.LocationId, - CreatedBy: 1, + CreatedBy: actorID, } err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { projectRepo := repository.NewProjectflockRepository(dbTransaction) - period, err := projectRepo.GetNextPeriodForFlock(c.Context(), req.FlockId) + nextSeq, err := projectRepo.GetNextSequenceForBase(c.Context(), canonicalBase) if err != nil { return err } - createBody.Period = period + generatedName, seq, err := s.generateSequentialFlockName(c.Context(), projectRepo, canonicalBase, nextSeq, nil) + if err != nil { + return err + } + createBody.FlockName = generatedName + createBody.Period = seq if err := projectRepo.CreateOne(c.Context(), createBody, nil); err != nil { return err @@ -282,7 +243,6 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* return err } - actorID := uint(1) //TODO: Change From Auth action := entity.ApprovalActionCreated approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) _, err = approvalSvcTx.CreateApproval( @@ -298,11 +258,14 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* }) if err != nil { + if fiberErr, ok := err.(*fiber.Error); ok { + return nil, fiberErr + } if errors.Is(err, gorm.ErrDuplicatedKey) { return nil, fiber.NewError(fiber.StatusConflict, "Project flock period already exists") } s.Log.Errorf("Failed to create projectflock: %+v", err) - return nil, err + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create project flock") } return s.GetOne(c, createBody.Id) @@ -313,7 +276,12 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id return nil, err } - existing, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + actorID, err := actorIDFromContext(c) + if err != nil { + return nil, err + } + + existing, err := s.Repository.GetByID(c.Context(), id, s.Repository.WithDefaultRelations()) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found") } @@ -324,15 +292,28 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id updateBody := make(map[string]any) hasBodyChanges := false var relationChecks []commonSvc.RelationCheck + existingBase := pfutils.DeriveBaseName(existing.FlockName) + targetBaseName := existingBase + needFlockNameRegenerate := false - if req.FlockId != nil { - updateBody["flock_id"] = *req.FlockId - hasBodyChanges = true - relationChecks = append(relationChecks, commonSvc.RelationCheck{ - Name: "Flock", - ID: req.FlockId, - Exists: relationExistsChecker[entity.Flock](s.Repository.DB()), - }) + if req.FlockName != nil { + trimmed := strings.TrimSpace(*req.FlockName) + if trimmed == "" { + return nil, fiber.NewError(fiber.StatusBadRequest, "Flock name cannot be empty") + } + canonicalBase := trimmed + if s.FlockRepo != nil { + flockEntity, err := s.ensureFlockByName(c.Context(), actorID, trimmed) + if err != nil { + return nil, err + } + canonicalBase = flockEntity.Name + } + if !strings.EqualFold(canonicalBase, existingBase) { + needFlockNameRegenerate = true + targetBaseName = canonicalBase + hasBodyChanges = true + } } if req.AreaId != nil { updateBody["area_id"] = *req.AreaId @@ -340,7 +321,7 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id relationChecks = append(relationChecks, commonSvc.RelationCheck{ Name: "Area", ID: req.AreaId, - Exists: relationExistsChecker[entity.Area](s.Repository.DB()), + Exists: s.Repository.AreaExists, }) } if req.Category != nil { @@ -357,7 +338,7 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id relationChecks = append(relationChecks, commonSvc.RelationCheck{ Name: "FCR", ID: req.FcrId, - Exists: relationExistsChecker[entity.Fcr](s.Repository.DB()), + Exists: s.Repository.FcrExists, }) } if req.LocationId != nil { @@ -366,7 +347,7 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id relationChecks = append(relationChecks, commonSvc.RelationCheck{ Name: "Location", ID: req.LocationId, - Exists: relationExistsChecker[entity.Location](s.Repository.DB()), + Exists: s.Repository.LocationExists, }) } @@ -394,11 +375,12 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id if len(kandangs) != len(newKandangIDs) { return nil, fiber.NewError(fiber.StatusNotFound, "Some kandangs not found") } - for _, k := range kandangs { - if k.ProjectFlockId != nil && *k.ProjectFlockId != id { - return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Kandang %s sudah terikat dengan project flock lain", k.Name)) - } + if linked, err := s.pivotRepo().HasKandangsLinkedToOtherProject(c.Context(), newKandangIDs, &id); err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate kandangs linkage") + } else if linked { + return nil, fiber.NewError(fiber.StatusConflict, "Beberapa kandang sudah terikat dengan project flock lain") } + } hasChanges := hasBodyChanges || hasKandangChanges @@ -409,6 +391,29 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { projectRepo := repository.NewProjectflockRepository(dbTransaction) + baseForGeneration := targetBaseName + if strings.TrimSpace(baseForGeneration) == "" { + baseForGeneration = existingBase + } + if strings.TrimSpace(baseForGeneration) == "" { + baseForGeneration = strings.TrimSpace(existing.FlockName) + } + + if needFlockNameRegenerate { + nextSeq, err := projectRepo.GetNextSequenceForBase(c.Context(), baseForGeneration) + if err != nil { + return err + } + newName, seq, err := s.generateSequentialFlockName(c.Context(), projectRepo, baseForGeneration, nextSeq, &id) + if err != nil { + return err + } + updateBody["flock_name"] = newName + if seq != existing.Period { + updateBody["period"] = seq + } + } + if len(updateBody) > 0 { if err := projectRepo.PatchOne(c.Context(), id, updateBody, nil); err != nil { return err @@ -457,7 +462,6 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id } if hasChanges { - actorID := uint(1) //TODO: Change From Auth approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) if approvalSvc != nil { latestBeforeReset, err := approvalSvc.LatestByTarget(c.Context(), s.approvalWorkflow, id, nil) @@ -497,7 +501,10 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id return nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found") } s.Log.Errorf("Failed to update projectflock %d: %+v", id, err) - return nil, err + if errors.Is(err, gorm.ErrDuplicatedKey) { + return nil, fiber.NewError(fiber.StatusConflict, "Project flock period already exists") + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update project flock") } return s.GetOne(c, id) @@ -508,7 +515,11 @@ func (s projectflockService) Approval(c *fiber.Ctx, req *validation.Approve) ([] return nil, err } - actorID := uint(1) // TODO: change from auth context + actorID, err := actorIDFromContext(c) + if err != nil { + return nil, err + } + var action entity.ApprovalAction switch strings.ToUpper(strings.TrimSpace(req.Action)) { case string(entity.ApprovalActionRejected): @@ -529,7 +540,7 @@ func (s projectflockService) Approval(c *fiber.Ctx, req *validation.Approve) ([] step = utils.ProjectFlockStepAktif } - err := s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) kandangRepoTx := kandangRepository.NewKandangRepository(dbTransaction) projectRepoTx := repository.NewProjectflockRepository(dbTransaction) @@ -601,7 +612,7 @@ func (s projectflockService) Approval(c *fiber.Ctx, req *validation.Approve) ([] } func (s projectflockService) DeleteOne(c *fiber.Ctx, id uint) error { - existing, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + existing, err := s.Repository.GetByID(c.Context(), id, s.Repository.WithDefaultRelations()) if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, "Projectflock not found") } @@ -635,30 +646,32 @@ func (s projectflockService) DeleteOne(c *fiber.Ctx, id uint) error { return fiberErr } s.Log.Errorf("Failed to delete projectflock %d: %+v", id, err) - return err + return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete project flock") } return nil } -func (s projectflockService) GetProjectFlockKandang(ctx *fiber.Ctx, id uint) (*entity.ProjectFlockKandang, error) { - // keep for backward compatibility; delegate to new consolidated method - return s.GetProjectFlockKandangByParams(ctx, fmt.Sprintf("%d", id), "", "") -} - -func (s projectflockService) GetProjectFlockKandangByProjectAndKandang(ctx *fiber.Ctx, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, error) { +func (s projectflockService) GetProjectFlockKandangByProjectAndKandang(ctx *fiber.Ctx, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, float64, error) { pfk, err := s.PivotRepo.GetByProjectFlockAndKandang(ctx.Context(), projectFlockID, kandangID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found") + return nil, 0, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found") } - return nil, err + s.Log.Errorf("Failed to fetch project_flock_kandang by project %d and kandang %d: %+v", projectFlockID, kandangID, err) + return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang") } - return pfk, nil + + availableQuantity, err := s.GetAvailableDocQuantity(ctx, pfk.KandangId) + if err != nil { + return nil, 0, err + } + + return pfk, availableQuantity, nil } -func (s projectflockService) GetProjectFlockKandangByParams(ctx *fiber.Ctx, idStr string, projectFlockIdStr string, kandangIdStr string) (*entity.ProjectFlockKandang, error) { +func (s projectflockService) GetProjectFlockKandangByParams(ctx *fiber.Ctx, idStr string, projectFlockIdStr string, kandangIdStr string) (*entity.ProjectFlockKandang, float64, error) { idStr = strings.TrimSpace(idStr) projectFlockIdStr = strings.TrimSpace(projectFlockIdStr) kandangIdStr = strings.TrimSpace(kandangIdStr) @@ -666,52 +679,107 @@ func (s projectflockService) GetProjectFlockKandangByParams(ctx *fiber.Ctx, idSt if idStr != "" { id, err := strconv.Atoi(idStr) if err != nil || id <= 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + return nil, 0, fiber.NewError(fiber.StatusBadRequest, "Invalid Id") } pfk, err := s.PivotRepo.GetByID(ctx.Context(), uint(id)) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found") + return nil, 0, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found") } - return nil, err + s.Log.Errorf("Failed to fetch project_flock_kandang %d: %+v", id, err) + return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang") } - return pfk, nil + + availableQuantity, err := s.GetAvailableDocQuantity(ctx, pfk.KandangId) + if err != nil { + return nil, 0, err + } + + return pfk, availableQuantity, nil } if projectFlockIdStr == "" || kandangIdStr == "" { - return nil, fiber.NewError(fiber.StatusBadRequest, "Missing lookup parameters") + return nil, 0, fiber.NewError(fiber.StatusBadRequest, "Missing lookup parameters") } pfid, err := strconv.Atoi(projectFlockIdStr) if err != nil || pfid <= 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id") + return nil, 0, fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id") } kid, err := strconv.Atoi(kandangIdStr) if err != nil || kid <= 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid kandang_id") + return nil, 0, fiber.NewError(fiber.StatusBadRequest, "Invalid kandang_id") } return s.GetProjectFlockKandangByProjectAndKandang(ctx, uint(pfid), uint(kid)) } -func (s projectflockService) GetFlockPeriodSummary(c *fiber.Ctx, flockID uint) (*FlockPeriodSummary, error) { - flock, err := s.FlockRepo.GetByID(c.Context(), flockID, func(db *gorm.DB) *gorm.DB { - return db.Preload("CreatedUser") - }) - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Flock not found") - } +func (s projectflockService) GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID uint) (float64, error) { + + wh, err := s.WarehouseRepo.GetByKandangID(ctx.Context(), kandangID) if err != nil { - s.Log.Errorf("Failed get flock %d for period summary: %+v", flockID, err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch flock") + return 0, err } - maxPeriod, err := s.Repository.GetMaxPeriodByFlock(c.Context(), flockID) + productWarehouses, err := s.ProductWarehouseRepo.GetByCategoryCodeAndWarehouseID(ctx.Context(), "DOC", wh.Id) if err != nil { - s.Log.Errorf("Failed to compute next period for flock %d: %+v", flockID, err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to compute next period") + return 0, err + } + + total := 0.0 + for _, pw := range productWarehouses { + total += pw.Quantity + } + return total, nil +} + +func (s projectflockService) GetFlockPeriodSummary(c *fiber.Ctx, projectFlockKandangID uint) (*FlockPeriodSummary, error) { + if projectFlockKandangID == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required") + } + + pivot, err := s.pivotRepo().GetByID(c.Context(), projectFlockKandangID) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Project flock kandang not found") + } + if err != nil { + s.Log.Errorf("Failed to fetch project_flock_kandang %d: %+v", projectFlockKandangID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang") + } + + var baseName string + var referenceFlock *entity.Flock + if pivot.ProjectFlock.Id != 0 { + baseName = pfutils.DeriveBaseName(pivot.ProjectFlock.FlockName) + } + + if strings.TrimSpace(baseName) != "" { + referenceFlock, err = s.FlockRepo.GetByName(c.Context(), baseName) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to fetch flock %q: %+v", baseName, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch flock") + } + } + + if referenceFlock == nil { + referenceFlock = &entity.Flock{Name: pivot.ProjectFlock.FlockName} + } + + maxPeriod := pivot.ProjectFlock.Period + if strings.TrimSpace(baseName) != "" { + if headerMax, err := s.Repository.GetMaxPeriodByBaseName(c.Context(), baseName); err != nil { + s.Log.Warnf("Unable to compute header period for base %q: %+v", baseName, err) + } else if headerMax > maxPeriod { + maxPeriod = headerMax + } + + if pivotMax, err := s.pivotRepo().MaxPeriodByBaseName(c.Context(), baseName); err != nil { + s.Log.Warnf("Unable to compute pivot period for base %q: %+v", baseName, err) + } else if pivotMax > maxPeriod { + maxPeriod = pivotMax + } } return &FlockPeriodSummary{ - Flock: *flock, + Flock: *referenceFlock, NextPeriod: maxPeriod + 1, }, nil } @@ -729,45 +797,64 @@ func uniqueUintSlice(values []uint) []uint { return result } -func relationExistsChecker[T any](db *gorm.DB) func(context.Context, uint) (bool, error) { - return func(ctx context.Context, id uint) (bool, error) { - return commonRepo.Exists[T](ctx, db, id) +func (s projectflockService) generateSequentialFlockName(ctx context.Context, repo repository.ProjectflockRepository, baseName string, startNumber int, excludeID *uint) (string, int, error) { + name := strings.TrimSpace(baseName) + if name == "" { + return "", 0, fiber.NewError(fiber.StatusBadRequest, "Base flock name cannot be empty") + } + + number := startNumber + if number <= 0 { + number = 1 + } + + attempts := 0 + for { + candidate := fmt.Sprintf("%s %03d", name, number) + exists, err := repo.ExistsByFlockName(ctx, candidate, excludeID) + if err != nil { + s.Log.Errorf("Failed checking project flock name uniqueness for %q: %+v", candidate, err) + return "", 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to generate flock name") + } + if !exists { + return candidate, number, nil + } + number++ + attempts++ + if attempts > 9999 { + return "", 0, fiber.NewError(fiber.StatusInternalServerError, "Unable to generate unique flock name") + } } } -func (s projectflockService) buildOrderExpressions(sortBy, sortOrder string) []string { - direction := "ASC" - if strings.ToLower(sortOrder) == "desc" { - direction = "DESC" +func (s projectflockService) ensureFlockByName(ctx context.Context, actorID uint, name string) (*entity.Flock, error) { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + return nil, fiber.NewError(fiber.StatusBadRequest, "Flock name cannot be empty") } - switch sortBy { - case "area": - return []string{ - fmt.Sprintf("(SELECT name FROM areas WHERE areas.id = project_flocks.area_id) %s", direction), - fmt.Sprintf("project_flocks.id %s", direction), - } - case "location": - return []string{ - fmt.Sprintf("(SELECT name FROM locations WHERE locations.id = project_flocks.location_id) %s", direction), - fmt.Sprintf("project_flocks.id %s", direction), - } - case "kandangs": - return []string{ - fmt.Sprintf("(SELECT COUNT(*) FROM kandangs WHERE kandangs.project_flock_id = project_flocks.id) %s", direction), - fmt.Sprintf("project_flocks.id %s", direction), - } - case "period": - return []string{ - fmt.Sprintf("project_flocks.period %s", direction), - fmt.Sprintf("project_flocks.id %s", direction), - } - default: - return []string{ - "project_flocks.created_at DESC", - "project_flocks.updated_at DESC", - } + flock, err := s.FlockRepo.GetByName(ctx, trimmed) + if err == nil { + return flock, nil } + if !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to fetch flock by name %q: %+v", trimmed, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to prepare flock data") + } + + newFlock := &entity.Flock{ + Name: trimmed, + CreatedBy: actorID, + } + if err := s.FlockRepo.CreateOne(ctx, newFlock, nil); err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + return s.FlockRepo.GetByName(ctx, trimmed) + } + s.Log.Errorf("Failed to create flock %q: %+v", trimmed, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to prepare flock data") + } + + return newFlock, nil } func (s projectflockService) attachKandangs(ctx context.Context, dbTransaction *gorm.DB, projectFlockID uint, kandangIDs []uint) error { @@ -775,24 +862,45 @@ func (s projectflockService) attachKandangs(ctx context.Context, dbTransaction * return nil } - if err := dbTransaction.Model(&entity.Kandang{}). - Where("id IN ?", kandangIDs). - Updates(map[string]any{ - "project_flock_id": projectFlockID, - "status": string(utils.KandangStatusPengajuan), - }).Error; err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs") + if err := s.kandangRepoWithTx(dbTransaction).UpdateStatusByIDs(ctx, kandangIDs, utils.KandangStatusPengajuan); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs status") } - pivotRepo := s.pivotRepoWithTx(dbTransaction) - records := make([]*entity.ProjectFlockKandang, len(kandangIDs)) - for i, id := range kandangIDs { - records[i] = &entity.ProjectFlockKandang{ - ProjectFlockId: projectFlockID, - KandangId: id, + already, err := s.pivotRepoWithTx(dbTransaction).ListExistingKandangIDs(ctx, projectFlockID, kandangIDs) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to check existing pivot") + } + exists := make(map[uint]struct{}, len(already)) + for _, id := range already { + exists[id] = struct{}{} + } + + var toAttach []uint + seen := make(map[uint]struct{}, len(kandangIDs)) + for _, id := range kandangIDs { + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + if _, ok := exists[id]; !ok { + toAttach = append(toAttach, id) } } - if err := pivotRepo.CreateMany(ctx, records); err != nil { + if len(toAttach) == 0 { + return nil + } + + records := make([]*entity.ProjectFlockKandang, 0, len(toAttach)) + for _, id := range toAttach { + records = append(records, &entity.ProjectFlockKandang{ + ProjectFlockId: projectFlockID, + KandangId: id, + }) + } + if err := s.pivotRepoWithTx(dbTransaction).CreateMany(ctx, records); err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + return fiber.NewError(fiber.StatusConflict, "Beberapa kandang sudah terhubung dengan project flock ini") + } return fiber.NewError(fiber.StatusInternalServerError, "Failed to persist project flock history") } return nil @@ -803,15 +911,27 @@ func (s projectflockService) detachKandangs(ctx context.Context, dbTransaction * return nil } - updates := map[string]any{"project_flock_id": nil} - if resetStatus { - updates["status"] = string(utils.KandangStatusNonActive) + blocked, err := s.pivotRepoWithTx(dbTransaction).FindKandangsWithRecordings(ctx, projectFlockID, kandangIDs) + if err != nil { + s.Log.Errorf("Failed to check recordings before detaching kandangs: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate kandang detachment") + } + if len(blocked) > 0 { + names := make([]string, 0, len(blocked)) + for _, item := range blocked { + label := fmt.Sprintf("ID %d", item.Id) + if strings.TrimSpace(item.Name) != "" { + label = fmt.Sprintf("%s (%s)", label, item.Name) + } + names = append(names, label) + } + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Tidak dapat melepas kandang karena sudah memiliki recording: %s", strings.Join(names, ", "))) } - if err := dbTransaction.Model(&entity.Kandang{}). - Where("id IN ?", kandangIDs). - Updates(updates).Error; err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs") + if resetStatus { + if err := s.kandangRepoWithTx(dbTransaction).UpdateStatusByIDs(ctx, kandangIDs, utils.KandangStatusNonActive); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs status") + } } if err := s.pivotRepoWithTx(dbTransaction).DeleteMany(ctx, projectFlockID, kandangIDs); err != nil { @@ -821,8 +941,33 @@ func (s projectflockService) detachKandangs(ctx context.Context, dbTransaction * } func (s projectflockService) pivotRepoWithTx(dbTransaction *gorm.DB) repository.ProjectFlockKandangRepository { - if s.PivotRepo == nil { - return repository.NewProjectFlockKandangRepository(dbTransaction) + if dbTransaction == nil { + return s.pivotRepo() } - return s.PivotRepo.WithTx(dbTransaction) + return s.pivotRepo().WithTx(dbTransaction) +} + +func (s projectflockService) pivotRepo() repository.ProjectFlockKandangRepository { + if s.PivotRepo != nil { + return s.PivotRepo + } + return repository.NewProjectFlockKandangRepository(s.Repository.DB()) +} + +func (s projectflockService) kandangRepoWithTx(tx *gorm.DB) kandangRepository.KandangRepository { + if tx != nil { + return kandangRepository.NewKandangRepository(tx) + } + if s.KandangRepo != nil { + return s.KandangRepo + } + return kandangRepository.NewKandangRepository(s.Repository.DB()) +} + +func actorIDFromContext(c *fiber.Ctx) (uint, error) { + user, ok := authmiddleware.AuthenticatedUser(c) + if !ok || user == nil || user.Id == 0 { + return 0, fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") + } + return user.Id, nil } diff --git a/internal/modules/production/project_flocks/utils/base_name.go b/internal/modules/production/project_flocks/utils/base_name.go new file mode 100644 index 00000000..93e8af53 --- /dev/null +++ b/internal/modules/production/project_flocks/utils/base_name.go @@ -0,0 +1,25 @@ +package utils + +import ( + "strconv" + "strings" +) + +// DeriveBaseName removes trailing numeric tokens from the flock name. +func DeriveBaseName(name string) string { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + return "" + } + + parts := strings.Fields(trimmed) + for len(parts) > 0 { + if _, err := strconv.Atoi(parts[len(parts)-1]); err == nil { + parts = parts[:len(parts)-1] + continue + } + break + } + + return strings.TrimSpace(strings.Join(parts, " ")) +} diff --git a/internal/modules/production/project_flocks/validations/projectflock.validation.go b/internal/modules/production/project_flocks/validations/projectflock.validation.go index f853c883..7932e07e 100644 --- a/internal/modules/production/project_flocks/validations/projectflock.validation.go +++ b/internal/modules/production/project_flocks/validations/projectflock.validation.go @@ -1,7 +1,7 @@ package validation type Create struct { - FlockId uint `json:"flock_id" validate:"required_strict,number,gt=0"` + FlockName string `json:"flock_name" validate:"required_strict"` AreaId uint `json:"area_id" validate:"required_strict,number,gt=0"` Category string `json:"category" validate:"required_strict"` FcrId uint `json:"fcr_id" validate:"required_strict,number,gt=0"` @@ -10,7 +10,7 @@ type Create struct { } type Update struct { - FlockId *uint `json:"flock_id,omitempty" validate:"omitempty,number,gt=0"` + FlockName *string `json:"flock_name,omitempty" validate:"omitempty"` AreaId *uint `json:"area_id,omitempty" validate:"omitempty,number,gt=0"` Category *string `json:"category,omitempty" validate:"omitempty"` FcrId *uint `json:"fcr_id,omitempty" validate:"omitempty,number,gt=0"` diff --git a/internal/modules/production/recordings/controllers/recording.controller.go b/internal/modules/production/recordings/controllers/recording.controller.go index 1215e8fc..c348a454 100644 --- a/internal/modules/production/recordings/controllers/recording.controller.go +++ b/internal/modules/production/recordings/controllers/recording.controller.go @@ -23,10 +23,14 @@ func NewRecordingController(recordingService service.RecordingService) *Recordin } func (u *RecordingController) GetAll(c *fiber.Ctx) error { + projectFlockID := c.QueryInt("project_flock_kandang_id", 0) + 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), + } + if projectFlockID > 0 { + query.ProjectFlockKandangId = uint(projectFlockID) } result, totalResults, err := u.RecordingService.GetAll(c, query) @@ -67,7 +71,30 @@ func (u *RecordingController) GetOne(c *fiber.Ctx) error { Code: fiber.StatusOK, Status: "success", Message: "Get recording successfully", - Data: dto.ToRecordingListDTO(*result), + Data: dto.ToRecordingDetailDTO(*result), + }) +} + +func (u *RecordingController) GetNextDay(c *fiber.Ctx) error { + projectFlockID := c.QueryInt("project_flock_kandang_id", 0) + if projectFlockID <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required") + } + + nextDay, err := u.RecordingService.GetNextDay(c, uint(projectFlockID)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get next recording day successfully", + Data: fiber.Map{ + "project_flock_kandang_id": projectFlockID, + "next_day": nextDay, + }, }) } @@ -88,7 +115,7 @@ func (u *RecordingController) CreateOne(c *fiber.Ctx) error { Code: fiber.StatusCreated, Status: "success", Message: "Create recording successfully", - Data: dto.ToRecordingListDTO(*result), + Data: dto.ToRecordingDetailDTO(*result), }) } @@ -115,7 +142,61 @@ func (u *RecordingController) UpdateOne(c *fiber.Ctx) error { Code: fiber.StatusOK, Status: "success", Message: "Update recording successfully", - Data: dto.ToRecordingListDTO(*result), + Data: dto.ToRecordingDetailDTO(*result), + }) +} + +func (u *RecordingController) SubmitGrading(c *fiber.Ctx) error { + req := new(validation.SubmitGrading) + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.RecordingService.SubmitGrading(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Submit grading eggs successfully", + Data: dto.ToRecordingDetailDTO(*result), + }) +} + +func (u *RecordingController) Approve(c *fiber.Ctx) error { + req := new(validation.Approve) + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + results, err := u.RecordingService.Approval(c, req) + if err != nil { + return err + } + + var ( + data interface{} + message = "Submit recording approvals successfully" + ) + + if len(results) == 1 { + message = "Submit recording approval successfully" + data = dto.ToRecordingDetailDTO(results[0]) + } else { + data = dto.ToRecordingListDTOs(results) + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: message, + Data: data, }) } diff --git a/internal/modules/production/recordings/dto/recording.dto.go b/internal/modules/production/recordings/dto/recording.dto.go index 7dbdec98..e8d04758 100644 --- a/internal/modules/production/recordings/dto/recording.dto.go +++ b/internal/modules/production/recordings/dto/recording.dto.go @@ -1,51 +1,142 @@ package dto import ( + "math" "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" ) // === DTO Structs === type RecordingBaseDTO struct { - Id uint `json:"id"` - Name string `json:"name"` + Id uint `json:"id"` + ProjectFlockKandangId uint `json:"project_flock_kandang_id"` + RecordDatetime time.Time `json:"record_datetime"` + Day *int `json:"day,omitempty"` + ProjectFlockCategory *string `json:"project_flock_category,omitempty"` + TotalDepletionQty *float64 `json:"total_depletion_qty,omitempty"` + CumDepletionRate *float64 `json:"cum_depletion_rate,omitempty"` + DailyGain *float64 `json:"daily_gain,omitempty"` + AvgDailyGain *float64 `json:"avg_daily_gain,omitempty"` + CumIntake *int `json:"cum_intake,omitempty"` + FcrValue *float64 `json:"fcr_value,omitempty"` + TotalChickQty *float64 `json:"total_chick_qty,omitempty"` + Approval approvalDTO.ApprovalBaseDTO `json:"approval"` + EggGradingStatus *string `json:"egg_grading_status,omitempty"` + EggGradingPendingQty *int `json:"egg_grading_pending_qty,omitempty"` + EggGradingCompletedQty *int `json:"egg_grading_completed_qty,omitempty"` } type RecordingListDTO struct { RecordingBaseDTO CreatedUser *userDTO.UserBaseDTO `json:"created_user"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type RecordingDetailDTO struct { RecordingListDTO + BodyWeights []RecordingBodyWeightDTO `json:"body_weights"` + Depletions []RecordingDepletionDTO `json:"depletions"` + Stocks []RecordingStockDTO `json:"stocks"` + Eggs []RecordingEggDTO `json:"eggs"` +} + +type RecordingBodyWeightDTO struct { + AvgWeight float64 `json:"avg_weight"` + Qty float64 `json:"qty"` + TotalWeight float64 `json:"total_weight"` +} + +type RecordingDepletionDTO struct { + ProductWarehouseId uint `json:"product_warehouse_id"` + Qty float64 `json:"qty"` + ProductWarehouse *RecordingProductWarehouseDTO `json:"product_warehouse,omitempty"` +} + +type RecordingStockDTO struct { + ProductWarehouseId uint `json:"product_warehouse_id"` + UsageAmount *float64 `json:"usage_amount,omitempty"` + PendingQty *float64 `json:"pending_qty,omitempty"` + ProductWarehouse *RecordingProductWarehouseDTO `json:"product_warehouse,omitempty"` +} + +type RecordingEggDTO struct { + ProductWarehouseId uint `json:"product_warehouse_id"` + Qty int `json:"qty"` + ProductWarehouse *RecordingProductWarehouseDTO `json:"product_warehouse,omitempty"` + Gradings []RecordingEggGradingDTO `json:"gradings,omitempty"` +} + +type RecordingProductWarehouseDTO struct { + Id uint `json:"id"` + ProductId uint `json:"product_id"` + ProductName string `json:"product_name"` + WarehouseId uint `json:"warehouse_id"` + WarehouseName string `json:"warehouse_name"` +} + +type RecordingEggGradingDTO struct { + Grade string `json:"grade,omitempty"` + Qty float64 `json:"qty"` } // === Mapper Functions === func ToRecordingBaseDTO(e entity.Recording) RecordingBaseDTO { + var projectFlockCategory *string + if e.ProjectFlockKandang != nil && e.ProjectFlockKandang.ProjectFlock.Id != 0 { + category := e.ProjectFlockKandang.ProjectFlock.Category + if category != "" { + projectFlockCategory = &category + } + } + + latestApproval := defaultRecordingLatestApproval(e) + if e.LatestApproval != nil { + snapshot := approvalDTO.ToApprovalDTO(*e.LatestApproval) + latestApproval = snapshot + } + + gradingStatus, gradingPending, gradingCompleted := computeEggGradingStatus(e) + return RecordingBaseDTO{ - Id: e.Id, - Name: e.Name, + Id: e.Id, + ProjectFlockKandangId: e.ProjectFlockKandangId, + RecordDatetime: e.RecordDatetime, + Day: e.Day, + ProjectFlockCategory: projectFlockCategory, + TotalDepletionQty: e.TotalDepletionQty, + CumDepletionRate: e.CumDepletionRate, + DailyGain: e.DailyGain, + AvgDailyGain: e.AvgDailyGain, + CumIntake: e.CumIntake, + FcrValue: e.FcrValue, + TotalChickQty: e.TotalChickQty, + Approval: latestApproval, + EggGradingStatus: gradingStatus, + EggGradingPendingQty: gradingPending, + EggGradingCompletedQty: gradingCompleted, } } func ToRecordingListDTO(e entity.Recording) RecordingListDTO { var createdUser *userDTO.UserBaseDTO - if e.CreatedUser.Id != 0 { - mapped := userDTO.ToUserBaseDTO(e.CreatedUser) + if e.CreatedUser != nil && e.CreatedUser.Id != 0 { + mapped := userDTO.ToUserBaseDTO(*e.CreatedUser) createdUser = &mapped } return RecordingListDTO{ RecordingBaseDTO: ToRecordingBaseDTO(e), - CreatedAt: e.CreatedAt, - UpdatedAt: e.UpdatedAt, - CreatedUser: createdUser, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, } } @@ -60,5 +151,174 @@ func ToRecordingListDTOs(e []entity.Recording) []RecordingListDTO { func ToRecordingDetailDTO(e entity.Recording) RecordingDetailDTO { return RecordingDetailDTO{ RecordingListDTO: ToRecordingListDTO(e), + BodyWeights: ToRecordingBodyWeightDTOs(e.BodyWeights), + Depletions: ToRecordingDepletionDTOs(e.Depletions), + Stocks: ToRecordingStockDTOs(e.Stocks), + Eggs: ToRecordingEggDTOs(e.Eggs), } } + +func ToRecordingBodyWeightDTOs(bodyWeights []entity.RecordingBW) []RecordingBodyWeightDTO { + result := make([]RecordingBodyWeightDTO, len(bodyWeights)) + for i, bw := range bodyWeights { + result[i] = RecordingBodyWeightDTO{ + AvgWeight: bw.AvgWeight, + Qty: bw.Qty, + TotalWeight: bw.TotalWeight, + } + } + return result +} + +func ToRecordingDepletionDTOs(depletions []entity.RecordingDepletion) []RecordingDepletionDTO { + result := make([]RecordingDepletionDTO, len(depletions)) + for i, d := range depletions { + result[i] = RecordingDepletionDTO{ + ProductWarehouseId: d.ProductWarehouseId, + Qty: d.Qty, + ProductWarehouse: toRecordingProductWarehouseDTO(&d.ProductWarehouse), + } + } + return result +} + +func ToRecordingStockDTOs(stocks []entity.RecordingStock) []RecordingStockDTO { + result := make([]RecordingStockDTO, len(stocks)) + for i, s := range stocks { + result[i] = RecordingStockDTO{ + ProductWarehouseId: s.ProductWarehouseId, + UsageAmount: s.UsageQty, + PendingQty: s.PendingQty, + ProductWarehouse: toRecordingProductWarehouseDTO(&s.ProductWarehouse), + } + } + return result +} + +func ToRecordingEggDTOs(eggs []entity.RecordingEgg) []RecordingEggDTO { + result := make([]RecordingEggDTO, len(eggs)) + for i, egg := range eggs { + result[i] = RecordingEggDTO{ + ProductWarehouseId: egg.ProductWarehouseId, + Qty: egg.Qty, + ProductWarehouse: toRecordingProductWarehouseDTO(&egg.ProductWarehouse), + Gradings: ToRecordingEggGradingDTOs(egg.GradingEggs), + } + } + return result +} + +func ToRecordingEggGradingDTOs(gradings []entity.GradingEgg) []RecordingEggGradingDTO { + if len(gradings) == 0 { + return nil + } + + result := make([]RecordingEggGradingDTO, len(gradings)) + for i, grading := range gradings { + result[i] = RecordingEggGradingDTO{ + Grade: grading.Grade, + Qty: grading.Qty, + } + } + + return result +} + +func toRecordingProductWarehouseDTO(pw *entity.ProductWarehouse) *RecordingProductWarehouseDTO { + if pw == nil || pw.Id == 0 { + return nil + } + + dto := RecordingProductWarehouseDTO{ + Id: pw.Id, + ProductId: pw.ProductId, + WarehouseId: pw.WarehouseId, + } + + if pw.Product.Id != 0 { + dto.ProductName = pw.Product.Name + } + if pw.Warehouse.Id != 0 { + dto.WarehouseName = pw.Warehouse.Name + } + + return &dto +} + +const goodEggProductWarehouseID uint = 5 + +func computeEggGradingStatus(e entity.Recording) (*string, *int, *int) { + goodEggs := filterGoodEggs(e.Eggs) + if len(goodEggs) == 0 { + return nil, nil, nil + } + + totalEggs := 0 + totalGraded := 0.0 + for _, egg := range goodEggs { + totalEggs += egg.Qty + for _, grading := range egg.GradingEggs { + totalGraded += grading.Qty + } + } + + if totalEggs == 0 { + return nil, nil, nil + } + + pendingFloat := float64(totalEggs) - totalGraded + if pendingFloat < 0 { + pendingFloat = 0 + } + pendingInt := int(math.Round(pendingFloat)) + completedInt := int(math.Round(totalGraded)) + if completedInt < 0 { + completedInt = 0 + } + + if pendingInt > 0 { + status := "GRADING_TELUR" + return &status, &pendingInt, &completedInt + } + + status := "GRADING_SELESAI" + zero := 0 + return &status, &zero, &completedInt +} + +func filterGoodEggs(eggs []entity.RecordingEgg) []entity.RecordingEgg { + if len(eggs) == 0 { + return nil + } + + result := make([]entity.RecordingEgg, 0, len(eggs)) + for _, egg := range eggs { + if egg.ProductWarehouseId == goodEggProductWarehouseID { + result = append(result, egg) + } + } + return result +} + +func defaultRecordingLatestApproval(e entity.Recording) approvalDTO.ApprovalBaseDTO { + result := approvalDTO.ApprovalBaseDTO{} + + step := utils.RecordingStepPengajuan + result.StepNumber = uint16(step) + if label, ok := approvalutils.ApprovalStepName(utils.ApprovalWorkflowRecording, step); ok { + result.StepName = label + } else if label, ok := utils.RecordingApprovalSteps[step]; ok { + result.StepName = label + } + + if e.CreatedUser != nil && e.CreatedUser.Id != 0 { + result.ActionBy = userDTO.ToUserBaseDTO(*e.CreatedUser) + } else if e.CreatedBy != 0 { + result.ActionBy = userDTO.UserBaseDTO{ + Id: e.CreatedBy, + IdUser: int64(e.CreatedBy), + } + } + + return result +} diff --git a/internal/modules/production/recordings/module.go b/internal/modules/production/recordings/module.go index 36ae8dd7..ff6b4ea0 100644 --- a/internal/modules/production/recordings/module.go +++ b/internal/modules/production/recordings/module.go @@ -1,12 +1,19 @@ package recordings import ( + "fmt" + "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "gorm.io/gorm" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" sRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services" + "gitlab.com/mbugroup/lti-api.git/internal/utils" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -16,11 +23,27 @@ type RecordingModule struct{} func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { recordingRepo := rRecording.NewRecordingRepository(db) + projectFlockKandangRepo := rProjectFlock.NewProjectFlockKandangRepository(db) + projectFlockPopulationRepo := rProjectFlock.NewProjectFlockPopulationRepository(db) + productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + approvalRepo := commonRepo.NewApprovalRepository(db) + approvalService := commonSvc.NewApprovalService(approvalRepo) + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowRecording, utils.RecordingApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register recording approval workflow: %v", err)) + } + userRepo := rUser.NewUserRepository(db) - recordingService := sRecording.NewRecordingService(recordingRepo, validate) + recordingService := sRecording.NewRecordingService( + recordingRepo, + projectFlockKandangRepo, + productWarehouseRepo, + projectFlockPopulationRepo, + approvalRepo, + approvalService, + validate, + ) userService := sUser.NewUserService(userRepo, validate) RecordingRoutes(router, userService, recordingService) } - diff --git a/internal/modules/production/recordings/permissions.go b/internal/modules/production/recordings/permissions.go new file mode 100644 index 00000000..00f9bd48 --- /dev/null +++ b/internal/modules/production/recordings/permissions.go @@ -0,0 +1,8 @@ +package recordings + +const ( + PermissionRecordingRead = "recording.read" + PermissionRecordingCreate = "recording.write" + PermissionRecordingUpdate = "recording.update" + PermissionRecordingDelete = "recording.delete" +) diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index 8dd114d1..832c9ce0 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -1,13 +1,51 @@ package repository import ( - entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "context" + "errors" + "math" + "sort" + "strings" + "time" + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gorm.io/gorm" ) type RecordingRepository interface { repository.BaseRepository[entity.Recording] + + WithRelations(db *gorm.DB) *gorm.DB + GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error) + + CreateBodyWeights(tx *gorm.DB, bodyWeights []entity.RecordingBW) error + DeleteBodyWeights(tx *gorm.DB, recordingID uint) error + + CreateStocks(tx *gorm.DB, stocks []entity.RecordingStock) error + DeleteStocks(tx *gorm.DB, recordingID uint) error + ListStocks(tx *gorm.DB, recordingID uint) ([]entity.RecordingStock, error) + + CreateDepletions(tx *gorm.DB, depletions []entity.RecordingDepletion) error + DeleteDepletions(tx *gorm.DB, recordingID uint) error + ListDepletions(tx *gorm.DB, recordingID uint) ([]entity.RecordingDepletion, error) + + CreateEggs(tx *gorm.DB, eggs []entity.RecordingEgg) error + DeleteEggs(tx *gorm.DB, recordingID uint) error + ListEggs(tx *gorm.DB, recordingID uint) ([]entity.RecordingEgg, error) + GetRecordingEggByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.RecordingEgg, error) + CreateGradingEggs(tx *gorm.DB, gradings []entity.GradingEgg) error + DeleteGradingEggs(tx *gorm.DB, recordingEggID uint) error + + ExistsOnDate(ctx context.Context, projectFlockKandangId uint, recordTime time.Time) (bool, error) + + SumRecordingDepletions(tx *gorm.DB, recordingID uint) (float64, error) + FindPreviousRecording(tx *gorm.DB, projectFlockKandangId uint, currentDay int) (*entity.Recording, error) + GetTotalChick(tx *gorm.DB, projectFlockKandangId uint) (int64, error) + GetAverageBodyWeight(tx *gorm.DB, recordingID uint) (float64, error) + GetFeedUsageInGrams(tx *gorm.DB, recordingID uint) (float64, error) + GetFcrID(tx *gorm.DB, projectFlockKandangId uint) (uint, error) + GetFcrStandardWeightKg(tx *gorm.DB, fcrId uint, currentWeightKg float64) (float64, bool, error) } type RecordingRepositoryImpl struct { @@ -19,3 +57,337 @@ func NewRecordingRepository(db *gorm.DB) RecordingRepository { BaseRepositoryImpl: repository.NewBaseRepository[entity.Recording](db), } } + +func (r *RecordingRepositoryImpl) WithRelations(db *gorm.DB) *gorm.DB { + return db. + Preload("CreatedUser"). + Preload("ProjectFlockKandang"). + Preload("ProjectFlockKandang.ProjectFlock"). + Preload("BodyWeights"). + Preload("Depletions"). + Preload("Depletions.ProductWarehouse"). + Preload("Depletions.ProductWarehouse.Product"). + Preload("Depletions.ProductWarehouse.Warehouse"). + Preload("Stocks"). + Preload("Stocks.ProductWarehouse"). + Preload("Stocks.ProductWarehouse.Product"). + Preload("Stocks.ProductWarehouse.Warehouse"). + Preload("Eggs"). + Preload("Eggs.ProductWarehouse"). + Preload("Eggs.ProductWarehouse.Product"). + Preload("Eggs.ProductWarehouse.Warehouse"). + Preload("Eggs.GradingEggs") +} + +func (r *RecordingRepositoryImpl) GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error) { + var days []int + if err := tx.Model(&entity.Recording{}). + Where("project_flock_kandangs_id = ?", projectFlockKandangId). + Where("day IS NOT NULL"). + Pluck("day", &days).Error; err != nil { + return 0, err + } + return nextRecordingDay(days), nil +} + +func (r *RecordingRepositoryImpl) CreateBodyWeights(tx *gorm.DB, bodyWeights []entity.RecordingBW) error { + if len(bodyWeights) == 0 { + return nil + } + return tx.Create(&bodyWeights).Error +} + +func (r *RecordingRepositoryImpl) DeleteBodyWeights(tx *gorm.DB, recordingID uint) error { + return tx.Where("recording_id = ?", recordingID).Delete(&entity.RecordingBW{}).Error +} + +func (r *RecordingRepositoryImpl) CreateStocks(tx *gorm.DB, stocks []entity.RecordingStock) error { + if len(stocks) == 0 { + return nil + } + return tx.Create(&stocks).Error +} + +func (r *RecordingRepositoryImpl) DeleteStocks(tx *gorm.DB, recordingID uint) error { + return tx.Where("recording_id = ?", recordingID).Delete(&entity.RecordingStock{}).Error +} + +func (r *RecordingRepositoryImpl) ListStocks(tx *gorm.DB, recordingID uint) ([]entity.RecordingStock, error) { + var items []entity.RecordingStock + if err := tx.Where("recording_id = ?", recordingID).Find(&items).Error; err != nil { + return nil, err + } + return items, nil +} + +func (r *RecordingRepositoryImpl) CreateDepletions(tx *gorm.DB, depletions []entity.RecordingDepletion) error { + if len(depletions) == 0 { + return nil + } + return tx.Create(&depletions).Error +} + +func (r *RecordingRepositoryImpl) DeleteDepletions(tx *gorm.DB, recordingID uint) error { + return tx.Where("recording_id = ?", recordingID).Delete(&entity.RecordingDepletion{}).Error +} + +func (r *RecordingRepositoryImpl) ListDepletions(tx *gorm.DB, recordingID uint) ([]entity.RecordingDepletion, error) { + var items []entity.RecordingDepletion + if err := tx.Where("recording_id = ?", recordingID).Find(&items).Error; err != nil { + return nil, err + } + return items, nil +} + +func (r *RecordingRepositoryImpl) CreateEggs(tx *gorm.DB, eggs []entity.RecordingEgg) error { + if len(eggs) == 0 { + return nil + } + return tx.Create(&eggs).Error +} + +func (r *RecordingRepositoryImpl) DeleteEggs(tx *gorm.DB, recordingID uint) error { + return tx.Where("recording_id = ?", recordingID).Delete(&entity.RecordingEgg{}).Error +} + +func (r *RecordingRepositoryImpl) ListEggs(tx *gorm.DB, recordingID uint) ([]entity.RecordingEgg, error) { + var items []entity.RecordingEgg + if err := tx.Where("recording_id = ?", recordingID).Find(&items).Error; err != nil { + return nil, err + } + return items, nil +} + +func (r *RecordingRepositoryImpl) GetRecordingEggByID( + ctx context.Context, + id uint, + modifier func(*gorm.DB) *gorm.DB, +) (*entity.RecordingEgg, error) { + if id == 0 { + return nil, gorm.ErrRecordNotFound + } + + db := r.DB() + if modifier != nil { + db = modifier(db) + } + + var egg entity.RecordingEgg + query := db.WithContext(ctx). + Preload("Recording"). + Preload("Recording.ProjectFlockKandang"). + Preload("Recording.ProjectFlockKandang.ProjectFlock"). + Preload("ProductWarehouse"). + Preload("GradingEggs"). + Where("id = ?", id) + + if err := query.First(&egg).Error; err != nil { + return nil, err + } + return &egg, nil +} + +func (r *RecordingRepositoryImpl) CreateGradingEggs(tx *gorm.DB, gradings []entity.GradingEgg) error { + if len(gradings) == 0 { + return nil + } + return tx.Create(&gradings).Error +} + +func (r *RecordingRepositoryImpl) DeleteGradingEggs(tx *gorm.DB, recordingEggID uint) error { + return tx.Where("recording_egg_id = ?", recordingEggID).Delete(&entity.GradingEgg{}).Error +} + +func (r *RecordingRepositoryImpl) ExistsOnDate(ctx context.Context, projectFlockKandangId uint, recordTime time.Time) (bool, error) { + if projectFlockKandangId == 0 { + return false, nil + } + + ref := recordTime.In(time.UTC) + startOfDay := time.Date(ref.Year(), ref.Month(), ref.Day(), 0, 0, 0, 0, time.UTC) + endOfDay := startOfDay.Add(24 * time.Hour) + + var count int64 + err := r.DB(). + WithContext(ctx). + Model(&entity.Recording{}). + Where("project_flock_kandangs_id = ?", projectFlockKandangId). + Where("record_datetime >= ? AND record_datetime < ?", startOfDay, endOfDay). + Count(&count).Error + if err != nil { + return false, err + } + return count > 0, nil +} + +func (r *RecordingRepositoryImpl) SumRecordingDepletions(tx *gorm.DB, recordingID uint) (float64, error) { + var result float64 + if err := tx.Model(&entity.RecordingDepletion{}). + Where("recording_id = ?", recordingID). + Select("COALESCE(SUM(qty), 0)"). + Scan(&result).Error; err != nil { + return 0, err + } + return result, nil +} + +func (r *RecordingRepositoryImpl) FindPreviousRecording(tx *gorm.DB, projectFlockKandangId uint, currentDay int) (*entity.Recording, error) { + if currentDay <= 1 { + return nil, nil + } + + var prev entity.Recording + err := tx. + Where("project_flock_kandangs_id = ? AND day < ?", projectFlockKandangId, currentDay). + Where("day IS NOT NULL"). + Order("day DESC"). + Limit(1). + Find(&prev).Error + + if errors.Is(err, gorm.ErrRecordNotFound) || prev.Id == 0 { + return nil, nil + } + if err != nil { + return nil, err + } + return &prev, nil +} + +func (r *RecordingRepositoryImpl) GetTotalChick(tx *gorm.DB, projectFlockKandangId uint) (int64, error) { + var population entity.ProjectFlockPopulation + err := tx. + Where("project_flock_kandang_id = ?", projectFlockKandangId). + Order("created_at DESC"). + First(&population).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, nil + } + if err != nil { + return 0, err + } + return int64(math.Round(population.InitialQuantity)), nil +} + +func (r *RecordingRepositoryImpl) GetAverageBodyWeight(tx *gorm.DB, recordingID uint) (float64, error) { + var result struct { + TotalWeight float64 + TotalQty float64 + } + if err := tx.Model(&entity.RecordingBW{}). + Select("COALESCE(SUM(total_weight), 0) AS total_weight, COALESCE(SUM(qty), 0) AS total_qty"). + Where("recording_id = ?", recordingID). + Scan(&result).Error; err != nil { + return 0, err + } + if result.TotalQty == 0 { + return 0, nil + } + return result.TotalWeight / result.TotalQty, nil +} + +func (r *RecordingRepositoryImpl) GetFeedUsageInGrams(tx *gorm.DB, recordingID uint) (float64, error) { + var rows []struct { + UsageQty float64 + UomName string + } + + if err := tx. + Table("recording_stocks"). + Select("COALESCE(recording_stocks.usage_qty, 0) AS usage_qty, LOWER(uoms.name) AS uom_name"). + Joins("JOIN product_warehouses ON product_warehouses.id = recording_stocks.product_warehouse_id"). + Joins("JOIN products ON products.id = product_warehouses.product_id"). + Joins("JOIN uoms ON uoms.id = products.uom_id"). + Where("recording_stocks.recording_id = ?", recordingID). + Scan(&rows).Error; err != nil { + return 0, err + } + + var total float64 + for _, row := range rows { + if row.UsageQty <= 0 { + continue + } + switch strings.TrimSpace(row.UomName) { + case "kilogram", "kg", "kilograms", "kilo": + total += row.UsageQty * 1000 + case "gram", "g", "grams": + total += row.UsageQty + default: + total += row.UsageQty + } + } + return total, nil +} + +func (r *RecordingRepositoryImpl) GetFcrID(tx *gorm.DB, projectFlockKandangId uint) (uint, error) { + var result struct { + FcrID uint + } + if err := tx.Table("project_flock_kandangs"). + Select("project_flocks.fcr_id AS fcr_id"). + Joins("JOIN project_flocks ON project_flocks.id = project_flock_kandangs.project_flock_id"). + Where("project_flock_kandangs.id = ?", projectFlockKandangId). + Scan(&result).Error; err != nil { + return 0, err + } + return result.FcrID, nil +} + +func (r *RecordingRepositoryImpl) GetFcrStandardWeightKg(tx *gorm.DB, fcrId uint, currentWeightKg float64) (float64, bool, error) { + if fcrId == 0 { + return 0, false, nil + } + + var standard entity.FcrStandard + err := tx. + Where("fcr_id = ? AND weight >= ?", fcrId, currentWeightKg). + Order("weight ASC"). + First(&standard).Error + + if errors.Is(err, gorm.ErrRecordNotFound) { + err = tx. + Where("fcr_id = ?", fcrId). + Order("weight DESC"). + First(&standard).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, false, nil + } + } + if err != nil { + return 0, false, err + } + + weight := standard.Weight + if weight > 10 { + return weight / 1000, true, nil + } + return weight, true, nil +} + +func nextRecordingDay(days []int) int { + if len(days) == 0 { + return 1 + } + + unique := make(map[int]struct{}, len(days)) + for _, day := range days { + if day > 0 { + unique[day] = struct{}{} + } + } + + normalized := make([]int, 0, len(unique)) + for day := range unique { + normalized = append(normalized, day) + } + sort.Ints(normalized) + + for idx, day := range normalized { + expected := idx + 1 + if day != expected { + return expected + } + } + + return len(normalized) + 1 +} diff --git a/internal/modules/production/recordings/route.go b/internal/modules/production/recordings/route.go index 6852a1ba..c492c39f 100644 --- a/internal/modules/production/recordings/route.go +++ b/internal/modules/production/recordings/route.go @@ -1,7 +1,7 @@ package recordings import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/controllers" recording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,16 +13,14 @@ func RecordingRoutes(v1 fiber.Router, u user.UserService, s recording.RecordingS ctrl := controller.NewRecordingController(s) route := v1.Group("/recordings") - - // route.Get("/", m.Auth(u), ctrl.GetAll) - // route.Post("/", m.Auth(u), ctrl.CreateOne) - // route.Get("/:id", m.Auth(u), ctrl.GetOne) - // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) - // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) + route.Get("/next-day", ctrl.GetNextDay) route.Post("/", ctrl.CreateOne) + route.Post("/gradings", ctrl.SubmitGrading) route.Get("/:id", ctrl.GetOne) route.Patch("/:id", ctrl.UpdateOne) + route.Post("/approvals", ctrl.Approve) route.Delete("/:id", ctrl.DeleteOne) } diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 84220bd2..e8836590 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -1,12 +1,23 @@ package service import ( + "context" "errors" + "fmt" + "math" + "strings" + "time" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" + approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" + recordingutil "gitlab.com/mbugroup/lti-api.git/internal/utils/recording" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -17,53 +28,83 @@ import ( type RecordingService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Recording, int64, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.Recording, error) + GetNextDay(ctx *fiber.Ctx, projectFlockKandangId uint) (int, error) CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Recording, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Recording, error) DeleteOne(ctx *fiber.Ctx, id uint) error + SubmitGrading(ctx *fiber.Ctx, req *validation.SubmitGrading) (*entity.Recording, error) + Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.Recording, error) } type recordingService struct { - Log *logrus.Logger - Validate *validator.Validate - Repository repository.RecordingRepository + Log *logrus.Logger + Validate *validator.Validate + Repository repository.RecordingRepository + ProjectFlockKandangRepo rProjectFlock.ProjectFlockKandangRepository + ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository + ProjectFlockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository + ApprovalRepo commonRepo.ApprovalRepository + ApprovalSvc commonSvc.ApprovalService } -func NewRecordingService(repo repository.RecordingRepository, validate *validator.Validate) RecordingService { +func NewRecordingService( + repo repository.RecordingRepository, + projectFlockKandangRepo rProjectFlock.ProjectFlockKandangRepository, + productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, + projectFlockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository, + approvalRepo commonRepo.ApprovalRepository, + approvalSvc commonSvc.ApprovalService, + validate *validator.Validate, +) RecordingService { return &recordingService{ - Log: utils.Log, - Validate: validate, - Repository: repo, + Log: utils.Log, + Validate: validate, + Repository: repo, + ProjectFlockKandangRepo: projectFlockKandangRepo, + ProductWarehouseRepo: productWarehouseRepo, + ProjectFlockPopulationRepo: projectFlockPopulationRepo, + ApprovalRepo: approvalRepo, + ApprovalSvc: approvalSvc, } } -func (s recordingService) withRelations(db *gorm.DB) *gorm.DB { - return db.Preload("CreatedUser") -} - func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Recording, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err } - offset := (params.Page - 1) * params.Limit + limit := params.Limit + if limit == 0 { + limit = 10 + } + page := params.Page + if page == 0 { + page = 1 + } + offset := (page - 1) * limit - recordings, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { - db = s.withRelations(db) - if params.Search != "" { - return db.Where("name LIKE ?", "%"+params.Search+"%") + recordings, total, err := s.Repository.GetAll(c.Context(), offset, limit, func(db *gorm.DB) *gorm.DB { + db = s.Repository.WithRelations(db) + if params.ProjectFlockKandangId != 0 { + db = db.Where("project_flock_kandangs_id = ?", params.ProjectFlockKandangId) } - return db.Order("created_at DESC").Order("updated_at DESC") + return db.Order("record_datetime DESC").Order("created_at DESC") }) if err != nil { s.Log.Errorf("Failed to get recordings: %+v", err) return nil, 0, err } + if err := s.attachLatestApprovals(c.Context(), recordings); err != nil { + return nil, 0, err + } return recordings, total, nil } func (s recordingService) GetOne(c *fiber.Ctx, id uint) (*entity.Recording, error) { - recording, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + recording, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + return s.Repository.WithRelations(db) + }) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Recording not found") } @@ -71,24 +112,148 @@ func (s recordingService) GetOne(c *fiber.Ctx, id uint) (*entity.Recording, erro s.Log.Errorf("Failed get recording by id: %+v", err) return nil, err } + if err := s.attachLatestApproval(c.Context(), recording); err != nil { + return nil, err + } return recording, nil } +func (s recordingService) GetNextDay(c *fiber.Ctx, projectFlockKandangId uint) (int, error) { + if projectFlockKandangId == 0 { + return 0, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required") + } + + db := s.Repository.DB().WithContext(c.Context()) + next, err := s.Repository.GenerateNextDay(db, projectFlockKandangId) + if err != nil { + s.Log.Errorf("Failed to compute next recording day for project_flock_kandang_id=%d: %+v", projectFlockKandangId, err) + return 0, err + } + + return next, nil +} + func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Recording, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } - createBody := &entity.Recording{ - Name: req.Name, - } + ctx := c.Context() - if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { - s.Log.Errorf("Failed to create recording: %+v", err) + pfk, err := s.ProjectFlockKandangRepo.GetByID(ctx, req.ProjectFlockKandangId) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang not found") + } + s.Log.Errorf("Failed to get project flock kandang: %+v", err) return nil, err } - return s.GetOne(c, createBody.Id) + category := strings.ToUpper(pfk.ProjectFlock.Category) + isLaying := category == strings.ToUpper(string(utils.ProjectFlockCategoryLaying)) + + if err := s.ensureProjectFlockApproved(ctx, pfk.ProjectFlockId); err != nil { + return nil, err + } + if err := s.ensureChickInExists(ctx, pfk.Id); err != nil { + return nil, err + } + + if !isLaying && len(req.Eggs) > 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Egg details permitted only for laying project flocks") + } + if isLaying && len(req.Eggs) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Egg details are required for laying project flocks") + } + + if err := s.ensureProductWarehousesExist(c, req.Stocks, req.Depletions, req.Eggs); err != nil { + return nil, err + } + + var createdRecording entity.Recording + transactionErr := s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + nextDay, err := s.Repository.GenerateNextDay(tx, req.ProjectFlockKandangId) + if err != nil { + s.Log.Errorf("Failed to determine recording day: %+v", err) + return err + } + + recordTime := time.Now().UTC() + existsToday, err := s.Repository.ExistsOnDate(ctx, req.ProjectFlockKandangId, recordTime) + if err != nil { + s.Log.Errorf("Failed to verify existing recording on date: %+v", err) + return err + } + if existsToday { + return fiber.NewError(fiber.StatusBadRequest, "Recording for this project flock today already exists") + } + + day := nextDay + createdRecording = entity.Recording{ + ProjectFlockKandangId: req.ProjectFlockKandangId, + RecordDatetime: recordTime, + Day: &day, + CreatedBy: 1, // TODO: replace with authenticated user + } + + if err := s.Repository.CreateOne(ctx, &createdRecording, func(*gorm.DB) *gorm.DB { return tx }); err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Recording for project flock kandang %d already exists", req.ProjectFlockKandangId), + ) + } + s.Log.Errorf("Failed to create recording: %+v", err) + return err + } + + mappedBodyWeights := recordingutil.MapBodyWeights(createdRecording.Id, req.BodyWeights) + if err := s.Repository.CreateBodyWeights(tx, mappedBodyWeights); err != nil { + s.Log.Errorf("Failed to persist body weights: %+v", err) + return err + } + + mappedStocks := recordingutil.MapStocks(createdRecording.Id, req.Stocks) + if err := s.Repository.CreateStocks(tx, mappedStocks); err != nil { + s.Log.Errorf("Failed to persist stocks: %+v", err) + return err + } + + mappedDepletions := recordingutil.MapDepletions(createdRecording.Id, req.Depletions) + if err := s.Repository.CreateDepletions(tx, mappedDepletions); err != nil { + s.Log.Errorf("Failed to persist depletions: %+v", err) + return err + } + + mappedEggs := recordingutil.MapEggs(createdRecording.Id, createdRecording.CreatedBy, req.Eggs) + if err := s.Repository.CreateEggs(tx, mappedEggs); err != nil { + s.Log.Errorf("Failed to persist eggs: %+v", err) + return err + } + + if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, mappedDepletions, nil, mappedStocks, nil, mappedEggs)); err != nil { + s.Log.Errorf("Failed to adjust product warehouses: %+v", err) + return err + } + + if err := s.computeAndUpdateMetrics(ctx, tx, &createdRecording); err != nil { + s.Log.Errorf("Failed to compute recording metrics: %+v", err) + return err + } + + action := entity.ApprovalActionCreated + if err := s.createRecordingApproval(ctx, tx, createdRecording.Id, utils.RecordingStepGradingTelur, action, createdRecording.CreatedBy, nil); err != nil { + s.Log.Errorf("Failed to create recording approval for %d: %+v", createdRecording.Id, err) + return err + } + + return nil + }) + if transactionErr != nil { + return nil, transactionErr + } + + return s.GetOne(c, createdRecording.Id) } func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Recording, error) { @@ -96,34 +261,767 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin return nil, err } - updateBody := make(map[string]any) + ctx := c.Context() - if req.Name != nil { - updateBody["name"] = *req.Name - } - - if len(updateBody) == 0 { - return s.GetOne(c, id) - } - - if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Recording not found") + var recordingEntity *entity.Recording + transactionErr := s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + recording, err := s.Repository.GetByID(ctx, id, func(db *gorm.DB) *gorm.DB { + return s.Repository.WithRelations(tx) + }) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Recording not found") + } + s.Log.Errorf("Failed to find recording: %+v", err) + return err } - s.Log.Errorf("Failed to update recording: %+v", err) - return nil, err + recordingEntity = recording + + var category string + if recordingEntity.ProjectFlockKandang != nil && recordingEntity.ProjectFlockKandang.ProjectFlock.Id != 0 { + category = strings.ToUpper(recordingEntity.ProjectFlockKandang.ProjectFlock.Category) + } + isLaying := category == strings.ToUpper(string(utils.ProjectFlockCategoryLaying)) + if req.Eggs != nil { + if !isLaying && len(req.Eggs) > 0 { + return fiber.NewError(fiber.StatusBadRequest, "Egg details permitted only for laying project flocks") + } + if isLaying && len(req.Eggs) == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Egg details are required for laying project flocks") + } + } + + if req.BodyWeights != nil { + if err := s.Repository.DeleteBodyWeights(tx, recordingEntity.Id); err != nil { + s.Log.Errorf("Failed to clear body weights: %+v", err) + return err + } + if err := s.Repository.CreateBodyWeights(tx, recordingutil.MapBodyWeights(recordingEntity.Id, req.BodyWeights)); err != nil { + s.Log.Errorf("Failed to update body weights: %+v", err) + return err + } + } + + if req.Stocks != nil { + if err := s.ensureProductWarehousesExist(c, req.Stocks, nil, nil); err != nil { + return err + } + + existingStocks, err := s.Repository.ListStocks(tx, recordingEntity.Id) + if err != nil { + s.Log.Errorf("Failed to list existing stocks: %+v", err) + return err + } + + if err := s.Repository.DeleteStocks(tx, recordingEntity.Id); err != nil { + s.Log.Errorf("Failed to clear stocks: %+v", err) + return err + } + + mappedStocks := recordingutil.MapStocks(recordingEntity.Id, req.Stocks) + if err := s.Repository.CreateStocks(tx, mappedStocks); err != nil { + s.Log.Errorf("Failed to update stocks: %+v", err) + return err + } + + if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, nil, existingStocks, mappedStocks, nil, nil)); err != nil { + s.Log.Errorf("Failed to adjust product warehouses for stocks: %+v", err) + return err + } + } + + if req.Eggs != nil && req.Depletions == nil { + if err := s.ensureProductWarehousesExist(c, nil, nil, req.Eggs); err != nil { + return err + } + } + + if req.Depletions != nil { + if err := s.ensureProductWarehousesExist(c, nil, req.Depletions, req.Eggs); err != nil { + return err + } + + existingDepletions, err := s.Repository.ListDepletions(tx, recordingEntity.Id) + if err != nil { + s.Log.Errorf("Failed to list existing depletions: %+v", err) + return err + } + + if err := s.Repository.DeleteDepletions(tx, recordingEntity.Id); err != nil { + s.Log.Errorf("Failed to clear depletions: %+v", err) + return err + } + + mappedDepletions := recordingutil.MapDepletions(recordingEntity.Id, req.Depletions) + if err := s.Repository.CreateDepletions(tx, mappedDepletions); err != nil { + s.Log.Errorf("Failed to update depletions: %+v", err) + return err + } + + if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(existingDepletions, mappedDepletions, nil, nil, nil, nil)); err != nil { + s.Log.Errorf("Failed to adjust product warehouses for depletions: %+v", err) + return err + } + } + + if req.Eggs != nil { + existingEggs, err := s.Repository.ListEggs(tx, recordingEntity.Id) + if err != nil { + s.Log.Errorf("Failed to list existing eggs: %+v", err) + return err + } + + if err := s.Repository.DeleteEggs(tx, recordingEntity.Id); err != nil { + s.Log.Errorf("Failed to clear eggs: %+v", err) + return err + } + + mappedEggs := recordingutil.MapEggs(recordingEntity.Id, recordingEntity.CreatedBy, req.Eggs) + if err := s.Repository.CreateEggs(tx, mappedEggs); err != nil { + s.Log.Errorf("Failed to update eggs: %+v", err) + return err + } + + if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, nil, nil, nil, existingEggs, mappedEggs)); err != nil { + s.Log.Errorf("Failed to adjust product warehouses for eggs: %+v", err) + return err + } + } + + if err := s.computeAndUpdateMetrics(ctx, tx, recordingEntity); err != nil { + s.Log.Errorf("Failed to recompute recording metrics: %+v", err) + return err + } + + action := entity.ApprovalActionUpdated + if err := s.createRecordingApproval(ctx, tx, recordingEntity.Id, utils.RecordingStepPengajuan, action, recordingEntity.CreatedBy, nil); err != nil { + s.Log.Errorf("Failed to create approval after recording update %d: %+v", recordingEntity.Id, err) + return err + } + + return nil + }) + if transactionErr != nil { + return nil, transactionErr } return s.GetOne(c, id) } -func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { - if err := s.Repository.DeleteOne(c.Context(), id); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "Recording not found") - } - s.Log.Errorf("Failed to delete recording: %+v", err) - return err +func (s *recordingService) SubmitGrading(c *fiber.Ctx, req *validation.SubmitGrading) (*entity.Recording, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err } + + if len(req.EggsGrading) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "eggs_grading must contain at least one item") + } + + recordingEggID := req.EggsGrading[0].RecordingEggId + for _, grading := range req.EggsGrading[1:] { + if grading.RecordingEggId != recordingEggID { + return nil, fiber.NewError(fiber.StatusBadRequest, "semua grading harus untuk recording egg yang sama") + } + } + + ctx := c.Context() + var recordingID uint + transactionErr := s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + recordingEgg, err := s.Repository.GetRecordingEggByID(ctx, recordingEggID, func(db *gorm.DB) *gorm.DB { + return tx + }) + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Recording egg not found") + } + if err != nil { + s.Log.Errorf("Failed to get recording egg %d: %+v", recordingEggID, err) + return err + } + + var category string + if recordingEgg.Recording.ProjectFlockKandang != nil && recordingEgg.Recording.ProjectFlockKandang.ProjectFlock.Id != 0 { + category = strings.ToUpper(recordingEgg.Recording.ProjectFlockKandang.ProjectFlock.Category) + } + if category != strings.ToUpper(string(utils.ProjectFlockCategoryLaying)) { + return fiber.NewError(fiber.StatusBadRequest, "Grading eggs hanya diperbolehkan pada project flock dengan kategori laying") + } + + totalGradingQty := 0.0 + for _, grading := range req.EggsGrading { + totalGradingQty += grading.Qty + } + + availableRecorded := float64(recordingEgg.Qty) + if totalGradingQty > availableRecorded { + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Total grading (%.2f) melebihi jumlah telur tercatat (%.2f)", totalGradingQty, availableRecorded), + ) + } + + if recordingEgg.ProductWarehouse.Id != 0 { + availableWarehouse := recordingEgg.ProductWarehouse.Quantity + if totalGradingQty > availableWarehouse { + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Total grading (%.2f) melebihi stok telur baik (%.2f)", totalGradingQty, availableWarehouse), + ) + } + } + + if err := s.Repository.DeleteGradingEggs(tx, recordingEgg.Id); err != nil { + s.Log.Errorf("Failed to clear grading eggs for recording egg %d: %+v", recordingEgg.Id, err) + return err + } + + gradings := make([]entity.GradingEgg, 0, len(req.EggsGrading)) + createdBy := recordingEgg.CreatedBy + if createdBy == 0 { + createdBy = recordingEgg.Recording.CreatedBy + } + for _, item := range req.EggsGrading { + gradings = append(gradings, entity.GradingEgg{ + RecordingEggId: recordingEgg.Id, + Grade: strings.TrimSpace(item.Grade), + Qty: item.Qty, + CreatedBy: createdBy, + }) + } + + if len(gradings) > 0 { + if err := s.Repository.CreateGradingEggs(tx, gradings); err != nil { + s.Log.Errorf("Failed to persist grading eggs for recording egg %d: %+v", recordingEgg.Id, err) + return err + } + } + + action := entity.ApprovalActionUpdated + if err := s.createRecordingApproval(ctx, tx, recordingEgg.RecordingId, utils.RecordingStepPengajuan, action, createdBy, nil); err != nil { + s.Log.Errorf("Failed to create approval after grading for recording %d: %+v", recordingEgg.RecordingId, err) + return err + } + + recordingID = recordingEgg.RecordingId + return nil + }) + if transactionErr != nil { + return nil, transactionErr + } + + return s.GetOne(c, recordingID) +} + +func (s recordingService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entity.Recording, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + actionValue := strings.ToUpper(strings.TrimSpace(req.Action)) + var action entity.ApprovalAction + switch actionValue { + case string(entity.ApprovalActionApproved): + action = entity.ApprovalActionApproved + case string(entity.ApprovalActionRejected): + action = entity.ApprovalActionRejected + default: + return nil, fiber.NewError(fiber.StatusBadRequest, "action must be APPROVED or REJECTED") + } + + ids := uniqueUintSlice(req.ApprovableIds) + if len(ids) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "approvable_ids must contain at least one id") + } + + step := utils.RecordingStepPengajuan + if action == entity.ApprovalActionApproved { + step = utils.RecordingStepDisetujui + } + + ctx := c.Context() + actorID := uint(1) // TODO: replace with authenticated user once auth is integrated + + transactionErr := s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + repoTx := s.Repository.WithTx(tx) + approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) + + for _, id := range ids { + if _, err := repoTx.GetByID(ctx, id, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Recording %d not found", id)) + } + return err + } + + if _, err := approvalSvc.CreateApproval( + ctx, + utils.ApprovalWorkflowRecording, + id, + step, + &action, + actorID, + req.Notes, + ); err != nil { + return err + } + } + + return nil + }) + + if transactionErr != nil { + if fiberErr, ok := transactionErr.(*fiber.Error); ok { + return nil, fiberErr + } + s.Log.Errorf("Failed to record approvals for recordings %+v: %+v", ids, transactionErr) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to submit recording approval") + } + + updated := make([]entity.Recording, 0, len(ids)) + for _, id := range ids { + recording, err := s.GetOne(c, id) + if err != nil { + return nil, err + } + updated = append(updated, *recording) + } + + return updated, nil +} + +func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { + ctx := c.Context() + + return s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + oldDepletions, err := s.Repository.ListDepletions(tx, id) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to list depletions before delete: %+v", err) + return err + } + + oldEggs, err := s.Repository.ListEggs(tx, id) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to list eggs before delete: %+v", err) + return err + } + + oldStocks, err := s.Repository.ListStocks(tx, id) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to list stocks before delete: %+v", err) + return err + } + + if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(oldDepletions, nil, oldStocks, nil, oldEggs, nil)); 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") + } + s.Log.Errorf("Failed to delete recording: %+v", err) + return err + } + + return nil + }) +} + +// === Persistence Helpers === + +func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []validation.Stock, depletions []validation.Depletion, eggs []validation.Egg) error { + idSet := make(map[uint]struct{}) + + for _, stock := range stocks { + if stock.ProductWarehouseId != 0 { + idSet[stock.ProductWarehouseId] = struct{}{} + } + } + for _, dep := range depletions { + if dep.ProductWarehouseId != 0 { + idSet[dep.ProductWarehouseId] = struct{}{} + } + } + for _, egg := range eggs { + if egg.ProductWarehouseId != 0 { + idSet[egg.ProductWarehouseId] = struct{}{} + } + } + + if len(idSet) == 0 { + return nil + } + + for id := range idSet { + ok, err := s.ProductWarehouseRepo.ExistsByID(c.Context(), id) + if err != nil { + s.Log.Errorf("Failed to validate product warehouse %d: %+v", id, err) + return err + } + if !ok { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Product warehouse %d not found", id)) + } + } + return nil } + +func buildWarehouseDeltas( + oldDepletions, newDepletions []entity.RecordingDepletion, + oldStocks, newStocks []entity.RecordingStock, + oldEggs, newEggs []entity.RecordingEgg, +) map[uint]float64 { + deltas := make(map[uint]float64) + for _, item := range oldDepletions { + accumulateWarehouseDelta(deltas, item.ProductWarehouseId, -item.Qty) + } + for _, item := range newDepletions { + accumulateWarehouseDelta(deltas, item.ProductWarehouseId, item.Qty) + } + for _, item := range oldStocks { + accumulateWarehouseDelta(deltas, item.ProductWarehouseId, usageQtyValue(item.UsageQty)) + } + for _, item := range newStocks { + accumulateWarehouseDelta(deltas, item.ProductWarehouseId, -usageQtyValue(item.UsageQty)) + } + for _, item := range oldEggs { + accumulateWarehouseDelta(deltas, item.ProductWarehouseId, -float64(item.Qty)) + } + for _, item := range newEggs { + accumulateWarehouseDelta(deltas, item.ProductWarehouseId, float64(item.Qty)) + } + return deltas +} + +func usageQtyValue(val *float64) float64 { + if val == nil { + return 0 + } + return *val +} + +func accumulateWarehouseDelta(deltas map[uint]float64, id uint, value float64) { + if id == 0 || value == 0 { + return + } + deltas[id] += value +} + +func (s *recordingService) adjustProductWarehouseQuantities(ctx context.Context, tx *gorm.DB, deltas map[uint]float64) error { + if len(deltas) == 0 { + return nil + } + return s.ProductWarehouseRepo.AdjustQuantities(ctx, deltas, func(*gorm.DB) *gorm.DB { return tx }) +} + +func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm.DB, recording *entity.Recording) error { + day := 0 + if recording.Day != nil { + day = *recording.Day + } + + totalDepletionQty, err := s.Repository.SumRecordingDepletions(tx, recording.Id) + if err != nil { + return fmt.Errorf("sumRecordingDepletions: %w", err) + } + + prevRecording, err := s.Repository.FindPreviousRecording(tx, recording.ProjectFlockKandangId, day) + if err != nil { + return fmt.Errorf("getPreviousRecording: %w", err) + } + + var prevCumDepletionQty float64 + var prevCumIntake float64 + var prevAvgWeight float64 + if prevRecording != nil { + if prevRecording.TotalDepletionQty != nil { + prevCumDepletionQty = *prevRecording.TotalDepletionQty + } + if prevRecording.CumIntake != nil { + prevCumIntake = float64(*prevRecording.CumIntake) + } + prevAvgWeight, err = s.Repository.GetAverageBodyWeight(tx, prevRecording.Id) + if err != nil { + return fmt.Errorf("getAverageBodyWeight(prev): %w", err) + } + } + + totalChick, err := s.Repository.GetTotalChick(tx, recording.ProjectFlockKandangId) + if err != nil { + return fmt.Errorf("getTotalChick: %w", err) + } + + currentAvgWeight, err := s.Repository.GetAverageBodyWeight(tx, recording.Id) + if err != nil { + return fmt.Errorf("getAverageBodyWeight(current): %w", err) + } + + usageInGrams, err := s.Repository.GetFeedUsageInGrams(tx, recording.Id) + if err != nil { + return fmt.Errorf("getFeedUsageInGrams: %w", err) + } + + fcrId, err := s.Repository.GetFcrID(tx, recording.ProjectFlockKandangId) + if err != nil { + return fmt.Errorf("getFcrID: %w", err) + } + + currentAvgGrams := recordingutil.ToGrams(currentAvgWeight) + currentAvgKg := recordingutil.GramsToKg(currentAvgGrams) + prevAvgGrams := recordingutil.ToGrams(prevAvgWeight) + + currentDepletion := float64(totalDepletionQty) + cumDepletionQty := prevCumDepletionQty + currentDepletion + + updates := map[string]any{ + "total_depletion_qty": cumDepletionQty, + } + recording.TotalDepletionQty = &cumDepletionQty + + if totalChick > 0 { + totalChickFloat := float64(totalChick) + remainingChick := totalChickFloat - cumDepletionQty + if remainingChick < 0 { + remainingChick = 0 + } + updates["total_chick_qty"] = remainingChick + recording.TotalChickQty = &remainingChick + + cumRate := 0.0 + if totalChickFloat > 0 { + cumRate = (cumDepletionQty / totalChickFloat) * 100 + } + updates["cum_depletion_rate"] = cumRate + recording.CumDepletionRate = &cumRate + } else { + updates["total_chick_qty"] = gorm.Expr("NULL") + updates["cum_depletion_rate"] = gorm.Expr("NULL") + recording.TotalChickQty = nil + recording.CumDepletionRate = nil + } + + if currentAvgGrams > 0 && prevAvgGrams > 0 { + dailyGainKg := (currentAvgGrams - prevAvgGrams) / 1000 + updates["daily_gain"] = dailyGainKg + recording.DailyGain = &dailyGainKg + } else { + updates["daily_gain"] = gorm.Expr("NULL") + recording.DailyGain = nil + } + + if fcrId != 0 && currentAvgKg > 0 && day > 0 { + if fcrWeightKg, ok, err := s.Repository.GetFcrStandardWeightKg(tx, fcrId, currentAvgKg); err != nil { + return fmt.Errorf("getFcrStandardWeightKg: %w", err) + } else if ok { + avgDailyGain := (currentAvgKg - fcrWeightKg) / float64(day) + updates["avg_daily_gain"] = avgDailyGain + recording.AvgDailyGain = &avgDailyGain + } else { + updates["avg_daily_gain"] = gorm.Expr("NULL") + recording.AvgDailyGain = nil + } + } else { + updates["avg_daily_gain"] = gorm.Expr("NULL") + recording.AvgDailyGain = nil + } + + if usageInGrams > 0 && totalChick > 0 { + var cumIntakeValue float64 + if prevRecording == nil || prevRecording.CumIntake == nil { + cumIntakeValue = usageInGrams / float64(totalChick) + } else { + remaining := float64(totalChick) - cumDepletionQty + if remaining <= 0 { + remaining = float64(totalChick) + } + cumIntakeValue = prevCumIntake + (usageInGrams / remaining) + } + cumIntakeRounded := int(math.Round(cumIntakeValue)) + updates["cum_intake"] = cumIntakeRounded + recording.CumIntake = &cumIntakeRounded + } else if prevRecording != nil && prevRecording.CumIntake != nil { + updates["cum_intake"] = *prevRecording.CumIntake + recording.CumIntake = prevRecording.CumIntake + } else { + updates["cum_intake"] = gorm.Expr("NULL") + recording.CumIntake = nil + } + + if usageInGrams > 0 && currentAvgKg > 0 { + feedUsageKg := usageInGrams / 1000 + fcrValue := feedUsageKg / currentAvgKg + updates["fcr_value"] = fcrValue + recording.FcrValue = &fcrValue + } else { + updates["fcr_value"] = gorm.Expr("NULL") + recording.FcrValue = nil + } + + if err := s.Repository.WithTx(tx).PatchOne(ctx, recording.Id, updates, nil); err != nil { + return err + } + + return nil +} + +func (s *recordingService) createRecordingApproval( + ctx context.Context, + db *gorm.DB, + recordingID uint, + step approvalutils.ApprovalStep, + action entity.ApprovalAction, + actorID uint, + notes *string, +) error { + if recordingID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Recording tidak valid untuk approval") + } + if actorID == 0 { + actorID = 1 + } + + var svc commonSvc.ApprovalService + if db != nil { + svc = commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(db)) + } else if s.ApprovalSvc != nil { + svc = s.ApprovalSvc + } else { + svc = commonSvc.NewApprovalService(s.ApprovalRepo) + } + + _, err := svc.CreateApproval(ctx, utils.ApprovalWorkflowRecording, recordingID, step, &action, actorID, notes) + return err +} + +func (s *recordingService) attachLatestApprovals(ctx context.Context, items []entity.Recording) error { + if len(items) == 0 || s.ApprovalSvc == nil { + return nil + } + + ids := make([]uint, 0, len(items)) + visited := make(map[uint]struct{}, len(items)) + for _, item := range items { + if item.Id == 0 { + continue + } + if _, ok := visited[item.Id]; ok { + continue + } + visited[item.Id] = struct{}{} + ids = append(ids, item.Id) + } + + if len(ids) == 0 { + return nil + } + + latestMap, err := s.ApprovalSvc.LatestByTargets(ctx, utils.ApprovalWorkflowRecording, ids, func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + }) + if err != nil { + s.Log.Warnf("Unable to load latest approvals for recordings: %+v", err) + return nil + } + + if len(latestMap) == 0 { + return nil + } + + for i := range items { + if items[i].Id == 0 { + continue + } + if approval, ok := latestMap[items[i].Id]; ok { + items[i].LatestApproval = approval + } + } + + return nil +} + +func (s *recordingService) attachLatestApproval(ctx context.Context, item *entity.Recording) error { + if item == nil || item.Id == 0 || s.ApprovalSvc == nil { + return nil + } + + approvals, err := s.ApprovalSvc.ListByTarget(ctx, utils.ApprovalWorkflowRecording, item.Id, func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + }) + if err != nil { + s.Log.Warnf("Unable to load approvals for recording %d: %+v", item.Id, err) + return nil + } + + if len(approvals) == 0 { + item.LatestApproval = nil + return nil + } + + latest := approvals[len(approvals)-1] + item.LatestApproval = &latest + return nil +} + +func uniqueUintSlice(values []uint) []uint { + if len(values) == 0 { + return nil + } + + seen := make(map[uint]struct{}, len(values)) + result := make([]uint, 0, len(values)) + for _, v := range values { + if v == 0 { + continue + } + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + result = append(result, v) + } + return result +} + +func (s *recordingService) ensureProjectFlockApproved(ctx context.Context, projectFlockID uint) error { + if projectFlockID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid") + } + + var ( + latest *entity.Approval + err error + ) + if s.ApprovalSvc != nil { + latest, err = s.ApprovalSvc.LatestByTarget(ctx, utils.ApprovalWorkflowProjectFlock, projectFlockID, nil) + } else { + latest, err = s.ApprovalRepo.LatestByTarget(ctx, utils.ApprovalWorkflowProjectFlock.String(), projectFlockID, nil) + } + if err != nil { + s.Log.Errorf("Failed to check project flock %d approval status: %+v", projectFlockID, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal memeriksa status project flock") + } + + if latest == nil { + return fiber.NewError(fiber.StatusBadRequest, "Project flock masih dalam status pengajuan sehingga belum dapat membuat recording") + } + if latest.StepNumber != uint16(utils.ProjectFlockStepAktif) || latest.Action == nil || *latest.Action != entity.ApprovalActionApproved { + return fiber.NewError(fiber.StatusBadRequest, "Project flock masih dalam status pengajuan sehingga belum dapat membuat recording") + } + + return nil +} + +func (s *recordingService) ensureChickInExists(ctx context.Context, projectFlockKandangID uint) error { + if projectFlockKandangID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid") + } + + _, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(ctx, projectFlockKandangID) + if err == nil { + return nil + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusBadRequest, "Project flock belum melakukan chick in sehingga belum dapat membuat recording") + } + s.Log.Errorf("Failed to check project flock population for project_flock_kandang_id=%d: %+v", projectFlockKandangID, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal memeriksa data chick in") +} diff --git a/internal/modules/production/recordings/validations/recording.validation.go b/internal/modules/production/recordings/validations/recording.validation.go index 95505746..f058248c 100644 --- a/internal/modules/production/recordings/validations/recording.validation.go +++ b/internal/modules/production/recordings/validations/recording.validation.go @@ -1,15 +1,62 @@ package validation +type ( + BodyWeight struct { + AvgWeight float64 `json:"avg_weight" validate:"required"` + Qty float64 `json:"qty" validate:"required,gt=0"` + TotalWeight *float64 `json:"total_weight,omitempty" validate:"omitempty,gt=0"` + } + + Stock struct { + ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"` + Qty *float64 `json:"qty,omitempty" validate:"required_without=UsageAmount,gte=0"` + PendingQty *float64 `json:"pending_qty,omitempty" validate:"omitempty,gte=0"` + } + + Depletion struct { + ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"` + Qty float64 `json:"qty" validate:"required,gte=0"` + } + + Egg struct { + ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"` + Qty int `json:"qty" validate:"required,number,min=0"` + } +) + type Create struct { - Name string `json:"name" validate:"required_strict,min=3"` + ProjectFlockKandangId uint `json:"project_flock_kandang_id" validate:"required,number,min=1"` + BodyWeights []BodyWeight `json:"body_weights,omitempty" validate:"omitempty,dive"` + Stocks []Stock `json:"stocks,omitempty" validate:"omitempty,dive"` + Depletions []Depletion `json:"depletions,omitempty" validate:"omitempty,dive"` + Eggs []Egg `json:"eggs,omitempty" validate:"omitempty,dive"` } type Update struct { - Name *string `json:"name,omitempty" validate:"omitempty"` + BodyWeights []BodyWeight `json:"body_weights,omitempty" validate:"omitempty,dive"` + Stocks []Stock `json:"stocks,omitempty" validate:"omitempty,dive"` + Depletions []Depletion `json:"depletions,omitempty" validate:"omitempty,dive"` + Eggs []Egg `json:"eggs,omitempty" validate:"omitempty,dive"` } type Query struct { - Page int `query:"page" validate:"omitempty,number,min=1"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` - Search string `query:"search" validate:"omitempty,max=50"` + Page int `query:"page" validate:"omitempty,number,min=1"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"` +} + +type EggGrading struct { + RecordingEggId uint `json:"recording_egg_id" validate:"required,number,min=1"` + Grade string `json:"grade" validate:"required"` + Qty float64 `json:"qty" validate:"required,gte=0"` +} + +type SubmitGrading struct { + EggsGrading []EggGrading `json:"eggs_grading" validate:"required,dive"` +} + +type Approve struct { + Action string `json:"action" validate:"required_strict"` + ApprovableIds []uint `json:"approvable_ids" validate:"required_strict,min=1,dive,gt=0"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` } diff --git a/internal/modules/shared/repositories/stock-logs.repository.go b/internal/modules/shared/repositories/stock-logs.repository.go index c93db2b1..77ed78ce 100644 --- a/internal/modules/shared/repositories/stock-logs.repository.go +++ b/internal/modules/shared/repositories/stock-logs.repository.go @@ -13,6 +13,7 @@ type StockLogRepository interface { GetByFlaggable(ctx context.Context, logType string, logId uint) ([]*entity.StockLog, error) GetByProductWarehouse(ctx context.Context, productWarehouseId uint, limit int) ([]*entity.StockLog, error) GetByTransactionType(ctx context.Context, transactionType string, limit int) ([]*entity.StockLog, error) + ApplyProductWarehouseFilters(db *gorm.DB, productID, warehouseID uint) *gorm.DB } type StockLogRepositoryImpl struct { @@ -86,3 +87,20 @@ func (r *StockLogRepositoryImpl) GetByTransactionType(ctx context.Context, trans return stockLogs, nil } + +func (r *StockLogRepositoryImpl) ApplyProductWarehouseFilters(db *gorm.DB, productID, warehouseID uint) *gorm.DB { + if productID == 0 && warehouseID == 0 { + return db + } + + db = db.Joins("JOIN product_warehouses ON product_warehouses.id = stock_logs.product_warehouse_id") + + if productID > 0 { + db = db.Where("product_warehouses.product_id = ?", productID) + } + if warehouseID > 0 { + db = db.Where("product_warehouses.warehouse_id = ?", warehouseID) + } + + return db +} diff --git a/internal/modules/sso/controllers/sso.controller.go b/internal/modules/sso/controllers/sso.controller.go index cfe324e8..f11a31c8 100644 --- a/internal/modules/sso/controllers/sso.controller.go +++ b/internal/modules/sso/controllers/sso.controller.go @@ -211,7 +211,6 @@ func (h *Controller) Callback(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadGateway, "missing access token") } - fmt.Println(tokenResp.AccessToken) verification, err := sso.VerifyAccessToken(tokenResp.AccessToken) if err != nil { utils.Log.Errorf("access token verification failed: %v", err) @@ -308,6 +307,13 @@ func (h *Controller) UserInfo(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadGateway, "invalid user profile response") } + // if sanitized, perms, ok := sanitizeUserInfoPayload(body); ok { + // if caps := capabilities.FromPermissions(perms); len(caps) > 0 { + // injectCapabilities(sanitized, caps) + // } + // return c.Status(resp.StatusCode).JSON(sanitized) + // } + if ct := resp.Header.Get("Content-Type"); ct != "" { c.Set("Content-Type", ct) } else { @@ -545,6 +551,99 @@ func normalizeClientParam(raw string) string { return strings.ToLower(value) } +func sanitizeUserInfoPayload(body []byte) (map[string]any, []string, bool) { + if len(body) == 0 { + return map[string]any{}, nil, true + } + + var payload any + if err := json.Unmarshal(body, &payload); err != nil { + return nil, nil, false + } + + perms := collectPermissionNames(payload) + + sensitive := map[string]struct{}{ + "roles": {}, + "permissions": {}, + } + payload = scrubSensitiveKeys(payload, sensitive) + + sanitized, ok := payload.(map[string]any) + if !ok { + sanitized = map[string]any{"data": payload} + } + + return sanitized, perms, true +} + +func scrubSensitiveKeys(value any, sensitive map[string]struct{}) any { + switch v := value.(type) { + case map[string]any: + for key, val := range v { + if _, ok := sensitive[strings.ToLower(key)]; ok { + delete(v, key) + continue + } + v[key] = scrubSensitiveKeys(val, sensitive) + } + return v + case []any: + for i, item := range v { + v[i] = scrubSensitiveKeys(item, sensitive) + } + return v + default: + return value + } +} + +func collectPermissionNames(value any) []string { + names := make(map[string]struct{}) + collectPermissionRec(value, names) + out := make([]string, 0, len(names)) + for name := range names { + out = append(out, name) + } + return out +} + +func collectPermissionRec(value any, acc map[string]struct{}) { + switch v := value.(type) { + case map[string]any: + for key, val := range v { + if strings.EqualFold(key, "permissions") { + if arr, ok := val.([]any); ok { + for _, item := range arr { + if perm, ok := item.(map[string]any); ok { + if name, ok := perm["name"].(string); ok && strings.TrimSpace(name) != "" { + acc[strings.ToLower(strings.TrimSpace(name))] = struct{}{} + } + } + } + } + } else { + collectPermissionRec(val, acc) + } + } + case []any: + for _, item := range v { + collectPermissionRec(item, acc) + } + } +} + +func injectCapabilities(payload map[string]any, caps map[string]bool) { + if len(caps) == 0 { + return + } + if data, ok := payload["data"].(map[string]any); ok { + data["capabilities"] = caps + return + } + payload["capabilities"] = caps +} + func findSSOClientConfig(requestedAlias string) (string, config.SSOClientConfig, bool) { if requestedAlias == "" { return "", config.SSOClientConfig{}, false diff --git a/internal/sso/profile.go b/internal/sso/profile.go new file mode 100644 index 00000000..a211fc74 --- /dev/null +++ b/internal/sso/profile.go @@ -0,0 +1,307 @@ +package sso + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/redis/go-redis/v9" + + "gitlab.com/mbugroup/lti-api.git/internal/cache" + "gitlab.com/mbugroup/lti-api.git/internal/config" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +const ( + profileCachePrefix = "sso:profile:user:" + profileCacheTTL = time.Minute +) + +var ( + profileClient = &http.Client{Timeout: 5 * time.Second} + + profileLocalCache sync.Map // map[string]cachedProfile +) + +type cachedProfile struct { + Profile *UserProfile + ExpiresAt time.Time +} + +// UserProfile represents the enriched user information returned by the central SSO. +type UserProfile struct { + UserID uint + Roles []Role + Permissions []Permission +} + +// Role describes a role assignment from the SSO profile response. +type Role struct { + ID uint + Key string + Name string + ClientID uint + ClientAlias string + ClientName string + Permissions []Permission + RawReference json.RawMessage `json:"-"` +} + +// Permission describes a granular permission entry from the SSO profile. +type Permission struct { + ID uint + Name string + Action string + ClientID uint + ClientAlias string + ClientName string +} + +// PermissionNames returns a de-duplicated slice of permission identifiers in canonical form. +func (p *UserProfile) PermissionNames() []string { + if p == nil || len(p.Permissions) == 0 { + return nil + } + set := make(map[string]struct{}, len(p.Permissions)) + for _, perm := range p.Permissions { + name := canonicalPermissionName(perm.Name) + if name != "" { + set[name] = struct{}{} + } + } + out := make([]string, 0, len(set)) + for name := range set { + out = append(out, name) + } + return out +} + +// FetchProfile retrieves the SSO profile for the authenticated user, using Redis/in-memory +// caching to reduce load on the SSO service. Only end-user tokens (subject user:ID) are supported. +func FetchProfile(ctx context.Context, token string, verification *VerificationResult) (*UserProfile, error) { + if verification == nil || verification.UserID == 0 { + return nil, errors.New("profile only available for user tokens") + } + key := profileCacheKey(verification.UserID) + + if profile := loadProfileFromLocalCache(key); profile != nil { + return profile, nil + } + + if profile := loadProfileFromRedis(ctx, key); profile != nil { + storeProfileInLocalCache(key, profile) + return profile, nil + } + + profile, err := fetchProfileFromSSO(ctx, token) + if err != nil { + return nil, err + } + + storeProfileInLocalCache(key, profile) + storeProfileInRedis(ctx, key, profile) + return profile, nil +} + +func fetchProfileFromSSO(ctx context.Context, token string) (*UserProfile, error) { + endpoint := strings.TrimSpace(config.SSOGetMeURL) + if endpoint == "" { + return nil, errors.New("sso get-me endpoint not configured") + } + + if ctx == nil { + ctx = context.Background() + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("build profile request: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + if cookieName := strings.TrimSpace(config.SSOAccessCookieName); cookieName != "" { + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", cookieName, token)) + } + + resp, err := profileClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch profile: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("fetch profile: status %d", resp.StatusCode) + } + + var envelope userInfoEnvelope + if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil { + return nil, fmt.Errorf("decode profile: %w", err) + } + + roles := envelope.getRoles() + profile := &UserProfile{} + + // Attempt to infer user id if provided. + if envelope.User != nil && envelope.User.ID > 0 { + profile.UserID = uint(envelope.User.ID) + } + + perms := make([]Permission, 0) + convertedRoles := make([]Role, 0, len(roles)) + for _, r := range roles { + role := Role{ + ID: uint(r.ID), + Key: strings.TrimSpace(r.Key), + Name: strings.TrimSpace(r.Name), + ClientAlias: strings.TrimSpace(r.Client.Alias), + ClientName: strings.TrimSpace(r.Client.Name), + ClientID: uint(r.Client.ID), + } + rolePerms := make([]Permission, 0, len(r.Permissions)) + for _, p := range r.Permissions { + perm := Permission{ + ID: uint(p.ID), + Name: strings.TrimSpace(p.Name), + Action: strings.TrimSpace(p.Action), + ClientAlias: strings.TrimSpace(p.Client.Alias), + ClientName: strings.TrimSpace(p.Client.Name), + ClientID: uint(p.Client.ID), + } + if perm.Name != "" { + rolePerms = append(rolePerms, perm) + perms = append(perms, perm) + } + } + role.Permissions = rolePerms + convertedRoles = append(convertedRoles, role) + } + profile.Roles = convertedRoles + profile.Permissions = perms + + return profile, nil +} + +func loadProfileFromLocalCache(key string) *UserProfile { + if value, ok := profileLocalCache.Load(key); ok { + if cached, ok := value.(cachedProfile); ok { + if time.Now().Before(cached.ExpiresAt) && cached.Profile != nil { + return cached.Profile + } + profileLocalCache.Delete(key) + } + } + return nil +} + +func loadProfileFromRedis(ctx context.Context, key string) *UserProfile { + client := cache.Redis() + if client == nil { + return nil + } + + data, err := client.Get(ctx, key).Bytes() + if err != nil { + if !errors.Is(err, redis.Nil) { + utils.Log.WithError(err).Warn("sso profile redis lookup failed") + } + return nil + } + + var profile UserProfile + if err := json.Unmarshal(data, &profile); err != nil { + utils.Log.WithError(err).Warn("sso profile redis decode failed") + return nil + } + + return &profile +} + +func storeProfileInLocalCache(key string, profile *UserProfile) { + if profile == nil { + return + } + profileLocalCache.Store(key, cachedProfile{ + Profile: profile, + ExpiresAt: time.Now().Add(profileCacheTTL), + }) +} + +func storeProfileInRedis(ctx context.Context, key string, profile *UserProfile) { + client := cache.Redis() + if client == nil || profile == nil { + return + } + + data, err := json.Marshal(profile) + if err != nil { + utils.Log.WithError(err).Warn("sso profile redis encode failed") + return + } + + if err := client.Set(ctx, key, data, profileCacheTTL).Err(); err != nil { + utils.Log.WithError(err).Warn("sso profile redis store failed") + } +} + +func profileCacheKey(userID uint) string { + return profileCachePrefix + strconv.FormatUint(uint64(userID), 10) +} + +func canonicalPermissionName(name string) string { + return strings.ToLower(strings.TrimSpace(name)) +} + +// userInfoEnvelope handles the varying shapes returned by the SSO userinfo endpoint. +type userInfoEnvelope struct { + Roles []userInfoRole `json:"roles"` + Data *struct { + ID int64 `json:"id"` + Roles []userInfoRole `json:"roles"` + } `json:"data"` + User *struct { + ID int64 `json:"id"` + } `json:"user"` +} + +func (e *userInfoEnvelope) getRoles() []userInfoRole { + if len(e.Roles) > 0 { + return e.Roles + } + if e.Data != nil && len(e.Data.Roles) > 0 { + if e.User == nil && e.Data.ID > 0 { + e.User = &struct { + ID int64 `json:"id"` + }{ID: e.Data.ID} + } + return e.Data.Roles + } + return nil +} + +type userInfoRole struct { + ID int64 `json:"id"` + Key string `json:"key"` + Name string `json:"name"` + Client userInfoClient `json:"client"` + Permissions []userInfoPermRaw `json:"permissions"` +} + +type userInfoClient struct { + ID int64 `json:"id"` + Name string `json:"name"` + Alias string `json:"alias"` +} + +type userInfoPermRaw struct { + ID int64 `json:"id"` + Name string `json:"name"` + Action string `json:"action"` + Client userInfoClient `json:"client"` + Details any `json:"details"` +} diff --git a/internal/utils/constant.go b/internal/utils/constant.go index bdbc53b6..0a8862f9 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -140,6 +140,23 @@ var ProjectFlockApprovalSteps = map[approvalutils.ApprovalStep]string{ ProjectFlockStepAktif: "Aktif", } +// ------------------------------------------------------------------- +// Recording Approval +// ------------------------------------------------------------------- + +const ( + ApprovalWorkflowRecording approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("RECORDINGS") + RecordingStepGradingTelur approvalutils.ApprovalStep = 1 + RecordingStepPengajuan approvalutils.ApprovalStep = 2 + RecordingStepDisetujui approvalutils.ApprovalStep = 3 +) + +var RecordingApprovalSteps = map[approvalutils.ApprovalStep]string{ + RecordingStepGradingTelur: "Grading-Telur", + RecordingStepPengajuan: "Pengajuan", + RecordingStepDisetujui: "Disetujui", +} + // ------------------------------------------------------------------- // Validators // ------------------------------------------------------------------- @@ -268,6 +285,8 @@ func IsValidSupplierCategory(v string) bool { // example use +// Recording helper + /** if !utils.IsValidFlagType(req.FlagName) { return fiber.NewError(fiber.StatusBadRequest, "Invalid flag type") diff --git a/internal/utils/recording/util.recording.go b/internal/utils/recording/util.recording.go new file mode 100644 index 00000000..fd463cf9 --- /dev/null +++ b/internal/utils/recording/util.recording.go @@ -0,0 +1,106 @@ +package recording + +import ( + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" +) + +func MapBodyWeights(recordingID uint, items []validation.BodyWeight) []entity.RecordingBW { + if len(items) == 0 { + return nil + } + + result := make([]entity.RecordingBW, 0, len(items)) + for _, item := range items { + totalWeight := item.TotalWeight + if totalWeight == nil { + calculated := item.AvgWeight * item.Qty + totalWeight = &calculated + } + + result = append(result, entity.RecordingBW{ + RecordingId: recordingID, + AvgWeight: item.AvgWeight, + Qty: item.Qty, + TotalWeight: *totalWeight, + }) + } + return result +} + +func MapStocks(recordingID uint, items []validation.Stock) []entity.RecordingStock { + if len(items) == 0 { + return nil + } + + result := make([]entity.RecordingStock, 0, len(items)) + for _, item := range items { + var usageAmount float64 + if item.Qty != nil { + usageAmount = *item.Qty + } + usagePtr := new(float64) + *usagePtr = usageAmount + pending := item.PendingQty + if pending == nil { + pending = new(float64) + } + result = append(result, entity.RecordingStock{ + RecordingId: recordingID, + ProductWarehouseId: item.ProductWarehouseId, + UsageQty: usagePtr, + PendingQty: pending, + }) + } + return result +} + +func MapDepletions(recordingID uint, items []validation.Depletion) []entity.RecordingDepletion { + if len(items) == 0 { + return nil + } + + result := make([]entity.RecordingDepletion, 0, len(items)) + for _, item := range items { + result = append(result, entity.RecordingDepletion{ + RecordingId: recordingID, + ProductWarehouseId: item.ProductWarehouseId, + Qty: item.Qty, + }) + } + return result +} + +func MapEggs(recordingID uint, createdBy uint, items []validation.Egg) []entity.RecordingEgg { + if len(items) == 0 { + return nil + } + + result := make([]entity.RecordingEgg, 0, len(items)) + for _, item := range items { + result = append(result, entity.RecordingEgg{ + RecordingId: recordingID, + ProductWarehouseId: item.ProductWarehouseId, + Qty: item.Qty, + CreatedBy: createdBy, + }) + } + return result +} + +func ToGrams(weight float64) float64 { + if weight <= 0 { + return 0 + } + if weight < 10 { + return weight * 1000 + } + return weight +} + +func GramsToKg(grams float64) float64 { + if grams <= 0 { + return 0 + } + return grams / 1000 +} diff --git a/internal/utils/strings.go b/internal/utils/strings.go index f6560191..a58ba1ac 100644 --- a/internal/utils/strings.go +++ b/internal/utils/strings.go @@ -1,6 +1,9 @@ package utils -import "strings" +import ( + "sort" + "strings" +) // NormalizeTrim returns the input string without leading/trailing whitespace. func NormalizeTrim(value string) string { @@ -11,3 +14,36 @@ func NormalizeTrim(value string) string { func NormalizeUpper(value string) string { return strings.ToUpper(NormalizeTrim(value)) } + +// NormalizeFlag trims whitespace, removes surrounding brackets/quotes and returns upper-case flag +func NormalizeFlag(value string) string { + v := NormalizeTrim(value) + v = strings.Trim(v, "[]\"'") + return strings.ToUpper(v) +} + +// ParseFlags parses a raw flags string like "[DOC, PAKAN]" or "DOC,PAKAN" +// and returns a deduplicated, sorted slice of normalized flags (upper-case, trimmed). +func ParseFlags(raw string) []string { + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + set := make(map[string]struct{}, len(parts)) + for _, p := range parts { + f := NormalizeFlag(p) + if f == "" { + continue + } + set[f] = struct{}{} + } + if len(set) == 0 { + return nil + } + res := make([]string, 0, len(set)) + for k := range set { + res = append(res, k) + } + sort.Strings(res) + return res +} diff --git a/test/integration/master_data/kandang_test.go b/test/integration/master_data/kandang_test.go index 6f7c5ce7..b7b82b21 100644 --- a/test/integration/master_data/kandang_test.go +++ b/test/integration/master_data/kandang_test.go @@ -2,6 +2,7 @@ package test import ( "encoding/json" + "fmt" "net/http" "testing" @@ -58,7 +59,7 @@ func TestKandangIntegration(t *testing.T) { flocID := createFlock(t, app, "Floc Test") projectFloc := entities.ProjectFlock{ - FlockId: flocID, + FlockName: fmt.Sprintf("Project Flock %d", flocID), AreaId: areaID, Category: string(utils.ProjectFlockCategoryGrowing), FcrId: fcrID, diff --git a/test/integration/master_data/project_flock_test.go b/test/integration/master_data/project_flock_test.go index 60bb2d90..a7f8f3f8 100644 --- a/test/integration/master_data/project_flock_test.go +++ b/test/integration/master_data/project_flock_test.go @@ -1,417 +1,417 @@ package test -import ( - "encoding/json" - "fmt" - "net/http" - "net/url" - "testing" +// import ( +// "encoding/json" +// "fmt" +// "net/http" +// "net/url" +// "testing" - "github.com/gofiber/fiber/v2" +// "github.com/gofiber/fiber/v2" - "gitlab.com/mbugroup/lti-api.git/internal/entities" - "gitlab.com/mbugroup/lti-api.git/internal/utils" -) +// "gitlab.com/mbugroup/lti-api.git/internal/entities" +// "gitlab.com/mbugroup/lti-api.git/internal/utils" +// ) -func TestProjectFlockSummary(t *testing.T) { - app, db := setupIntegrationApp(t) +// func TestProjectFlockSummary(t *testing.T) { +// app, db := setupIntegrationApp(t) - areaID := createArea(t, app, "Area Project") - locationID := createLocation(t, app, "Location Project", "Address", areaID) - flockID := createFlock(t, app, "Flock Summary") - fcrID := createFcr(t, app, "FCR Summary", []map[string]any{ - {"weight": 1.0, "fcr_number": 1.5, "mortality": 2.0}, - }) - kandangID := createKandang(t, app, "Kandang Summary", locationID, 1) +// areaID := createArea(t, app, "Area Project") +// locationID := createLocation(t, app, "Location Project", "Address", areaID) +// flockID := createFlock(t, app, "Flock Summary") +// fcrID := createFcr(t, app, "FCR Summary", []map[string]any{ +// {"weight": 1.0, "fcr_number": 1.5, "mortality": 2.0}, +// }) +// kandangID := createKandang(t, app, "Kandang Summary", locationID, 1) - createPayload := map[string]any{ - "flock_id": flockID, - "area_id": areaID, - "category": "growing", - "fcr_id": fcrID, - "location_id": locationID, - "kandang_ids": []uint{kandangID}, - } - resp, body := doJSONRequest(t, app, http.MethodPost, "/api/production/project_flocks", createPayload) - if resp.StatusCode != fiber.StatusCreated { - t.Fatalf("expected 201 when creating project flock, got %d: %s", resp.StatusCode, string(body)) - } +// createPayload := map[string]any{ +// "flock_id": flockID, +// "area_id": areaID, +// "category": "growing", +// "fcr_id": fcrID, +// "location_id": locationID, +// "kandang_ids": []uint{kandangID}, +// } +// resp, body := doJSONRequest(t, app, http.MethodPost, "/api/production/project_flocks", createPayload) +// if resp.StatusCode != fiber.StatusCreated { +// t.Fatalf("expected 201 when creating project flock, got %d: %s", resp.StatusCode, string(body)) +// } - var createResp struct { - Data struct { - Id uint `json:"id"` - Period int `json:"period"` - Category string `json:"category"` - Flock struct { - Id uint `json:"id"` - Name string `json:"name"` - } `json:"flock"` - Area struct { - Id uint `json:"id"` - Name string `json:"name"` - } `json:"area"` - Fcr struct { - Id uint `json:"id"` - Name string `json:"name"` - } `json:"fcr"` - Location struct { - Id uint `json:"id"` - Name string `json:"name"` - Address string `json:"address"` - } `json:"location"` - Kandangs []struct { - Id uint `json:"id"` - Name string `json:"name"` - Status string `json:"status"` - } `json:"kandangs"` - CreatedUser struct { - Id uint `json:"id"` - IdUser uint `json:"id_user"` - Email string `json:"email"` - Name string `json:"name"` - } `json:"created_user"` - } `json:"data"` - } - if err := json.Unmarshal(body, &createResp); err != nil { - t.Fatalf("failed to parse create response: %v", err) - } - if createResp.Data.Flock.Id != flockID || createResp.Data.Flock.Name == "" { - t.Fatalf("expected flock detail to be present, got %+v", createResp.Data.Flock) - } - if createResp.Data.Area.Id != areaID || createResp.Data.Area.Name == "" { - t.Fatalf("expected area detail to be present, got %+v", createResp.Data.Area) - } - if createResp.Data.Category != string(utils.ProjectFlockCategoryGrowing) { - t.Fatalf("expected category to be %s, got %s", utils.ProjectFlockCategoryGrowing, createResp.Data.Category) - } - if createResp.Data.Location.Id != locationID || createResp.Data.Location.Name == "" { - t.Fatalf("expected location detail to be present, got %+v", createResp.Data.Location) - } - if len(createResp.Data.Kandangs) != 1 || createResp.Data.Kandangs[0].Id != kandangID { - t.Fatalf("expected kandang detail to be present, got %+v", createResp.Data.Kandangs) - } - if createResp.Data.Kandangs[0].Status != string(utils.KandangStatusPengajuan) { - t.Fatalf("expected kandang status to be PENGAJUAN, got %s", createResp.Data.Kandangs[0].Status) - } - if createResp.Data.Period != 1 { - t.Fatalf("expected period 1 to be assigned automatically, got %d", createResp.Data.Period) - } +// var createResp struct { +// Data struct { +// Id uint `json:"id"` +// Period int `json:"period"` +// Category string `json:"category"` +// Flock struct { +// Id uint `json:"id"` +// Name string `json:"name"` +// } `json:"flock"` +// Area struct { +// Id uint `json:"id"` +// Name string `json:"name"` +// } `json:"area"` +// Fcr struct { +// Id uint `json:"id"` +// Name string `json:"name"` +// } `json:"fcr"` +// Location struct { +// Id uint `json:"id"` +// Name string `json:"name"` +// Address string `json:"address"` +// } `json:"location"` +// Kandangs []struct { +// Id uint `json:"id"` +// Name string `json:"name"` +// Status string `json:"status"` +// } `json:"kandangs"` +// CreatedUser struct { +// Id uint `json:"id"` +// IdUser uint `json:"id_user"` +// Email string `json:"email"` +// Name string `json:"name"` +// } `json:"created_user"` +// } `json:"data"` +// } +// if err := json.Unmarshal(body, &createResp); err != nil { +// t.Fatalf("failed to parse create response: %v", err) +// } +// if createResp.Data.Flock.Id != flockID || createResp.Data.Flock.Name == "" { +// t.Fatalf("expected flock detail to be present, got %+v", createResp.Data.Flock) +// } +// if createResp.Data.Area.Id != areaID || createResp.Data.Area.Name == "" { +// t.Fatalf("expected area detail to be present, got %+v", createResp.Data.Area) +// } +// if createResp.Data.Category != string(utils.ProjectFlockCategoryGrowing) { +// t.Fatalf("expected category to be %s, got %s", utils.ProjectFlockCategoryGrowing, createResp.Data.Category) +// } +// if createResp.Data.Location.Id != locationID || createResp.Data.Location.Name == "" { +// t.Fatalf("expected location detail to be present, got %+v", createResp.Data.Location) +// } +// if len(createResp.Data.Kandangs) != 1 || createResp.Data.Kandangs[0].Id != kandangID { +// t.Fatalf("expected kandang detail to be present, got %+v", createResp.Data.Kandangs) +// } +// if createResp.Data.Kandangs[0].Status != string(utils.KandangStatusPengajuan) { +// t.Fatalf("expected kandang status to be PENGAJUAN, got %s", createResp.Data.Kandangs[0].Status) +// } +// if createResp.Data.Period != 1 { +// t.Fatalf("expected period 1 to be assigned automatically, got %d", createResp.Data.Period) +// } - createdKandang := fetchKandang(t, db, kandangID) - if createdKandang.Status != string(utils.KandangStatusPengajuan) { - t.Fatalf("expected kandang status in DB to be PENGAJUAN, got %s", createdKandang.Status) - } +// createdKandang := fetchKandang(t, db, kandangID) +// if createdKandang.Status != string(utils.KandangStatusPengajuan) { +// t.Fatalf("expected kandang status in DB to be PENGAJUAN, got %s", createdKandang.Status) +// } - var pivotRecords []entities.ProjectFlockKandang - if err := db.Where("project_flock_id = ?", createResp.Data.Id).Find(&pivotRecords).Error; err != nil { - t.Fatalf("failed to fetch pivot records: %v", err) - } - if len(pivotRecords) != 1 { - t.Fatalf("expected 1 pivot record, got %d", len(pivotRecords)) - } - firstPivotRecord := pivotRecords[0] - if firstPivotRecord.KandangId != kandangID { - t.Fatalf("expected pivot kandang id %d, got %d", kandangID, firstPivotRecord.KandangId) - } +// var pivotRecords []entities.ProjectFlockKandang +// if err := db.Where("project_flock_id = ?", createResp.Data.Id).Find(&pivotRecords).Error; err != nil { +// t.Fatalf("failed to fetch pivot records: %v", err) +// } +// if len(pivotRecords) != 1 { +// t.Fatalf("expected 1 pivot record, got %d", len(pivotRecords)) +// } +// firstPivotRecord := pivotRecords[0] +// if firstPivotRecord.KandangId != kandangID { +// t.Fatalf("expected pivot kandang id %d, got %d", kandangID, firstPivotRecord.KandangId) +// } - secondKandangID := createKandang(t, app, "Kandang Summary 2", locationID, 1) - secondPayload := map[string]any{ - "flock_id": flockID, - "area_id": areaID, - "category": "laying", - "fcr_id": fcrID, - "location_id": locationID, - "kandang_ids": []uint{secondKandangID}, - } - resp, body = doJSONRequest(t, app, http.MethodPost, "/api/production/project_flocks", secondPayload) - if resp.StatusCode != fiber.StatusCreated { - t.Fatalf("expected 201 when creating second project flock, got %d: %s", resp.StatusCode, string(body)) - } - var createRespSecond struct { - Data struct { - Id uint `json:"id"` - Period int `json:"period"` - Category string `json:"category"` - } `json:"data"` - } - if err := json.Unmarshal(body, &createRespSecond); err != nil { - t.Fatalf("failed to parse second create response: %v", err) - } - if createRespSecond.Data.Period != 2 { - t.Fatalf("expected second period to be 2, got %d", createRespSecond.Data.Period) - } - if createRespSecond.Data.Category != string(utils.ProjectFlockCategoryLaying) { - t.Fatalf("expected category to be %s, got %s", utils.ProjectFlockCategoryLaying, createRespSecond.Data.Category) - } +// secondKandangID := createKandang(t, app, "Kandang Summary 2", locationID, 1) +// secondPayload := map[string]any{ +// "flock_id": flockID, +// "area_id": areaID, +// "category": "laying", +// "fcr_id": fcrID, +// "location_id": locationID, +// "kandang_ids": []uint{secondKandangID}, +// } +// resp, body = doJSONRequest(t, app, http.MethodPost, "/api/production/project_flocks", secondPayload) +// if resp.StatusCode != fiber.StatusCreated { +// t.Fatalf("expected 201 when creating second project flock, got %d: %s", resp.StatusCode, string(body)) +// } +// var createRespSecond struct { +// Data struct { +// Id uint `json:"id"` +// Period int `json:"period"` +// Category string `json:"category"` +// } `json:"data"` +// } +// if err := json.Unmarshal(body, &createRespSecond); err != nil { +// t.Fatalf("failed to parse second create response: %v", err) +// } +// if createRespSecond.Data.Period != 2 { +// t.Fatalf("expected second period to be 2, got %d", createRespSecond.Data.Period) +// } +// if createRespSecond.Data.Category != string(utils.ProjectFlockCategoryLaying) { +// t.Fatalf("expected category to be %s, got %s", utils.ProjectFlockCategoryLaying, createRespSecond.Data.Category) +// } - pivotRecords = nil - if err := db.Where("project_flock_id = ?", createRespSecond.Data.Id).Find(&pivotRecords).Error; err != nil { - t.Fatalf("failed to fetch second pivot records: %v", err) - } - if len(pivotRecords) != 1 { - t.Fatalf("expected 1 pivot record for second project, got %d", len(pivotRecords)) - } - secondPivotRecord := pivotRecords[0] - if secondPivotRecord.KandangId != secondKandangID { - t.Fatalf("expected second pivot kandang id %d, got %d", secondKandangID, secondPivotRecord.KandangId) - } +// pivotRecords = nil +// if err := db.Where("project_flock_id = ?", createRespSecond.Data.Id).Find(&pivotRecords).Error; err != nil { +// t.Fatalf("failed to fetch second pivot records: %v", err) +// } +// if len(pivotRecords) != 1 { +// t.Fatalf("expected 1 pivot record for second project, got %d", len(pivotRecords)) +// } +// secondPivotRecord := pivotRecords[0] +// if secondPivotRecord.KandangId != secondKandangID { +// t.Fatalf("expected second pivot kandang id %d, got %d", secondKandangID, secondPivotRecord.KandangId) +// } - secondKandang := fetchKandang(t, db, secondKandangID) - if secondKandang.Status != string(utils.KandangStatusPengajuan) { - t.Fatalf("expected second kandang status in DB to be PENGAJUAN, got %s", secondKandang.Status) - } +// secondKandang := fetchKandang(t, db, secondKandangID) +// if secondKandang.Status != string(utils.KandangStatusPengajuan) { +// t.Fatalf("expected second kandang status in DB to be PENGAJUAN, got %s", secondKandang.Status) +// } - resp, body = doJSONRequest(t, app, http.MethodGet, "/api/production/project_flocks/flocks/"+uintToString(flockID)+"/periods", nil) - if resp.StatusCode != fiber.StatusOK { - t.Fatalf("expected 200 when fetching summary, got %d: %s", resp.StatusCode, string(body)) - } +// resp, body = doJSONRequest(t, app, http.MethodGet, "/api/production/project_flocks/flocks/"+uintToString(flockID)+"/periods", nil) +// if resp.StatusCode != fiber.StatusOK { +// t.Fatalf("expected 200 when fetching summary, got %d: %s", resp.StatusCode, string(body)) +// } - var summary struct { - Data struct { - NextPeriod int `json:"next_period"` - } `json:"data"` - } - if err := json.Unmarshal(body, &summary); err != nil { - t.Fatalf("failed to parse summary response: %v", err) - } +// var summary struct { +// Data struct { +// NextPeriod int `json:"next_period"` +// } `json:"data"` +// } +// if err := json.Unmarshal(body, &summary); err != nil { +// t.Fatalf("failed to parse summary response: %v", err) +// } - if summary.Data.NextPeriod != 3 { - t.Fatalf("expected next_period 3, got %d", summary.Data.NextPeriod) - } +// if summary.Data.NextPeriod != 3 { +// t.Fatalf("expected next_period 3, got %d", summary.Data.NextPeriod) +// } - resp, body = doJSONRequest(t, app, http.MethodDelete, "/api/production/project_flocks/"+uintToString(createResp.Data.Id), nil) - if resp.StatusCode != fiber.StatusOK { - t.Fatalf("expected 200 when deleting first project flock, got %d: %s", resp.StatusCode, string(body)) - } +// resp, body = doJSONRequest(t, app, http.MethodDelete, "/api/production/project_flocks/"+uintToString(createResp.Data.Id), nil) +// if resp.StatusCode != fiber.StatusOK { +// t.Fatalf("expected 200 when deleting first project flock, got %d: %s", resp.StatusCode, string(body)) +// } - firstKandang := fetchKandang(t, db, kandangID) - if firstKandang.ProjectFlockId != nil { - t.Fatalf("expected project_flock_id to be nil after delete, got %v", *firstKandang.ProjectFlockId) - } - if firstKandang.Status != string(utils.KandangStatusNonActive) { - t.Fatalf("expected kandang status to revert to NON_ACTIVE, got %s", firstKandang.Status) - } +// firstKandang := fetchKandang(t, db, kandangID) +// if firstKandang.ProjectFlockId != nil { +// t.Fatalf("expected project_flock_id to be nil after delete, got %v", *firstKandang.ProjectFlockId) +// } +// if firstKandang.Status != string(utils.KandangStatusNonActive) { +// t.Fatalf("expected kandang status to revert to NON_ACTIVE, got %s", firstKandang.Status) +// } - var remainingFirst int64 - if err := db.Model(&entities.ProjectFlockKandang{}). - Where("project_flock_id = ? AND kandang_id = ?", createResp.Data.Id, kandangID). - Count(&remainingFirst).Error; err != nil { - t.Fatalf("failed to count first pivot records after delete: %v", err) - } - if remainingFirst != 0 { - t.Fatalf("expected no pivot records remaining after delete, found %d", remainingFirst) - } +// var remainingFirst int64 +// if err := db.Model(&entities.ProjectFlockKandang{}). +// Where("project_flock_id = ? AND kandang_id = ?", createResp.Data.Id, kandangID). +// Count(&remainingFirst).Error; err != nil { +// t.Fatalf("failed to count first pivot records after delete: %v", err) +// } +// if remainingFirst != 0 { +// t.Fatalf("expected no pivot records remaining after delete, found %d", remainingFirst) +// } - resp, body = doJSONRequest(t, app, http.MethodDelete, "/api/production/project_flocks/"+uintToString(createRespSecond.Data.Id), nil) - if resp.StatusCode != fiber.StatusOK { - t.Fatalf("expected 200 when deleting second project flock, got %d: %s", resp.StatusCode, string(body)) - } +// resp, body = doJSONRequest(t, app, http.MethodDelete, "/api/production/project_flocks/"+uintToString(createRespSecond.Data.Id), nil) +// if resp.StatusCode != fiber.StatusOK { +// t.Fatalf("expected 200 when deleting second project flock, got %d: %s", resp.StatusCode, string(body)) +// } - secondKandang = fetchKandang(t, db, secondKandangID) - if secondKandang.ProjectFlockId != nil { - t.Fatalf("expected second project_flock_id to be nil after delete, got %v", *secondKandang.ProjectFlockId) - } - if secondKandang.Status != string(utils.KandangStatusNonActive) { - t.Fatalf("expected second kandang status to revert to NON_ACTIVE, got %s", secondKandang.Status) - } +// secondKandang = fetchKandang(t, db, secondKandangID) +// if secondKandang.ProjectFlockId != nil { +// t.Fatalf("expected second project_flock_id to be nil after delete, got %v", *secondKandang.ProjectFlockId) +// } +// if secondKandang.Status != string(utils.KandangStatusNonActive) { +// t.Fatalf("expected second kandang status to revert to NON_ACTIVE, got %s", secondKandang.Status) +// } - var remainingSecond int64 - if err := db.Model(&entities.ProjectFlockKandang{}). - Where("project_flock_id = ? AND kandang_id = ?", createRespSecond.Data.Id, secondKandangID). - Count(&remainingSecond).Error; err != nil { - t.Fatalf("failed to count second pivot records after delete: %v", err) - } - if remainingSecond != 0 { - t.Fatalf("expected no second pivot records remaining after delete, found %d", remainingSecond) - } +// var remainingSecond int64 +// if err := db.Model(&entities.ProjectFlockKandang{}). +// Where("project_flock_id = ? AND kandang_id = ?", createRespSecond.Data.Id, secondKandangID). +// Count(&remainingSecond).Error; err != nil { +// t.Fatalf("failed to count second pivot records after delete: %v", err) +// } +// if remainingSecond != 0 { +// t.Fatalf("expected no second pivot records remaining after delete, found %d", remainingSecond) +// } - resp, body = doJSONRequest(t, app, http.MethodGet, "/api/production/project_flocks/flocks/"+uintToString(flockID)+"/periods", nil) - if resp.StatusCode != fiber.StatusOK { - t.Fatalf("expected 200 when fetching summary after delete, got %d: %s", resp.StatusCode, string(body)) - } +// resp, body = doJSONRequest(t, app, http.MethodGet, "/api/production/project_flocks/flocks/"+uintToString(flockID)+"/periods", nil) +// if resp.StatusCode != fiber.StatusOK { +// t.Fatalf("expected 200 when fetching summary after delete, got %d: %s", resp.StatusCode, string(body)) +// } - if err := json.Unmarshal(body, &summary); err != nil { - t.Fatalf("failed to parse summary response after delete: %v", err) - } +// if err := json.Unmarshal(body, &summary); err != nil { +// t.Fatalf("failed to parse summary response after delete: %v", err) +// } - if summary.Data.NextPeriod != 1 { - t.Fatalf("expected next_period 1 after soft deletes, got %d", summary.Data.NextPeriod) - } -} +// if summary.Data.NextPeriod != 1 { +// t.Fatalf("expected next_period 1 after soft deletes, got %d", summary.Data.NextPeriod) +// } +// } -func uintToString(v uint) string { - return fmt.Sprintf("%d", v) -} +// func uintToString(v uint) string { +// return fmt.Sprintf("%d", v) +// } -func TestProjectFlockSearchByRelatedFields(t *testing.T) { - app, _ := setupIntegrationApp(t) +// func TestProjectFlockSearchByRelatedFields(t *testing.T) { +// app, _ := setupIntegrationApp(t) - areaID := createArea(t, app, "Area Search Target") - locationID := createLocation(t, app, "Location Search Target", "Location Address Target", areaID) - flockID := createFlock(t, app, "Flock Search Target") - fcrID := createFcr(t, app, "FCR Search Target", []map[string]any{ - {"weight": 1.0, "fcr_number": 1.5, "mortality": 2.0}, - }) - kandangID := createKandang(t, app, "Kandang Search Target", locationID, 1) +// areaID := createArea(t, app, "Area Search Target") +// locationID := createLocation(t, app, "Location Search Target", "Location Address Target", areaID) +// flockID := createFlock(t, app, "Flock Search Target") +// fcrID := createFcr(t, app, "FCR Search Target", []map[string]any{ +// {"weight": 1.0, "fcr_number": 1.5, "mortality": 2.0}, +// }) +// kandangID := createKandang(t, app, "Kandang Search Target", locationID, 1) - createPayload := map[string]any{ - "flock_id": flockID, - "area_id": areaID, - "category": "growing", - "fcr_id": fcrID, - "location_id": locationID, - "kandang_ids": []uint{kandangID}, - } +// createPayload := map[string]any{ +// "flock_id": flockID, +// "area_id": areaID, +// "category": "growing", +// "fcr_id": fcrID, +// "location_id": locationID, +// "kandang_ids": []uint{kandangID}, +// } - resp, body := doJSONRequest(t, app, http.MethodPost, "/api/production/project_flocks", createPayload) - if resp.StatusCode != fiber.StatusCreated { - t.Fatalf("expected 201 when creating project flock, got %d: %s", resp.StatusCode, string(body)) - } +// resp, body := doJSONRequest(t, app, http.MethodPost, "/api/production/project_flocks", createPayload) +// if resp.StatusCode != fiber.StatusCreated { +// t.Fatalf("expected 201 when creating project flock, got %d: %s", resp.StatusCode, string(body)) +// } - var createResp struct { - Data struct { - Id uint `json:"id"` - } `json:"data"` - } - if err := json.Unmarshal(body, &createResp); err != nil { - t.Fatalf("failed to parse create response: %v", err) - } +// var createResp struct { +// Data struct { +// Id uint `json:"id"` +// } `json:"data"` +// } +// if err := json.Unmarshal(body, &createResp); err != nil { +// t.Fatalf("failed to parse create response: %v", err) +// } - searchTerms := []string{ - "Flock Search Target", - "Area Search Target", - string(utils.ProjectFlockCategoryGrowing), - "growing", - "FCR Search Target", - "Kandang Search Target", - "Location Search Target", - "Location Address Target", - "Tester", - "1", - } +// searchTerms := []string{ +// "Flock Search Target", +// "Area Search Target", +// string(utils.ProjectFlockCategoryGrowing), +// "growing", +// "FCR Search Target", +// "Kandang Search Target", +// "Location Search Target", +// "Location Address Target", +// "Tester", +// "1", +// } - for _, term := range searchTerms { - path := "/api/production/project_flocks?search=" + url.QueryEscape(term) - resp, body := doJSONRequest(t, app, http.MethodGet, path, nil) - if resp.StatusCode != fiber.StatusOK { - t.Fatalf("expected 200 when searching for %q, got %d: %s", term, resp.StatusCode, string(body)) - } +// for _, term := range searchTerms { +// path := "/api/production/project_flocks?search=" + url.QueryEscape(term) +// resp, body := doJSONRequest(t, app, http.MethodGet, path, nil) +// if resp.StatusCode != fiber.StatusOK { +// t.Fatalf("expected 200 when searching for %q, got %d: %s", term, resp.StatusCode, string(body)) +// } - var listResp struct { - Data []struct { - Id uint `json:"id"` - } `json:"data"` - Meta struct { - TotalResults int64 `json:"total_results"` - } `json:"meta"` - } - if err := json.Unmarshal(body, &listResp); err != nil { - t.Fatalf("failed to parse list response for %q: %v", term, err) - } - if listResp.Meta.TotalResults == 0 { - t.Fatalf("expected at least one result when searching for %q", term) - } - if len(listResp.Data) == 0 { - t.Fatalf("expected data when searching for %q", term) - } - if listResp.Data[0].Id != createResp.Data.Id { - t.Fatalf("expected project flock id %d for search term %q, got %d", createResp.Data.Id, term, listResp.Data[0].Id) - } - } -} +// var listResp struct { +// Data []struct { +// Id uint `json:"id"` +// } `json:"data"` +// Meta struct { +// TotalResults int64 `json:"total_results"` +// } `json:"meta"` +// } +// if err := json.Unmarshal(body, &listResp); err != nil { +// t.Fatalf("failed to parse list response for %q: %v", term, err) +// } +// if listResp.Meta.TotalResults == 0 { +// t.Fatalf("expected at least one result when searching for %q", term) +// } +// if len(listResp.Data) == 0 { +// t.Fatalf("expected data when searching for %q", term) +// } +// if listResp.Data[0].Id != createResp.Data.Id { +// t.Fatalf("expected project flock id %d for search term %q, got %d", createResp.Data.Id, term, listResp.Data[0].Id) +// } +// } +// } -func TestProjectFlockSorting(t *testing.T) { - app, _ := setupIntegrationApp(t) +// func TestProjectFlockSorting(t *testing.T) { +// app, _ := setupIntegrationApp(t) - areaA := createArea(t, app, "Area Alpha") - areaB := createArea(t, app, "Area Beta") +// areaA := createArea(t, app, "Area Alpha") +// areaB := createArea(t, app, "Area Beta") - locationA := createLocation(t, app, "Location Alpha", "Address Alpha", areaA) - locationB := createLocation(t, app, "Location Beta", "Address Beta", areaB) +// locationA := createLocation(t, app, "Location Alpha", "Address Alpha", areaA) +// locationB := createLocation(t, app, "Location Beta", "Address Beta", areaB) - flockOne := createFlock(t, app, "Flock Sort One") - flockTwo := createFlock(t, app, "Flock Sort Two") +// flockOne := createFlock(t, app, "Flock Sort One") +// flockTwo := createFlock(t, app, "Flock Sort Two") - fcrID := createFcr(t, app, "FCR Sort", []map[string]any{ - {"weight": 1.0, "fcr_number": 1.5, "mortality": 2.0}, - }) +// fcrID := createFcr(t, app, "FCR Sort", []map[string]any{ +// {"weight": 1.0, "fcr_number": 1.5, "mortality": 2.0}, +// }) - kandangOne := createKandang(t, app, "Kandang Sort One", locationA, 1) - kandangTwo := createKandang(t, app, "Kandang Sort Two", locationB, 1) - kandangThree := createKandang(t, app, "Kandang Sort Three", locationB, 1) +// kandangOne := createKandang(t, app, "Kandang Sort One", locationA, 1) +// kandangTwo := createKandang(t, app, "Kandang Sort Two", locationB, 1) +// kandangThree := createKandang(t, app, "Kandang Sort Three", locationB, 1) - projectOnePayload := map[string]any{ - "flock_id": flockOne, - "area_id": areaA, - "category": "growing", - "fcr_id": fcrID, - "location_id": locationA, - "kandang_ids": []uint{kandangOne}, - } - resp, body := doJSONRequest(t, app, http.MethodPost, "/api/production/project_flocks", projectOnePayload) - if resp.StatusCode != fiber.StatusCreated { - t.Fatalf("expected 201 for project one, got %d: %s", resp.StatusCode, string(body)) - } - projectOneID := parseProjectFlockID(t, body) +// projectOnePayload := map[string]any{ +// "flock_id": flockOne, +// "area_id": areaA, +// "category": "growing", +// "fcr_id": fcrID, +// "location_id": locationA, +// "kandang_ids": []uint{kandangOne}, +// } +// resp, body := doJSONRequest(t, app, http.MethodPost, "/api/production/project_flocks", projectOnePayload) +// if resp.StatusCode != fiber.StatusCreated { +// t.Fatalf("expected 201 for project one, got %d: %s", resp.StatusCode, string(body)) +// } +// projectOneID := parseProjectFlockID(t, body) - projectTwoPayload := map[string]any{ - "flock_id": flockTwo, - "area_id": areaB, - "category": "laying", - "fcr_id": fcrID, - "location_id": locationB, - "kandang_ids": []uint{kandangTwo, kandangThree}, - } - resp, body = doJSONRequest(t, app, http.MethodPost, "/api/production/project_flocks", projectTwoPayload) - if resp.StatusCode != fiber.StatusCreated { - t.Fatalf("expected 201 for project two, got %d: %s", resp.StatusCode, string(body)) - } - projectTwoID := parseProjectFlockID(t, body) +// projectTwoPayload := map[string]any{ +// "flock_id": flockTwo, +// "area_id": areaB, +// "category": "laying", +// "fcr_id": fcrID, +// "location_id": locationB, +// "kandang_ids": []uint{kandangTwo, kandangThree}, +// } +// resp, body = doJSONRequest(t, app, http.MethodPost, "/api/production/project_flocks", projectTwoPayload) +// if resp.StatusCode != fiber.StatusCreated { +// t.Fatalf("expected 201 for project two, got %d: %s", resp.StatusCode, string(body)) +// } +// projectTwoID := parseProjectFlockID(t, body) - updatePeriodPayload := map[string]any{"period": 5} - resp, body = doJSONRequest(t, app, http.MethodPatch, "/api/production/project_flocks/"+uintToString(projectTwoID), updatePeriodPayload) - if resp.StatusCode != fiber.StatusOK { - t.Fatalf("expected 200 when updating period, got %d: %s", resp.StatusCode, string(body)) - } +// updatePeriodPayload := map[string]any{"period": 5} +// resp, body = doJSONRequest(t, app, http.MethodPatch, "/api/production/project_flocks/"+uintToString(projectTwoID), updatePeriodPayload) +// if resp.StatusCode != fiber.StatusOK { +// t.Fatalf("expected 200 when updating period, got %d: %s", resp.StatusCode, string(body)) +// } - assertOrder := func(t *testing.T, app *fiber.App, query string, expectedFirst uint) { - t.Helper() - resp, body := doJSONRequest(t, app, http.MethodGet, "/api/production/project_flocks?"+query, nil) - if resp.StatusCode != fiber.StatusOK { - t.Fatalf("expected 200 for query %q, got %d: %s", query, resp.StatusCode, string(body)) - } - var listResp struct { - Data []struct { - Id uint `json:"id"` - } `json:"data"` - } - if err := json.Unmarshal(body, &listResp); err != nil { - t.Fatalf("failed to parse list response for %q: %v", query, err) - } - if len(listResp.Data) == 0 { - t.Fatalf("expected data for query %q", query) - } - if listResp.Data[0].Id != expectedFirst { - t.Fatalf("expected first id %d for query %q, got %d", expectedFirst, query, listResp.Data[0].Id) - } - } +// assertOrder := func(t *testing.T, app *fiber.App, query string, expectedFirst uint) { +// t.Helper() +// resp, body := doJSONRequest(t, app, http.MethodGet, "/api/production/project_flocks?"+query, nil) +// if resp.StatusCode != fiber.StatusOK { +// t.Fatalf("expected 200 for query %q, got %d: %s", query, resp.StatusCode, string(body)) +// } +// var listResp struct { +// Data []struct { +// Id uint `json:"id"` +// } `json:"data"` +// } +// if err := json.Unmarshal(body, &listResp); err != nil { +// t.Fatalf("failed to parse list response for %q: %v", query, err) +// } +// if len(listResp.Data) == 0 { +// t.Fatalf("expected data for query %q", query) +// } +// if listResp.Data[0].Id != expectedFirst { +// t.Fatalf("expected first id %d for query %q, got %d", expectedFirst, query, listResp.Data[0].Id) +// } +// } - assertOrder(t, app, "sort_by=area&sort_order=asc", projectOneID) - assertOrder(t, app, "sort_by=location&sort_order=desc", projectTwoID) - assertOrder(t, app, "sort_by=period&sort_order=desc", projectTwoID) - assertOrder(t, app, "sort_by=kandangs&sort_order=desc", projectTwoID) - assertOrder(t, app, "sort_by=kandangs&sort_order=asc", projectOneID) -} +// assertOrder(t, app, "sort_by=area&sort_order=asc", projectOneID) +// assertOrder(t, app, "sort_by=location&sort_order=desc", projectTwoID) +// assertOrder(t, app, "sort_by=period&sort_order=desc", projectTwoID) +// assertOrder(t, app, "sort_by=kandangs&sort_order=desc", projectTwoID) +// assertOrder(t, app, "sort_by=kandangs&sort_order=asc", projectOneID) +// } -func parseProjectFlockID(t *testing.T, body []byte) uint { - t.Helper() - var resp struct { - Data struct { - Id uint `json:"id"` - } `json:"data"` - } - if err := json.Unmarshal(body, &resp); err != nil { - t.Fatalf("failed to parse project flock response: %v", err) - } - return resp.Data.Id -} +// func parseProjectFlockID(t *testing.T, body []byte) uint { +// t.Helper() +// var resp struct { +// Data struct { +// Id uint `json:"id"` +// } `json:"data"` +// } +// if err := json.Unmarshal(body, &resp); err != nil { +// t.Fatalf("failed to parse project flock response: %v", err) +// } +// return resp.Data.Id +// } diff --git a/tools/templates/controller.tmpl b/tools/templates/controller.tmpl index 9fcf6d9b..f2eb615e 100644 --- a/tools/templates/controller.tmpl +++ b/tools/templates/controller.tmpl @@ -29,6 +29,10 @@ func (u *{{Pascal .Entity}}Controller) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.{{Pascal .Entity}}Service.GetAll(c, query) if err != nil { return err