diff --git a/.air.toml b/.air.toml index 9853d2fb..0c534172 100644 --- a/.air.toml +++ b/.air.toml @@ -3,13 +3,9 @@ root = "." tmp_dir = "tmp" [build] -# Build binary utama -cmd = "go build -o /lti-api/tmp/main ./cmd/api" -# Lokasi binary hasil build -bin = "/lti-api/tmp/main" -# Jalankan binary langsung dengan environment dev -full_bin = "APP_ENV=dev /lti-api/tmp/main" -# File yang dipantau oleh Air +cmd = "go build -o ./tmp/main ./cmd/api" +bin = "tmp/main" +full_bin = "APP_ENV=dev ./tmp/main" include_ext = ["go", "tpl", "tmpl", "html"] exclude_dir = ["vendor", "tmp"] diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 00000000..cdc4652d --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,77 @@ +services: + postgresdb: + image: postgres:alpine + restart: always + ports: + - "${DB_PORT_HOST:-5542}:5432" + environment: + POSTGRES_USER: ${DB_USER:-postgres} + POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres} + POSTGRES_DB: ${DB_NAME:-db_lti_erp} + volumes: + - dbdata:/var/lib/postgresql/data + - ./internal/database/init:/docker-entrypoint-initdb.d + networks: [go-network] + healthcheck: + test: + [ + "CMD-SHELL", + "pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-db_lti_erp}", + ] + interval: 10s + timeout: 5s + retries: 5 + redis: + image: redis:7-alpine + restart: unless-stopped + ports: + - "${REDIS_PORT_HOST:-6381}:6379" + healthcheck: + test: ["CMD-SHELL", "redis-cli ping | grep PONG"] + interval: 5s + timeout: 3s + retries: 10 + networks: [go-network] + + app: + build: + context: . + dockerfile: Dockerfile.local + image: cosmtrek/air:v1.52.3 + 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 + environment: + DB_HOST: postgresdb + DB_PORT: 5432 + DB_USER: ${DB_USER:-postgres} + DB_PASSWORD: ${DB_PASSWORD:-postgres} + DB_NAME: ${DB_NAME:-db_lti_erp} + REDIS_URL: ${REDIS_URL:-redis://redis:6379/0} + ports: + - "${APP_PORT:-8081}:8081" + depends_on: + postgresdb: + condition: service_healthy + networks: [go-network] + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:8081/healthz || exit 1"] + interval: 10s + timeout: 3s + retries: 10 + start_period: 10s + +volumes: + dbdata: + go-mod-cache: + go-build-cache: + +networks: + go-network: + name: lti-api_go-network + driver: bridge diff --git a/go.mod b/go.mod index fc28567b..078bcfe0 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/gofiber/contrib/jwt v1.0.10 github.com/gofiber/fiber/v2 v2.52.5 github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/google/uuid v1.6.0 github.com/jackc/pgconn v1.14.1 github.com/redis/go-redis/v9 v9.14.0 github.com/sirupsen/logrus v1.9.3 @@ -33,7 +34,6 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/google/go-cmp v0.6.0 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/jackc/chunkreader/v2 v2.0.1 // indirect github.com/jackc/pgio v1.0.0 // indirect diff --git a/internal/common/validation/validation.go b/internal/common/validation/validation.go index 426974f3..330009e6 100644 --- a/internal/common/validation/validation.go +++ b/internal/common/validation/validation.go @@ -3,6 +3,8 @@ package validation import ( "errors" "fmt" + "reflect" + "strings" "github.com/go-playground/validator/v10" ) @@ -21,34 +23,41 @@ var customMessages = map[string]string{ "alphanum": "Field %s must contain only alphanumeric characters", "oneof": "Invalid value for field %s", "password": "Field %s must be at least 8 characters, contain uppercase, lowercase, number, and special character", + "gt": "Invalid %s, must be greater than %s", } -func CustomErrorMessages(err error) map[string]string { +func CustomErrorMessages(err error) (string, map[string]string) { var validationErrors validator.ValidationErrors if errors.As(err, &validationErrors) { return generateErrorMessages(validationErrors) } - return nil + return "", nil } -func generateErrorMessages(validationErrors validator.ValidationErrors) map[string]string { +func generateErrorMessages(validationErrors validator.ValidationErrors) (string, map[string]string) { errorsMap := make(map[string]string) - for _, err := range validationErrors { + var firstMessage string + for i, err := range validationErrors { fieldName := err.StructNamespace() tag := err.Tag() customMessage := customMessages[tag] + var msg string if customMessage != "" { - errorsMap[fieldName] = formatErrorMessage(customMessage, err, tag) + msg = formatErrorMessage(customMessage, err, tag) } else { - errorsMap[fieldName] = defaultErrorMessage(err) + msg = defaultErrorMessage(err) + } + errorsMap[fieldName] = msg + if i == 0 { + firstMessage = msg } } - return errorsMap + return firstMessage, errorsMap } func formatErrorMessage(customMessage string, err validator.FieldError, tag string) string { - if tag == "min" || tag == "max" || tag == "len" { + if tag == "min" || tag == "max" || tag == "len" || tag == "gt" { return fmt.Sprintf(customMessage, err.Field(), err.Param()) } return fmt.Sprintf(customMessage, err.Field()) @@ -61,6 +70,16 @@ func defaultErrorMessage(err validator.FieldError) string { func Validator() *validator.Validate { validate := validator.New() + validate.RegisterTagNameFunc(func(fld reflect.StructField) string { + if jsonTag := getTagName(fld, "json"); jsonTag != "" { + return jsonTag + } + if queryTag := getTagName(fld, "query"); queryTag != "" { + return queryTag + } + return fld.Name + }) + if err := validate.RegisterValidation("password", Password); err != nil { return nil } @@ -72,3 +91,16 @@ func Validator() *validator.Validate { } return validate } + +func getTagName(fld reflect.StructField, tag string) string { + value, ok := fld.Tag.Lookup(tag) + if !ok || value == "-" { + return "" + } + + name := strings.Split(value, ",")[0] + if name == "" || name == "-" { + return "" + } + return name +} diff --git a/internal/config/.DS_Store b/internal/config/.DS_Store index 5008ddfc..6dacdd03 100644 Binary files a/internal/config/.DS_Store and b/internal/config/.DS_Store differ diff --git a/internal/database/migrations/20251018120649_create_project_chick_ins_table.up.sql b/internal/database/migrations/20251018120649_create_project_chick_ins_table.up.sql deleted file mode 100644 index 25d3476d..00000000 --- a/internal/database/migrations/20251018120649_create_project_chick_ins_table.up.sql +++ /dev/null @@ -1,36 +0,0 @@ -CREATE TABLE IF NOT EXISTS project_chickins ( - id BIGSERIAL PRIMARY KEY, - project_flock_kandang_id BIGINT NOT NULL, - chick_in_date DATE NOT NULL, - quantity NUMERIC(15, 3) NOT NULL, - note TEXT, - created_by BIGINT NOT NULL, - created_at TIMESTAMPTZ DEFAULT now(), - updated_at TIMESTAMPTZ DEFAULT now(), - deleted_at TIMESTAMPTZ -); - --- FOREIGN KEYS (dijalankan setelah semua tabel parent ada) -DO $$ -BEGIN - IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'project_flock_kandangs') THEN - ALTER TABLE project_chickins - ADD CONSTRAINT fk_project_flock_kandang_id - FOREIGN KEY (project_flock_kandang_id) - REFERENCES project_flock_kandangs(id) - ON DELETE RESTRICT ON UPDATE CASCADE; - END IF; - - IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN - ALTER TABLE project_chickins - ADD CONSTRAINT fk_created_by - FOREIGN KEY (created_by) - REFERENCES users(id) - ON DELETE RESTRICT ON UPDATE CASCADE; - END IF; -END $$; - --- INDEXES -CREATE INDEX IF NOT EXISTS idx_project_chickins_project_flock_kandang_id ON project_chickins (project_flock_kandang_id); - -CREATE INDEX IF NOT EXISTS idx_project_chickins_created_by ON project_chickins (created_by); \ No newline at end of file diff --git a/internal/database/migrations/20251020022357_create_project_flock_populations_table.up.sql b/internal/database/migrations/20251020022357_create_project_flock_populations_table.up.sql deleted file mode 100644 index 82b3e9a7..00000000 --- a/internal/database/migrations/20251020022357_create_project_flock_populations_table.up.sql +++ /dev/null @@ -1,36 +0,0 @@ -CREATE TABLE IF NOT EXISTS project_flock_populations ( - id BIGSERIAL PRIMARY KEY, - project_flock_kandang_id BIGINT NOT NULL, - initial_quantity NUMERIC(15, 3) NOT NULL, - current_quantity NUMERIC(15, 3) NOT NULL, - reserved_quantity NUMERIC(15, 3), - created_by BIGINT NOT NULL, - created_at TIMESTAMPTZ DEFAULT now(), - updated_at TIMESTAMPTZ DEFAULT now(), - deleted_at TIMESTAMPTZ -); - --- FOREIGN KEYS (dijalankan setelah semua tabel parent ada) -DO $$ -BEGIN - IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'project_flock_kandangs') THEN - ALTER TABLE project_flock_populations - ADD CONSTRAINT fk_project_flock_kandang_id - FOREIGN KEY (project_flock_kandang_id) - REFERENCES project_flock_kandangs(id) - ON DELETE RESTRICT ON UPDATE CASCADE; - END IF; - - IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN - ALTER TABLE project_flock_populations - ADD CONSTRAINT fk_created_by - FOREIGN KEY (created_by) - REFERENCES users(id) - ON DELETE RESTRICT ON UPDATE CASCADE; - END IF; -END $$; - --- INDEXES -CREATE INDEX IF NOT EXISTS idx_project_flock_populations_project_flock_kandang_id ON project_flock_populations (project_flock_kandang_id); - -CREATE INDEX IF NOT EXISTS idx_project_flock_populations_created_by ON project_flock_populations (created_by); \ No newline at end of file diff --git a/internal/database/migrations/20251022024829_create_project_chickin_details.up.sql b/internal/database/migrations/20251022024829_create_project_chickin_details.up.sql index 349086ba..a2364abe 100644 --- a/internal/database/migrations/20251022024829_create_project_chickin_details.up.sql +++ b/internal/database/migrations/20251022024829_create_project_chickin_details.up.sql @@ -9,7 +9,7 @@ CREATE TABLE IF NOT EXISTS project_chickin_details ( deleted_at TIMESTAMPTZ ); --- FOREIGN KEYS (dijalankan setelah semua tabel parent ada) + DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'project_chickins') THEN 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 index 14e6dd0a..5ae51638 100644 --- 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 @@ -1,22 +1,30 @@ - ALTER TABLE kandangs - DROP CONSTRAINT IF EXISTS kandangs_project_flock_id_fkey; +DROP CONSTRAINT IF EXISTS kandangs_project_flock_id_fkey; -ALTER TABLE kandangs - DROP COLUMN IF EXISTS project_flock_id; +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; +-- Only alter if tables exist +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'project_chickins') THEN + ALTER TABLE project_chickins + DROP CONSTRAINT IF EXISTS fk_project_flock_kandang_id; + ALTER TABLE project_chickins + ADD CONSTRAINT fk_project_flock_kandang_id + FOREIGN KEY (project_flock_kandang_id) + REFERENCES project_flock_kandangs(id) + ON UPDATE CASCADE + ON DELETE CASCADE; + END IF; -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; + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'project_flock_populations') THEN + ALTER TABLE project_flock_populations + DROP CONSTRAINT IF EXISTS fk_project_flock_kandang_id; + ALTER TABLE project_flock_populations + ADD CONSTRAINT fk_project_flock_kandang_id + FOREIGN KEY (project_flock_kandang_id) + REFERENCES project_flock_kandangs(id) + ON UPDATE CASCADE + ON DELETE CASCADE; + END IF; +END $$; \ No newline at end of file diff --git a/internal/database/migrations/20251029074825_create_laying_transfers_table.down.sql b/internal/database/migrations/20251029074825_create_laying_transfers_table.down.sql new file mode 100644 index 00000000..29313fe4 --- /dev/null +++ b/internal/database/migrations/20251029074825_create_laying_transfers_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS laying_transfers CASCADE; \ No newline at end of file diff --git a/internal/database/migrations/20251029074825_create_laying_transfers_table.up.sql b/internal/database/migrations/20251029074825_create_laying_transfers_table.up.sql new file mode 100644 index 00000000..69b0fb5d --- /dev/null +++ b/internal/database/migrations/20251029074825_create_laying_transfers_table.up.sql @@ -0,0 +1,52 @@ +CREATE TABLE IF NOT EXISTS laying_transfers ( + id BIGSERIAL PRIMARY KEY, + transfer_number VARCHAR(50) UNIQUE NOT NULL, + from_project_flock_id BIGINT NOT NULL, + to_project_flock_id BIGINT NOT NULL, + transfer_date DATE NOT NULL, + pending_usage_qty NUMERIC(15, 3), + usage_qty NUMERIC(15, 3), + notes TEXT, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now(), + deleted_at TIMESTAMPTZ, + created_by BIGINT NOT NULL +); + +-- FOREIGN KEYS (dijalankan setelah semua tabel parent ada) +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'project_flocks') THEN + ALTER TABLE laying_transfers + ADD CONSTRAINT fk_laying_from_project_flock + FOREIGN KEY (from_project_flock_id) + REFERENCES project_flocks(id) + ON DELETE RESTRICT ON UPDATE CASCADE; + ALTER TABLE laying_transfers + ADD CONSTRAINT fk_laying_to_project_flock + FOREIGN KEY (to_project_flock_id) + REFERENCES project_flocks(id) + ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; + + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN + ALTER TABLE laying_transfers + ADD CONSTRAINT fk_laying_created_by + FOREIGN KEY (created_by) + REFERENCES users(id) + ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- INDEXES +CREATE UNIQUE INDEX IF NOT EXISTS idx_laying_transfers_transfer_number ON laying_transfers (transfer_number) +WHERE + deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_laying_transfers_from_project_flock_id ON laying_transfers (from_project_flock_id); + +CREATE INDEX IF NOT EXISTS idx_laying_transfers_to_project_flock_id ON laying_transfers (to_project_flock_id); + +CREATE INDEX IF NOT EXISTS idx_laying_transfers_created_by ON laying_transfers (created_by); + +CREATE INDEX IF NOT EXISTS idx_laying_transfers_deleted_at ON laying_transfers (deleted_at); \ No newline at end of file diff --git a/internal/database/migrations/20251030134527_recreate_project_chikins_table.down.sql b/internal/database/migrations/20251030134527_recreate_project_chikins_table.down.sql new file mode 100644 index 00000000..e69de29b diff --git a/internal/database/migrations/20251030134527_recreate_project_chikins_table.up.sql b/internal/database/migrations/20251030134527_recreate_project_chikins_table.up.sql new file mode 100644 index 00000000..e029646b --- /dev/null +++ b/internal/database/migrations/20251030134527_recreate_project_chikins_table.up.sql @@ -0,0 +1,58 @@ +-- ============================================ +-- MIGRATION: project_chickins +-- ============================================ + +-- STEP 1: Hapus tabel jika sudah ada +DROP TABLE IF EXISTS project_chickins; + +-- STEP 2: Buat tabel project_chickins +CREATE TABLE IF NOT EXISTS project_chickins ( + id BIGSERIAL PRIMARY KEY, + project_flock_kandang_id BIGINT NOT NULL, + product_warehouse_id BIGINT NOT NULL, + chick_in_date DATE NOT NULL, + usage_qty NUMERIC(15, 3) NOT NULL, + pending_usage_qty NUMERIC(15, 3) DEFAULT 0, + notes TEXT, + created_by BIGINT NOT NULL, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now(), + deleted_at TIMESTAMPTZ +); + +-- STEP 3: FOREIGN KEYS +BEGIN; + +-- Relasi ke project_flock_kandangs +ALTER TABLE project_chickins +ADD CONSTRAINT fk_project_chickins_kandang FOREIGN KEY (project_flock_kandang_id) REFERENCES project_flock_kandangs (id) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- Relasi ke product_warehouses +ALTER TABLE project_chickins +ADD CONSTRAINT fk_project_chickins_warehouse FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses (id) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- Relasi ke users +ALTER TABLE project_chickins +ADD CONSTRAINT fk_project_chickins_created_by FOREIGN KEY (created_by) REFERENCES users (id) ON DELETE RESTRICT ON UPDATE CASCADE; + +COMMIT; + +-- STEP 4: INDEXES +CREATE INDEX IF NOT EXISTS idx_chickins_kandang_id ON project_chickins (project_flock_kandang_id) +WHERE + deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_chickins_warehouse_id ON project_chickins (product_warehouse_id) +WHERE + deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_chickins_created_by ON project_chickins (created_by); + +-- Composite index for common queries +CREATE INDEX IF NOT EXISTS idx_chickins_kandang_deleted ON project_chickins ( + project_flock_kandang_id, + deleted_at +); + +-- Index for soft delete queries +CREATE INDEX IF NOT EXISTS idx_chickins_deleted_at ON project_chickins (deleted_at); \ No newline at end of file diff --git a/internal/database/migrations/20251030134542_recreate_project_flock_populations.down.sql b/internal/database/migrations/20251030134542_recreate_project_flock_populations.down.sql new file mode 100644 index 00000000..e69de29b diff --git a/internal/database/migrations/20251030134542_recreate_project_flock_populations.up.sql b/internal/database/migrations/20251030134542_recreate_project_flock_populations.up.sql new file mode 100644 index 00000000..aa529408 --- /dev/null +++ b/internal/database/migrations/20251030134542_recreate_project_flock_populations.up.sql @@ -0,0 +1,62 @@ +-- ============================================ +-- MIGRATION: project_flock_populations +-- ============================================ + +-- STEP 1: Hapus tabel jika sudah ada +DROP TABLE IF EXISTS project_flock_populations; + +-- STEP 2: Buat tabel project_flock_populations +CREATE TABLE IF NOT EXISTS project_flock_populations ( + id BIGSERIAL PRIMARY KEY, + project_chickin_id BIGINT NOT NULL, + product_warehouse_id BIGINT NOT NULL, + total_qty NUMERIC(15, 3) NOT NULL, + total_used_qty NUMERIC(15, 3) DEFAULT 0, + notes TEXT, + created_by BIGINT NOT NULL, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now(), + deleted_at TIMESTAMPTZ +); + +-- STEP 3: FOREIGN KEYS +BEGIN; + +-- Relasi ke project_chickins +ALTER TABLE project_flock_populations +ADD CONSTRAINT fk_project_flock_populations_chickin FOREIGN KEY (project_chickin_id) REFERENCES project_chickins (id) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- Relasi ke product_warehouses +ALTER TABLE project_flock_populations +ADD CONSTRAINT fk_project_flock_populations_warehouse FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses (id) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- Relasi ke users +ALTER TABLE project_flock_populations +ADD CONSTRAINT fk_project_flock_populations_created_by FOREIGN KEY (created_by) REFERENCES users (id) ON DELETE RESTRICT ON UPDATE CASCADE; + +COMMIT; + +-- STEP 4: INDEXES +CREATE INDEX IF NOT EXISTS idx_populations_chickin_id ON project_flock_populations (project_chickin_id) +WHERE + deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_populations_warehouse_id ON project_flock_populations (product_warehouse_id) +WHERE + deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_populations_created_by ON project_flock_populations (created_by); + +-- Composite index for common queries +CREATE INDEX IF NOT EXISTS idx_populations_chickin_deleted ON project_flock_populations ( + project_chickin_id, + deleted_at +); + +-- Index for soft delete queries +CREATE INDEX IF NOT EXISTS idx_populations_deleted_at ON project_flock_populations (deleted_at); + +-- Unique constraint: one population per chickin +CREATE UNIQUE INDEX IF NOT EXISTS idx_populations_chickin_unique ON project_flock_populations (project_chickin_id) +WHERE + deleted_at IS NULL; \ No newline at end of file diff --git a/internal/database/migrations/20251103054536_add_laying_transfer_sources_and_targets_tables.down.sql b/internal/database/migrations/20251103054536_add_laying_transfer_sources_and_targets_tables.down.sql new file mode 100644 index 00000000..2b890114 --- /dev/null +++ b/internal/database/migrations/20251103054536_add_laying_transfer_sources_and_targets_tables.down.sql @@ -0,0 +1,5 @@ +-- Rollback laying_transfer_sources dan laying_transfer_targets tables + +DROP TABLE IF EXISTS laying_transfer_targets CASCADE; + +DROP TABLE IF EXISTS laying_transfer_sources CASCADE; \ No newline at end of file diff --git a/internal/database/migrations/20251103054536_add_laying_transfer_sources_and_targets_tables.up.sql b/internal/database/migrations/20251103054536_add_laying_transfer_sources_and_targets_tables.up.sql new file mode 100644 index 00000000..023fc7b4 --- /dev/null +++ b/internal/database/migrations/20251103054536_add_laying_transfer_sources_and_targets_tables.up.sql @@ -0,0 +1,93 @@ +-- Create laying_transfer_sources dan laying_transfer_targets tables + +-- 1. Create laying_transfer_sources table (detail sumber - kandang asal growing) +CREATE TABLE laying_transfer_sources ( + id BIGSERIAL PRIMARY KEY, + laying_transfer_id BIGINT NOT NULL, + source_project_flock_kandang_id BIGINT NOT NULL, + product_warehouse_id BIGINT, + qty NUMERIC(15, 3) NOT NULL, + note TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +-- Add foreign keys untuk laying_transfer_sources +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'laying_transfers') THEN + ALTER TABLE laying_transfer_sources + ADD CONSTRAINT fk_laying_transfer_sources_laying_transfer_id + FOREIGN KEY (laying_transfer_id) REFERENCES laying_transfers(id) ON DELETE CASCADE; + END IF; + + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'project_flock_kandangs') THEN + ALTER TABLE laying_transfer_sources + ADD CONSTRAINT fk_laying_transfer_sources_project_flock_kandang_id + FOREIGN KEY (source_project_flock_kandang_id) REFERENCES project_flock_kandangs(id) ON DELETE RESTRICT; + END IF; + + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'product_warehouses') THEN + ALTER TABLE laying_transfer_sources + ADD CONSTRAINT fk_laying_transfer_sources_product_warehouse_id + FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses(id) ON DELETE SET NULL; + END IF; +END $$; + +-- 2. Create laying_transfer_targets table (detail tujuan - kandang laying) +CREATE TABLE laying_transfer_targets ( + id BIGSERIAL PRIMARY KEY, + laying_transfer_id BIGINT NOT NULL, + target_project_flock_kandang_id BIGINT NOT NULL, + qty NUMERIC(15, 3) NOT NULL, + product_warehouse_id BIGINT, + note TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +-- Add foreign keys untuk laying_transfer_targets +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'laying_transfers') THEN + ALTER TABLE laying_transfer_targets + ADD CONSTRAINT fk_laying_transfer_targets_laying_transfer_id + FOREIGN KEY (laying_transfer_id) REFERENCES laying_transfers(id) ON DELETE CASCADE; + END IF; + + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'project_flock_kandangs') THEN + ALTER TABLE laying_transfer_targets + ADD CONSTRAINT fk_laying_transfer_targets_project_flock_kandang_id + FOREIGN KEY (target_project_flock_kandang_id) REFERENCES project_flock_kandangs(id) ON DELETE RESTRICT; + END IF; + + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'product_warehouses') THEN + ALTER TABLE laying_transfer_targets + ADD CONSTRAINT fk_laying_transfer_targets_product_warehouse_id + FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses(id) ON DELETE SET NULL; + END IF; +END $$; + +-- 3. Create indexes untuk laying_transfer_sources +CREATE INDEX idx_laying_transfer_sources_laying_transfer_id ON laying_transfer_sources (laying_transfer_id); + +CREATE INDEX idx_laying_transfer_sources_source_kandang_id ON laying_transfer_sources ( + source_project_flock_kandang_id +); + +CREATE INDEX idx_laying_transfer_sources_product_warehouse_id ON laying_transfer_sources (product_warehouse_id); + +CREATE INDEX idx_laying_transfer_sources_deleted_at ON laying_transfer_sources (deleted_at); + +-- 4. Create indexes untuk laying_transfer_targets +CREATE INDEX idx_laying_transfer_targets_laying_transfer_id ON laying_transfer_targets (laying_transfer_id); + +CREATE INDEX idx_laying_transfer_targets_target_kandang_id ON laying_transfer_targets ( + target_project_flock_kandang_id +); + +CREATE INDEX idx_laying_transfer_targets_product_warehouse_id ON laying_transfer_targets (product_warehouse_id); + +CREATE INDEX idx_laying_transfer_targets_deleted_at ON laying_transfer_targets (deleted_at); \ No newline at end of file diff --git a/internal/database/migrations/20251104084540_purchase-items.down.sql b/internal/database/migrations/20251104084540_purchase-items.down.sql new file mode 100644 index 00000000..46d2b5eb --- /dev/null +++ b/internal/database/migrations/20251104084540_purchase-items.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS purchase_items; diff --git a/internal/database/migrations/20251104084540_purchase-items.up.sql b/internal/database/migrations/20251104084540_purchase-items.up.sql new file mode 100644 index 00000000..a09b1d15 --- /dev/null +++ b/internal/database/migrations/20251104084540_purchase-items.up.sql @@ -0,0 +1,59 @@ +CREATE TABLE IF NOT EXISTS purchase_items ( + id BIGSERIAL PRIMARY KEY, + purchase_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + warehouse_id BIGINT NOT NULL, + product_warehouse_id BIGINT, + received_date TIMESTAMPTZ, + travel_number VARCHAR, + travel_number_docs VARCHAR, + vehicle_number VARCHAR, + sub_qty NUMERIC(15, 3) NOT NULL, + total_qty NUMERIC(15, 3) DEFAULT 0, + total_used NUMERIC(15, 3) DEFAULT 0, + price NUMERIC(15, 3) DEFAULT 0, + total_price NUMERIC(15, 3) DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'products') THEN + EXECUTE + 'ALTER TABLE purchase_items + ADD CONSTRAINT fk_purchase_items_product + FOREIGN KEY (product_id) + REFERENCES products(id) + ON DELETE RESTRICT ON UPDATE CASCADE'; + END IF; + + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'warehouses') THEN + EXECUTE + 'ALTER TABLE purchase_items + ADD CONSTRAINT fk_purchase_items_warehouse + FOREIGN KEY (warehouse_id) + REFERENCES warehouses(id) + ON DELETE RESTRICT ON UPDATE CASCADE'; + END IF; + + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'product_warehouses') THEN + EXECUTE + 'ALTER TABLE purchase_items + ADD CONSTRAINT fk_purchase_items_product_warehouse + FOREIGN KEY (product_warehouse_id) + REFERENCES product_warehouses(id) + ON DELETE SET NULL ON UPDATE CASCADE'; + END IF; +END $$; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_purchase_items_unique_allocation + ON purchase_items (purchase_id, product_id, warehouse_id) + WHERE deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_purchase_items_product_id ON purchase_items (product_id); +CREATE INDEX IF NOT EXISTS idx_purchase_items_warehouse_id ON purchase_items (warehouse_id); +CREATE INDEX IF NOT EXISTS idx_purchase_items_product_warehouse_id ON purchase_items (product_warehouse_id); +CREATE INDEX IF NOT EXISTS idx_purchase_items_purchase_id ON purchase_items (purchase_id); +CREATE INDEX IF NOT EXISTS idx_purchase_items_deleted_at ON purchase_items (deleted_at); diff --git a/internal/database/migrations/20251104084555_purchases.down.sql b/internal/database/migrations/20251104084555_purchases.down.sql new file mode 100644 index 00000000..f3900bbf --- /dev/null +++ b/internal/database/migrations/20251104084555_purchases.down.sql @@ -0,0 +1,14 @@ +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_purchase_items_purchase' + AND conrelid = 'purchase_items'::regclass + ) THEN + ALTER TABLE purchase_items + DROP CONSTRAINT fk_purchase_items_purchase; + END IF; +END $$; + +DROP TABLE IF EXISTS purchases; diff --git a/internal/database/migrations/20251104084555_purchases.up.sql b/internal/database/migrations/20251104084555_purchases.up.sql new file mode 100644 index 00000000..e42f1606 --- /dev/null +++ b/internal/database/migrations/20251104084555_purchases.up.sql @@ -0,0 +1,64 @@ +CREATE TABLE IF NOT EXISTS purchases ( + id BIGSERIAL PRIMARY KEY, + pr_number VARCHAR NOT NULL, + po_number VARCHAR, + po_date TIMESTAMPTZ, + supplier_id BIGINT NOT NULL, + credit_term INT, + due_date TIMESTAMPTZ, + grand_total NUMERIC(15, 3) DEFAULT 0, + notes TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + created_by BIGINT NOT NULL +); + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'suppliers') THEN + EXECUTE + 'ALTER TABLE purchases + ADD CONSTRAINT fk_purchases_supplier + FOREIGN KEY (supplier_id) + REFERENCES suppliers(id) + ON DELETE RESTRICT ON UPDATE CASCADE'; + END IF; + + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN + EXECUTE + 'ALTER TABLE purchases + ADD CONSTRAINT fk_purchases_created_by + FOREIGN KEY (created_by) + REFERENCES users(id) + ON DELETE RESTRICT ON UPDATE CASCADE'; + END IF; + + IF EXISTS ( + SELECT 1 FROM pg_tables WHERE tablename = 'purchase_items' + ) AND NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_purchase_items_purchase' + ) THEN + EXECUTE + 'ALTER TABLE purchase_items + ADD CONSTRAINT fk_purchase_items_purchase + FOREIGN KEY (purchase_id) + REFERENCES purchases(id) + ON DELETE CASCADE ON UPDATE CASCADE'; + END IF; +END $$; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_purchases_pr_number_unique + ON purchases (pr_number) + WHERE deleted_at IS NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_purchases_po_number_unique + ON purchases (po_number) + WHERE deleted_at IS NULL AND po_number IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_purchases_supplier_id ON purchases (supplier_id); +CREATE INDEX IF NOT EXISTS idx_purchases_created_by ON purchases (created_by); +CREATE INDEX IF NOT EXISTS idx_purchases_po_date ON purchases (po_date); +CREATE INDEX IF NOT EXISTS idx_purchases_deleted_at ON purchases (deleted_at); diff --git a/internal/database/migrations/20251107013921_create_marketings.down.sql b/internal/database/migrations/20251107013921_create_marketings.down.sql new file mode 100644 index 00000000..df4a0db9 --- /dev/null +++ b/internal/database/migrations/20251107013921_create_marketings.down.sql @@ -0,0 +1,5 @@ +DROP TABLE IF EXISTS marketing_delivery_products CASCADE; + +DROP TABLE IF EXISTS marketing_products CASCADE; + +DROP TABLE IF EXISTS marketings CASCADE; \ No newline at end of file diff --git a/internal/database/migrations/20251107013921_create_marketings.up.sql b/internal/database/migrations/20251107013921_create_marketings.up.sql new file mode 100644 index 00000000..d2a3e24f --- /dev/null +++ b/internal/database/migrations/20251107013921_create_marketings.up.sql @@ -0,0 +1,44 @@ +CREATE TABLE marketings ( + id BIGSERIAL PRIMARY KEY, + so_number VARCHAR(255) UNIQUE NOT NULL, + customer_id BIGINT NOT NULL, + so_docs VARCHAR(20), + so_date DATE NOT NULL, + sales_person_id BIGINT NOT NULL, + notes TEXT, + created_by BIGINT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'customers') THEN + ALTER TABLE marketings + ADD CONSTRAINT fk_marketings_customer_id + FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE RESTRICT; + END IF; + + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN + ALTER TABLE marketings + ADD CONSTRAINT fk_marketings_sales_person_id + FOREIGN KEY (sales_person_id) REFERENCES users(id) ON DELETE RESTRICT; + END IF; + + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN + ALTER TABLE marketings + ADD CONSTRAINT fk_marketings_created_by + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE RESTRICT; + END IF; +END $$; + +CREATE INDEX idx_marketings_customer_id ON marketings (customer_id); + +CREATE INDEX idx_marketings_sales_person_id ON marketings (sales_person_id); + +CREATE INDEX idx_marketings_created_by ON marketings (created_by); + +CREATE INDEX idx_marketings_so_date ON marketings (so_date); + +CREATE INDEX idx_marketings_deleted_at ON marketings (deleted_at); \ No newline at end of file diff --git a/internal/database/migrations/20251107015122_create_marketing_products.down.sql b/internal/database/migrations/20251107015122_create_marketing_products.down.sql new file mode 100644 index 00000000..c40efdb3 --- /dev/null +++ b/internal/database/migrations/20251107015122_create_marketing_products.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS marketing_products CASCADE; \ No newline at end of file diff --git a/internal/database/migrations/20251107015122_create_marketing_products.up.sql b/internal/database/migrations/20251107015122_create_marketing_products.up.sql new file mode 100644 index 00000000..5490f931 --- /dev/null +++ b/internal/database/migrations/20251107015122_create_marketing_products.up.sql @@ -0,0 +1,34 @@ +CREATE TABLE marketing_products ( + id BIGSERIAL PRIMARY KEY, + marketing_id BIGINT NOT NULL, + product_warehouse_id BIGINT NOT NULL, + qty NUMERIC(15, 3) NOT NULL, + unit_price NUMERIC(15, 3) NOT NULL, + avg_weight NUMERIC(15, 3) NOT NULL, + total_weight NUMERIC(15, 3) NOT NULL, + total_price NUMERIC(15, 3) NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'marketings') THEN + ALTER TABLE marketing_products + ADD CONSTRAINT fk_marketing_products_marketing_id + FOREIGN KEY (marketing_id) REFERENCES marketings(id) ON DELETE CASCADE; + END IF; + + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'product_warehouses') THEN + ALTER TABLE marketing_products + ADD CONSTRAINT fk_marketing_products_product_warehouse_id + FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses(id) ON DELETE RESTRICT; + END IF; +END $$; + +CREATE INDEX idx_marketing_products_marketing_id ON marketing_products (marketing_id); + +CREATE INDEX idx_marketing_products_product_warehouse_id ON marketing_products (product_warehouse_id); + +CREATE INDEX idx_marketing_products_deleted_at ON marketing_products (deleted_at); \ No newline at end of file diff --git a/internal/database/migrations/20251107015128_create_marketing_product_deliveries.down.sql b/internal/database/migrations/20251107015128_create_marketing_product_deliveries.down.sql new file mode 100644 index 00000000..20da9516 --- /dev/null +++ b/internal/database/migrations/20251107015128_create_marketing_product_deliveries.down.sql @@ -0,0 +1,2 @@ + +DROP TABLE IF EXISTS marketing_delivery_products CASCADE; \ No newline at end of file diff --git a/internal/database/migrations/20251107015128_create_marketing_product_deliveries.up.sql b/internal/database/migrations/20251107015128_create_marketing_product_deliveries.up.sql new file mode 100644 index 00000000..45ca0907 --- /dev/null +++ b/internal/database/migrations/20251107015128_create_marketing_product_deliveries.up.sql @@ -0,0 +1,29 @@ +CREATE TABLE marketing_delivery_products ( + id BIGSERIAL PRIMARY KEY, + marketing_product_id BIGINT UNIQUE NOT NULL, + qty NUMERIC(15, 3) NOT NULL, + unit_price NUMERIC(15, 3) NOT NULL, + total_weight NUMERIC(15, 3) NOT NULL, + avg_weight NUMERIC(15, 3) NOT NULL, + total_price NUMERIC(15, 3) NOT NULL, + delivery_date DATE, + vehicle_number VARCHAR(50), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'marketing_products') THEN + ALTER TABLE marketing_delivery_products + ADD CONSTRAINT fk_marketing_delivery_products_marketing_product_id + FOREIGN KEY (marketing_product_id) REFERENCES marketing_products(id) ON DELETE CASCADE; + END IF; +END $$; + +CREATE INDEX idx_marketing_delivery_products_marketing_product_id ON marketing_delivery_products (marketing_product_id); + +CREATE INDEX idx_marketing_delivery_products_delivery_date ON marketing_delivery_products (delivery_date); + +CREATE INDEX idx_marketing_delivery_products_deleted_at ON marketing_delivery_products (deleted_at); \ No newline at end of file diff --git a/internal/database/migrations/20251114084320_update_kandang_capacity.down.sql b/internal/database/migrations/20251114084320_update_kandang_capacity.down.sql new file mode 100644 index 00000000..4afc4f12 --- /dev/null +++ b/internal/database/migrations/20251114084320_update_kandang_capacity.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE kandangs + DROP COLUMN IF EXISTS capacity; diff --git a/internal/database/migrations/20251114084320_update_kandang_capacity.up.sql b/internal/database/migrations/20251114084320_update_kandang_capacity.up.sql new file mode 100644 index 00000000..e1ea4410 --- /dev/null +++ b/internal/database/migrations/20251114084320_update_kandang_capacity.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE kandangs + ADD COLUMN capacity NUMERIC(15,3) NOT NULL; diff --git a/internal/database/migrations/20251117034511_create_expenses_table.down.sql b/internal/database/migrations/20251117034511_create_expenses_table.down.sql new file mode 100644 index 00000000..bf0ea945 --- /dev/null +++ b/internal/database/migrations/20251117034511_create_expenses_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS expenses; \ No newline at end of file diff --git a/internal/database/migrations/20251117034511_create_expenses_table.up.sql b/internal/database/migrations/20251117034511_create_expenses_table.up.sql new file mode 100644 index 00000000..054084e4 --- /dev/null +++ b/internal/database/migrations/20251117034511_create_expenses_table.up.sql @@ -0,0 +1,44 @@ +CREATE TABLE expenses ( + id BIGSERIAL PRIMARY KEY, + reference_number VARCHAR, -- format => BOP-LTI-0001 = 0001 is increment + supplier_id BIGINT NULL, + category VARCHAR(50) NOT NULL CHECK ( + category IN ('BOP', 'NON-BOP') + ), + po_number VARCHAR(50) UNIQUE NOT NULL, + document_path JSON, + expense_date DATE NOT NULL, + grand_total NUMERIC(15, 3) DEFAULT 0, + note TEXT, + created_by BIGINT, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now(), + deleted_at TIMESTAMPTZ +); + +-- Tambahkan Foreign Key ke suppliers +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'suppliers') THEN + ALTER TABLE expenses + ADD CONSTRAINT fk_expenses_supplier_id + FOREIGN KEY (supplier_id) REFERENCES suppliers(id); + END IF; +END $$; + +-- Tambahkan Foreign Key ke users (created_by) +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN + ALTER TABLE expenses + ADD CONSTRAINT fk_expenses_created_by + FOREIGN KEY (created_by) REFERENCES users(id); + END IF; +END $$; + +-- Index +CREATE INDEX idx_expenses_supplier_id ON expenses (supplier_id); + +CREATE INDEX idx_expenses_expense_date ON expenses (expense_date); + +CREATE INDEX idx_expenses_deleted_at ON expenses (deleted_at); \ No newline at end of file diff --git a/internal/database/migrations/20251117034529_create_expense_nonstocks_table.down.sql b/internal/database/migrations/20251117034529_create_expense_nonstocks_table.down.sql new file mode 100644 index 00000000..70fcf148 --- /dev/null +++ b/internal/database/migrations/20251117034529_create_expense_nonstocks_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS expense_nonstocks; \ No newline at end of file diff --git a/internal/database/migrations/20251117034529_create_expense_nonstocks_table.up.sql b/internal/database/migrations/20251117034529_create_expense_nonstocks_table.up.sql new file mode 100644 index 00000000..5b0c2c16 --- /dev/null +++ b/internal/database/migrations/20251117034529_create_expense_nonstocks_table.up.sql @@ -0,0 +1,50 @@ +CREATE TABLE expense_nonstocks ( + id BIGSERIAL PRIMARY KEY, + expense_id BIGINT, + project_flock_kandang_id BIGINT, + nonstock_id BIGINT, + qty NUMERIC(15, 3) NOT NULL, + unit_price NUMERIC(15, 3) NOT NULL, + total_price NUMERIC(15, 3) NOT NULL, + note TEXT NULL, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now(), + deleted_at TIMESTAMPTZ +); + +-- Tambahkan Foreign Key ke expenses +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'expenses') THEN + ALTER TABLE expense_nonstocks + ADD CONSTRAINT fk_expense_nonstocks_expense_id + FOREIGN KEY (expense_id) REFERENCES expenses(id); + END IF; +END $$; + +-- Tambahkan Foreign Key ke project_flock_kandangs +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'project_flock_kandangs') THEN + ALTER TABLE expense_nonstocks + ADD CONSTRAINT fk_expense_nonstocks_kandang_id + FOREIGN KEY (project_flock_kandang_id) REFERENCES project_flock_kandangs(id); + END IF; +END $$; + +-- Tambahkan Foreign Key ke nonstocks +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'nonstocks') THEN + ALTER TABLE expense_nonstocks + ADD CONSTRAINT fk_expense_nonstocks_nonstock_id + FOREIGN KEY (nonstock_id) REFERENCES nonstocks(id); + END IF; +END $$; + +-- Index +CREATE INDEX idx_expense_nonstocks_expense_id ON expense_nonstocks (expense_id); + +CREATE INDEX idx_expense_nonstocks_nonstock_id ON expense_nonstocks (nonstock_id); + +CREATE INDEX idx_expense_nonstocks_deleted_at ON expense_nonstocks (deleted_at); \ No newline at end of file diff --git a/internal/database/migrations/20251117034538_create_expense_realizations_table.down.sql b/internal/database/migrations/20251117034538_create_expense_realizations_table.down.sql new file mode 100644 index 00000000..5f70a0e6 --- /dev/null +++ b/internal/database/migrations/20251117034538_create_expense_realizations_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS expense_realizations; \ No newline at end of file diff --git a/internal/database/migrations/20251117034538_create_expense_realizations_table.up.sql b/internal/database/migrations/20251117034538_create_expense_realizations_table.up.sql new file mode 100644 index 00000000..4a8dc148 --- /dev/null +++ b/internal/database/migrations/20251117034538_create_expense_realizations_table.up.sql @@ -0,0 +1,40 @@ +CREATE TABLE expense_realizations ( + id BIGSERIAL PRIMARY KEY, + expense_nonstock_id BIGINT, + realization_qty NUMERIC(15, 3) NOT NULL, + realization_unit_price NUMERIC(15, 3) NOT NULL, + realization_total_price NUMERIC(15, 3) NOT NULL, + realization_date DATE NOT NULL, + note TEXT, + created_by BIGINT, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now(), + deleted_at TIMESTAMPTZ +); + +-- Tambahkan Foreign Key ke expense_nonstocks +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'expense_nonstocks') THEN + ALTER TABLE expense_realizations + ADD CONSTRAINT fk_expense_realizations_nonstock_id + FOREIGN KEY (expense_nonstock_id) REFERENCES expense_nonstocks(id); + END IF; +END $$; + +-- Tambahkan Foreign Key ke users (created_by) +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN + ALTER TABLE expense_realizations + ADD CONSTRAINT fk_expense_realizations_created_by + FOREIGN KEY (created_by) REFERENCES users(id); + END IF; +END $$; + +-- Index +CREATE INDEX idx_expense_realizations_nonstock_id ON expense_realizations (expense_nonstock_id); + +CREATE INDEX idx_expense_realizations_date ON expense_realizations (realization_date); + +CREATE INDEX idx_expense_realizations_deleted_at ON expense_realizations (deleted_at); \ No newline at end of file diff --git a/internal/database/seed/seeder.go b/internal/database/seed/seeder.go index 24425917..7c1f8a1e 100644 --- a/internal/database/seed/seeder.go +++ b/internal/database/seed/seeder.go @@ -235,13 +235,14 @@ func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users seeds := []struct { Name string Status utils.KandangStatus + Capacity float64 Location string PicKey string }{ - {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.KandangStatusNonActive, Location: "Cikaum", PicKey: "admin"}, - {Name: "Cikaum 2", Status: utils.KandangStatusNonActive, Location: "Cikaum", PicKey: "admin"}, + {Name: "Singaparna 1", Status: utils.KandangStatusNonActive, Capacity: 50000, Location: "Singaparna", PicKey: "admin"}, + {Name: "Singaparna 2", Status: utils.KandangStatusNonActive, Capacity: 50000, Location: "Singaparna", PicKey: "admin"}, + {Name: "Cikaum 1", Status: utils.KandangStatusNonActive, Capacity: 50000, Location: "Cikaum", PicKey: "admin"}, + {Name: "Cikaum 2", Status: utils.KandangStatusNonActive, Capacity: 50000, Location: "Cikaum", PicKey: "admin"}, } result := make(map[string]uint, len(seeds)) @@ -571,52 +572,54 @@ func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories Flags: []utils.FlagType{utils.FlagDOC}, }, { - Name: "Ayam Afkir", - Brand: "-", - Sku: "1", + Name: "Ayam Pullet", + Brand: "MBU Pullet", + Sku: "PLT0001", Uom: "Ekor", - Category: "Day Old Chick", - Price: 1, - - + Category: "Pullet", + Price: 15000, + Suppliers: []string{"PT CHAROEN POKPHAND INDONESIA Tbk"}, + Flags: []utils.FlagType{utils.FlagPullet}, }, { - Name: "Ayam Mati", - Brand: "-", - Sku: "2", - Uom: "Ekor", - Category: "Day Old Chick", - Price: 1, - - + Name: "Ayam Afkir", + Brand: "-", + Sku: "1", + Uom: "Ekor", + Category: "Day Old Chick", + Price: 1, }, { - Name: "Ayam Culling", - Brand: "-", - Sku: "3", - Uom: "Ekor", - Category: "Day Old Chick", - Price: 1, - - + Name: "Ayam Mati", + Brand: "-", + Sku: "2", + Uom: "Ekor", + Category: "Day Old Chick", + Price: 1, }, { - Name: "Telur Konsumsi Baik", - Brand: "-", - Sku: "4", - Uom: "Unit", - Category: "Telur", - Price: 1, - + Name: "Ayam Culling", + Brand: "-", + Sku: "3", + Uom: "Ekor", + Category: "Day Old Chick", + Price: 1, }, { - Name: "Telur Pecah", - Brand: "-", - Sku: "5", - Uom: "Unit", - Category: "Telur", - 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", diff --git a/internal/entities/expense.go b/internal/entities/expense.go new file mode 100644 index 00000000..a427582d --- /dev/null +++ b/internal/entities/expense.go @@ -0,0 +1,18 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type Expense 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:"-"` + + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` +} diff --git a/internal/entities/kandang.go b/internal/entities/kandang.go index 178681f0..882184b3 100644 --- a/internal/entities/kandang.go +++ b/internal/entities/kandang.go @@ -7,17 +7,18 @@ 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"` - 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"` + 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"` + Capacity float64 `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/laying_kandang_transfer.go b/internal/entities/laying_kandang_transfer.go new file mode 100644 index 00000000..8f514f71 --- /dev/null +++ b/internal/entities/laying_kandang_transfer.go @@ -0,0 +1,22 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type LayingKandangTransfer struct { + Id uint `gorm:"primaryKey"` + KandangId uint + ProductWarehouseId uint + Qty float64 `gorm:"type:numeric(15,3)"` + LayingTransferId uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index"` + + Kandang *Kandang `gorm:"foreignKey:KandangId;references:Id"` + ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` + LayingTransfer *LayingTransfer `gorm:"foreignKey:LayingTransferId;references:Id"` +} diff --git a/internal/entities/laying_transfer.go b/internal/entities/laying_transfer.go new file mode 100644 index 00000000..dd173042 --- /dev/null +++ b/internal/entities/laying_transfer.go @@ -0,0 +1,29 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type LayingTransfer struct { + Id uint `gorm:"primaryKey"` + TransferNumber string `gorm:"uniqueIndex;not null"` + FromProjectFlockId uint `gorm:"not null"` + ToProjectFlockId uint `gorm:"not null"` + TransferDate time.Time `gorm:"type:date;not null"` + PendingUsageQty *float64 `gorm:"type:numeric(15,3)"` + UsageQty *float64 `gorm:"type:numeric(15,3)"` + Notes string `gorm:"type:text"` + CreatedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index"` + + FromProjectFlock *ProjectFlock `gorm:"foreignKey:FromProjectFlockId;references:Id"` + ToProjectFlock *ProjectFlock `gorm:"foreignKey:ToProjectFlockId;references:Id"` + CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` + Sources []LayingTransferSource `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"` + Targets []LayingTransferTarget `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"` + LatestApproval *Approval `gorm:"-" json:"-"` +} diff --git a/internal/entities/laying_transfer_source.go b/internal/entities/laying_transfer_source.go new file mode 100644 index 00000000..6b54bd84 --- /dev/null +++ b/internal/entities/laying_transfer_source.go @@ -0,0 +1,23 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type LayingTransferSource struct { + Id uint `gorm:"primaryKey"` + LayingTransferId uint `gorm:"index;not null"` + SourceProjectFlockKandangId uint `gorm:"not null"` + ProductWarehouseId *uint `gorm:""` + Qty float64 `gorm:"type:numeric(15,3);not null"` + Note string `gorm:"type:text"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index"` + + LayingTransfer *LayingTransfer `gorm:"foreignKey:LayingTransferId;references:Id"` + SourceProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:SourceProjectFlockKandangId;references:Id"` + ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` +} diff --git a/internal/entities/laying_transfer_target.go b/internal/entities/laying_transfer_target.go new file mode 100644 index 00000000..dec98f1f --- /dev/null +++ b/internal/entities/laying_transfer_target.go @@ -0,0 +1,24 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type LayingTransferTarget struct { + Id uint `gorm:"primaryKey"` + LayingTransferId uint `gorm:"index;not null"` + TargetProjectFlockKandangId uint `gorm:"not null"` + Qty float64 `gorm:"type:numeric(15,3);not null"` + ProductWarehouseId *uint `gorm:""` + Note string `gorm:"type:text"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index"` + + LayingTransfer *LayingTransfer `gorm:"foreignKey:LayingTransferId;references:Id"` + TargetProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:TargetProjectFlockKandangId;references:Id"` + ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` +} + diff --git a/internal/entities/marketing.go b/internal/entities/marketing.go new file mode 100644 index 00000000..c9ff7624 --- /dev/null +++ b/internal/entities/marketing.go @@ -0,0 +1,27 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type Marketing struct { + Id uint `gorm:"primaryKey;autoIncrement"` + SoNumber string `gorm:"uniqueIndex;not null"` + CustomerId uint `gorm:"not null"` + SoDocs string `gorm:"type:varchar(20)"` + SoDate time.Time `gorm:"type:date;not null"` + SalesPersonId uint `gorm:"not null"` + Notes string `gorm:"type:text"` + CreatedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Customer Customer `gorm:"foreignKey:CustomerId;references:Id"` + SalesPerson User `gorm:"foreignKey:SalesPersonId;references:Id"` + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + Products []MarketingProduct `gorm:"foreignKey:MarketingId;references:Id"` + LatestApproval *Approval `gorm:"-" json:"latest_approval,omitempty"` +} diff --git a/internal/entities/marketing_delivery_product.go b/internal/entities/marketing_delivery_product.go new file mode 100644 index 00000000..253c00b2 --- /dev/null +++ b/internal/entities/marketing_delivery_product.go @@ -0,0 +1,24 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type MarketingDeliveryProduct struct { + Id uint `gorm:"primaryKey;autoIncrement"` + MarketingProductId uint `gorm:"uniqueIndex;not null"` + Qty float64 `gorm:"type:numeric(15,3)"` + UnitPrice float64 `gorm:"type:numeric(15,3)"` + TotalWeight float64 `gorm:"type:numeric(15,3)"` + AvgWeight float64 `gorm:"type:numeric(15,3)"` + TotalPrice float64 `gorm:"type:numeric(15,3)"` + DeliveryDate *time.Time `gorm:"type:timestamptz"` + VehicleNumber string `gorm:"type:varchar(50)"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + MarketingProduct MarketingProduct `gorm:"foreignKey:MarketingProductId;references:Id"` +} diff --git a/internal/entities/marketing_product.go b/internal/entities/marketing_product.go new file mode 100644 index 00000000..66524bc6 --- /dev/null +++ b/internal/entities/marketing_product.go @@ -0,0 +1,25 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type MarketingProduct struct { + Id uint `gorm:"primaryKey;autoIncrement"` + MarketingId uint `gorm:"not null"` + ProductWarehouseId uint `gorm:"not null"` + Qty float64 `gorm:"type:numeric(15,3);not null"` + UnitPrice float64 `gorm:"type:numeric(15,3);not null"` + AvgWeight float64 `gorm:"type:numeric(15,3);not null"` + TotalWeight float64 `gorm:"type:numeric(15,3);not null"` + TotalPrice float64 `gorm:"type:numeric(15,3);not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Marketing Marketing `gorm:"foreignKey:MarketingId;references:Id"` + ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` + DeliveryProduct *MarketingDeliveryProduct `gorm:"foreignKey:MarketingProductId;references:Id"` +} diff --git a/internal/entities/project_chickin.go b/internal/entities/project_chickin.go index 5dd22f1a..304ab0a9 100644 --- a/internal/entities/project_chickin.go +++ b/internal/entities/project_chickin.go @@ -12,13 +12,16 @@ type ProjectChickin struct { Id uint `gorm:"primaryKey"` 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"` + ProductWarehouseId uint `gorm:"not null"` + UsageQty float64 `gorm:"type:numeric(15,3);not null"` + PendingUsageQty float64 `gorm:"type:numeric(15,3);default:0"` + Notes string `gorm:"type:text"` CreatedBy uint `gorm:"not null"` CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - ProjectFlockKandang ProjectFlockKandang `gorm:"foreignKey:ProjectFlockKandangId;references:Id"` - CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + ProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:ProjectFlockKandangId;references:Id"` + ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` + CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` } diff --git a/internal/entities/project_flock_population.go b/internal/entities/project_flock_population.go index 6cd3a214..e5b3216c 100644 --- a/internal/entities/project_flock_population.go +++ b/internal/entities/project_flock_population.go @@ -7,17 +7,18 @@ import ( ) type ProjectFlockPopulation struct { - Id uint `gorm:"primaryKey"` - 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)"` - CreatedBy uint `gorm:"not null"` - CreatedAt time.Time `gorm:"autoCreateTime"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` - DeletedAt gorm.DeletedAt `gorm:"index"` + Id uint `gorm:"primaryKey"` + ProjectChickinId uint `gorm:"not null"` + ProductWarehouseId uint `gorm:"not null"` + TotalQty float64 `gorm:"type:numeric(15,3);not null"` + TotalUsedQty float64 `gorm:"type:numeric(15,3);not null"` + Notes string `gorm:"type:text"` + CreatedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index"` - ProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:ProjectFlockKandangId;references:Id"` - - CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` + ProjectChickin *ProjectChickin `gorm:"foreignKey:ProjectChickinId;references:Id"` + ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` + CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` } diff --git a/internal/entities/projectflock.go b/internal/entities/projectflock.go index 0507d9f3..64f47aaf 100644 --- a/internal/entities/projectflock.go +++ b/internal/entities/projectflock.go @@ -28,3 +28,4 @@ type ProjectFlock struct { LatestApproval *Approval `gorm:"-" json:"-"` } + diff --git a/internal/entities/projectflock_kandang.go b/internal/entities/projectflock_kandang.go index 26238980..c02eafe1 100644 --- a/internal/entities/projectflock_kandang.go +++ b/internal/entities/projectflock_kandang.go @@ -3,13 +3,13 @@ package entities import "time" type ProjectFlockKandang struct { - Id uint `gorm:"primaryKey"` - 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"` + Id uint `gorm:"primaryKey"` + 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"` + Chickins []ProjectChickin `gorm:"foreignKey:ProjectFlockKandangId;references:Id"` + LatestApproval *Approval `gorm:"-" json:"-"` } diff --git a/internal/entities/purchase.go b/internal/entities/purchase.go new file mode 100644 index 00000000..1a57090a --- /dev/null +++ b/internal/entities/purchase.go @@ -0,0 +1,26 @@ +package entities + +import ( + "time" +) + +type Purchase struct { + Id uint64 `gorm:"primaryKey;autoIncrement"` + PrNumber string `gorm:"not null"` + PoNumber *string + PoDate *time.Time + SupplierId uint64 `gorm:"not null"` + CreditTerm *int + DueDate *time.Time + GrandTotal float64 `gorm:"type:numeric(15,3);default:0"` + Notes *string + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt *time.Time `gorm:"index"` + CreatedBy uint64 `gorm:"not null"` + + // Relations + Supplier Supplier `gorm:"foreignKey:SupplierId;references:Id"` + Items []PurchaseItem `gorm:"foreignKey:PurchaseId"` + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` +} diff --git a/internal/entities/purchase_item.go b/internal/entities/purchase_item.go new file mode 100644 index 00000000..b092b647 --- /dev/null +++ b/internal/entities/purchase_item.go @@ -0,0 +1,31 @@ +package entities + +import ( + "time" +) + +type PurchaseItem struct { + Id uint64 `gorm:"primaryKey;autoIncrement"` + PurchaseId uint64 `gorm:"not null"` + ProductId uint64 `gorm:"not null"` + WarehouseId uint64 `gorm:"not null"` + ProductWarehouseId *uint64 + ReceivedDate *time.Time + TravelNumber *string + TravelNumberDocs *string + VehicleNumber *string + SubQty float64 `gorm:"type:numeric(15,3);not null"` + TotalQty float64 `gorm:"type:numeric(15,3);default:0"` + TotalUsed float64 `gorm:"type:numeric(15,3);default:0"` + Price float64 `gorm:"type:numeric(15,3);default:0"` + TotalPrice float64 `gorm:"type:numeric(15,3);default:0"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt *time.Time `gorm:"index"` + + // Relations + Purchase *Purchase `gorm:"foreignKey:PurchaseId;references:Id"` + Product *Product `gorm:"foreignKey:ProductId;references:Id"` + Warehouse *Warehouse `gorm:"foreignKey:WarehouseId;references:Id"` + ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` +} diff --git a/internal/modules/approvals/dto/approval.dto.go b/internal/modules/approvals/dto/approval.dto.go index 085c367c..52b99fc6 100644 --- a/internal/modules/approvals/dto/approval.dto.go +++ b/internal/modules/approvals/dto/approval.dto.go @@ -11,6 +11,7 @@ import ( ) type ApprovalBaseDTO struct { + Id uint `json:"id"` StepNumber uint16 `json:"step_number"` StepName string `json:"step_name"` Action *string `json:"action"` @@ -27,6 +28,7 @@ type ApprovalGroupDTO struct { func ToApprovalDTO(e entity.Approval) ApprovalBaseDTO { dto := ApprovalBaseDTO{ + Id: e.Id, Notes: e.Notes, } diff --git a/internal/modules/constants/repositories/constant.repository.go b/internal/modules/constants/repositories/constant.repository.go index 4b44d553..493f4cb9 100644 --- a/internal/modules/constants/repositories/constant.repository.go +++ b/internal/modules/constants/repositories/constant.repository.go @@ -82,6 +82,10 @@ func (r *ConstantRepositoryImpl) GetConstants() map[string]interface{} { "LOKASI", "KANDANG", }, + "stock_log": map[string][]string{ + "log_types": []string{"TRANSFER", "ADJUSTMENT"}, + "transaction_types": []string{"INCREASE", "DECREASE"}, + }, "supplier_categories": []string{ "BOP", "SAPRONAK", diff --git a/internal/modules/expenses/controllers/expense.controller.go b/internal/modules/expenses/controllers/expense.controller.go new file mode 100644 index 00000000..074f2f0a --- /dev/null +++ b/internal/modules/expenses/controllers/expense.controller.go @@ -0,0 +1,144 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type ExpenseController struct { + ExpenseService service.ExpenseService +} + +func NewExpenseController(expenseService service.ExpenseService) *ExpenseController { + return &ExpenseController{ + ExpenseService: expenseService, + } +} + +func (u *ExpenseController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + } + + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + result, totalResults, err := u.ExpenseService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.ExpenseListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all expenses successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToExpenseListDTOs(result), + }) +} + +func (u *ExpenseController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := u.ExpenseService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get expense successfully", + Data: dto.ToExpenseListDTO(*result), + }) +} + +func (u *ExpenseController) CreateOne(c *fiber.Ctx) error { + req := new(validation.Create) + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.ExpenseService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create expense successfully", + Data: dto.ToExpenseListDTO(*result), + }) +} + +func (u *ExpenseController) UpdateOne(c *fiber.Ctx) error { + req := new(validation.Update) + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.ExpenseService.UpdateOne(c, req, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Update expense successfully", + Data: dto.ToExpenseListDTO(*result), + }) +} + +func (u *ExpenseController) DeleteOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := u.ExpenseService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete expense successfully", + }) +} diff --git a/internal/modules/expenses/dto/expense.dto.go b/internal/modules/expenses/dto/expense.dto.go new file mode 100644 index 00000000..b7bd1b5f --- /dev/null +++ b/internal/modules/expenses/dto/expense.dto.go @@ -0,0 +1,66 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" +) + +// === DTO Structs === + +type ExpenseBaseDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type ExpenseListDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + CreatedUser *userDTO.UserBaseDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ExpenseDetailDTO struct { + ExpenseListDTO +} + +// === Mapper Functions === + +func ToExpenseBaseDTO(e entity.Expense) ExpenseBaseDTO { + return ExpenseBaseDTO{ + Id: e.Id, + Name: e.Name, + } +} + +func ToExpenseListDTO(e entity.Expense) ExpenseListDTO { + var createdUser *userDTO.UserBaseDTO + if e.CreatedUser.Id != 0 { + mapped := userDTO.ToUserBaseDTO(e.CreatedUser) + createdUser = &mapped + } + + return ExpenseListDTO{ + Id: e.Id, + Name: e.Name, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, + } +} + +func ToExpenseListDTOs(e []entity.Expense) []ExpenseListDTO { + result := make([]ExpenseListDTO, len(e)) + for i, r := range e { + result[i] = ToExpenseListDTO(r) + } + return result +} + +func ToExpenseDetailDTO(e entity.Expense) ExpenseDetailDTO { + return ExpenseDetailDTO{ + ExpenseListDTO: ToExpenseListDTO(e), + } +} diff --git a/internal/modules/expenses/module.go b/internal/modules/expenses/module.go new file mode 100644 index 00000000..c9b2ab66 --- /dev/null +++ b/internal/modules/expenses/module.go @@ -0,0 +1,26 @@ +package expenses + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + rExpense "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" + sExpense "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type ExpenseModule struct{} + +func (ExpenseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + expenseRepo := rExpense.NewExpenseRepository(db) + userRepo := rUser.NewUserRepository(db) + + expenseService := sExpense.NewExpenseService(expenseRepo, validate) + userService := sUser.NewUserService(userRepo, validate) + + ExpenseRoutes(router, userService, expenseService) +} + diff --git a/internal/modules/expenses/repositories/expense.repository.go b/internal/modules/expenses/repositories/expense.repository.go new file mode 100644 index 00000000..94712cd5 --- /dev/null +++ b/internal/modules/expenses/repositories/expense.repository.go @@ -0,0 +1,21 @@ +package repository + +import ( + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + "gorm.io/gorm" +) + +type ExpenseRepository interface { + repository.BaseRepository[entity.Expense] +} + +type ExpenseRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Expense] +} + +func NewExpenseRepository(db *gorm.DB) ExpenseRepository { + return &ExpenseRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Expense](db), + } +} diff --git a/internal/modules/expenses/route.go b/internal/modules/expenses/route.go new file mode 100644 index 00000000..49a4e7c5 --- /dev/null +++ b/internal/modules/expenses/route.go @@ -0,0 +1,28 @@ +package expenses + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/controllers" + expense "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func ExpenseRoutes(v1 fiber.Router, u user.UserService, s expense.ExpenseService) { + ctrl := controller.NewExpenseController(s) + + route := v1.Group("/expenses") + + // 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.Get("/", ctrl.GetAll) + route.Post("/", ctrl.CreateOne) + route.Get("/:id", ctrl.GetOne) + route.Patch("/:id", ctrl.UpdateOne) + route.Delete("/:id", ctrl.DeleteOne) +} diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go new file mode 100644 index 00000000..1b57263e --- /dev/null +++ b/internal/modules/expenses/services/expense.service.go @@ -0,0 +1,129 @@ +package service + +import ( + "errors" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type ExpenseService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Expense, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.Expense, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Expense, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Expense, error) + DeleteOne(ctx *fiber.Ctx, id uint) error +} + +type expenseService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.ExpenseRepository +} + +func NewExpenseService(repo repository.ExpenseRepository, validate *validator.Validate) ExpenseService { + return &expenseService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + } +} + +func (s expenseService) withRelations(db *gorm.DB) *gorm.DB { + return db.Preload("CreatedUser") +} + +func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Expense, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + expenses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + if params.Search != "" { + return db.Where("name LIKE ?", "%"+params.Search+"%") + } + return db.Order("created_at DESC").Order("updated_at DESC") + }) + + if err != nil { + s.Log.Errorf("Failed to get expenses: %+v", err) + return nil, 0, err + } + return expenses, total, nil +} + +func (s expenseService) GetOne(c *fiber.Ctx, id uint) (*entity.Expense, error) { + expense, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Expense not found") + } + if err != nil { + s.Log.Errorf("Failed get expense by id: %+v", err) + return nil, err + } + return expense, nil +} + +func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Expense, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + createBody := &entity.Expense{ + Name: req.Name, + } + + if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { + s.Log.Errorf("Failed to create expense: %+v", err) + return nil, err + } + + return s.GetOne(c, createBody.Id) +} + +func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Expense, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + updateBody := make(map[string]any) + + if req.Name != nil { + updateBody["name"] = *req.Name + } + + if len(updateBody) == 0 { + return s.GetOne(c, id) + } + + if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Expense not found") + } + s.Log.Errorf("Failed to update expense: %+v", err) + return nil, err + } + + return s.GetOne(c, id) +} + +func (s expenseService) 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, "Expense not found") + } + s.Log.Errorf("Failed to delete expense: %+v", err) + return err + } + return nil +} diff --git a/internal/modules/expenses/validations/expense.validation.go b/internal/modules/expenses/validations/expense.validation.go new file mode 100644 index 00000000..7d16d3ee --- /dev/null +++ b/internal/modules/expenses/validations/expense.validation.go @@ -0,0 +1,15 @@ +package validation + +type Create struct { + Name string `json:"name" validate:"required_strict,min=3"` +} + +type Update struct { + Name *string `json:"name,omitempty" validate:"omitempty"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` +} diff --git a/internal/modules/inventory/marketing-delivery-products/repositories/marketing-delivery-products.repository.go b/internal/modules/inventory/marketing-delivery-products/repositories/marketing-delivery-products.repository.go new file mode 100644 index 00000000..512a5786 --- /dev/null +++ b/internal/modules/inventory/marketing-delivery-products/repositories/marketing-delivery-products.repository.go @@ -0,0 +1,51 @@ +package repository + +import ( + "context" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type MarketingDeliveryProductRepository interface { + repository.BaseRepository[entity.MarketingDeliveryProduct] + GetByMarketingProductID(ctx context.Context, marketingProductID uint) (*entity.MarketingDeliveryProduct, error) + GetByMarketingId(ctx context.Context, marketingId uint) ([]entity.MarketingDeliveryProduct, error) +} + +type MarketingDeliveryProductRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.MarketingDeliveryProduct] +} + +func NewMarketingDeliveryProductRepository(db *gorm.DB) MarketingDeliveryProductRepository { + return &MarketingDeliveryProductRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.MarketingDeliveryProduct](db), + } +} + +func (r *MarketingDeliveryProductRepositoryImpl) GetByMarketingProductID(ctx context.Context, marketingProductID uint) (*entity.MarketingDeliveryProduct, error) { + var deliveryProduct entity.MarketingDeliveryProduct + if err := r.DB().WithContext(ctx).Where("marketing_product_id = ?", marketingProductID).First(&deliveryProduct).Error; err != nil { + return nil, err + } + return &deliveryProduct, nil +} + +func (r *MarketingDeliveryProductRepositoryImpl) GetByMarketingId(ctx context.Context, marketingId uint) ([]entity.MarketingDeliveryProduct, error) { + var deliveryProducts []entity.MarketingDeliveryProduct + + // Raw query untuk mengambil delivery products berdasarkan marketing ID dengan preload MarketingProduct + // Filter: hanya ambil yang sudah memiliki delivery_date (delivery date tidak null) + if err := r.DB().WithContext(ctx). + Preload("MarketingProduct"). + Joins("INNER JOIN marketing_products mp ON marketing_delivery_products.marketing_product_id = mp.id"). + Where("mp.marketing_id = ?", marketingId). + Where("marketing_delivery_products.delivery_date IS NOT NULL"). + Order("marketing_delivery_products.id ASC"). + Find(&deliveryProducts).Error; err != nil { + return nil, err + } + + return deliveryProducts, nil +} 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 26f23278..671d964b 100644 --- a/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go +++ b/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go @@ -29,6 +29,7 @@ func (u *ProductWarehouseController) GetAll(c *fiber.Ctx) error { ProductId: uint(c.QueryInt("product_id", 0)), WarehouseId: uint(c.QueryInt("warehouse_id", 0)), Flags: c.Query("flags", ""), + KandangId: uint(c.QueryInt("kandang_id", 0)), } if query.Page < 1 || query.Limit < 1 { diff --git a/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go b/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go index 8c9f3846..f88a6ca3 100644 --- a/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go +++ b/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go @@ -4,6 +4,7 @@ import ( "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + productDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/dto" ) // === DTO Structs === @@ -15,13 +16,19 @@ type ProductWarehouseBaseDTO struct { Quantity float64 `json:"quantity"` } +type ProductWarehousNestedDTO struct { + Id uint `json:"id"` + Product *productDTO.ProductBaseDTO `json:"product,omitempty"` + Warehouse *WarehouseBaseDTO `json:"warehouse,omitempty"` +} + type ProductWarehouseListDTO struct { ProductWarehouseBaseDTO - Product *ProductBaseDTO `json:"product,omitempty"` - Warehouse *WarehouseBaseDTO `json:"warehouse,omitempty"` - CreatedUser *UserBaseDTO `json:"created_user,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + Product *productDTO.ProductBaseDTO `json:"product,omitempty"` + Warehouse *WarehouseBaseDTO `json:"warehouse,omitempty"` + CreatedUser *UserBaseDTO `json:"created_user,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type UserBaseDTO struct { @@ -75,6 +82,19 @@ func ToProductWarehouseBaseDTO(e entity.ProductWarehouse) ProductWarehouseBaseDT } } +func ToProductWarehouseNestedDTO(e entity.ProductWarehouse) ProductWarehousNestedDTO { + product := productDTO.ToProductBaseDTO(e.Product) + + return ProductWarehousNestedDTO{ + Id: e.Id, + Product: &product, + Warehouse: &WarehouseBaseDTO{ + Id: e.Warehouse.Id, + Name: e.Warehouse.Name, + }, + } +} + func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDTO { dto := ProductWarehouseListDTO{ ProductWarehouseBaseDTO: ToProductWarehouseBaseDTO(e), @@ -84,18 +104,7 @@ func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDT // Map Product relation jika ada if e.Product.Id != 0 { - product := ProductBaseDTO{ - Id: e.Product.Id, - Name: e.Product.Name, - } - if e.Product.Sku != nil { - product.Sku = *e.Product.Sku - } - if len(e.Product.Flags) > 0 { - for _, f := range e.Product.Flags { - product.Flags = append(product.Flags, f.Name) - } - } + product := productDTO.ToProductBaseDTO(e.Product) dto.Product = &product } @@ -120,7 +129,7 @@ func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDT } } - if &e.Warehouse.Area != nil && e.Warehouse.Area.Id != 0 { + if e.Warehouse.Area.Id != 0 { warehouse.Area = &AreaBaseDTO{ Id: e.Warehouse.Area.Id, Name: e.Warehouse.Area.Name, diff --git a/internal/modules/inventory/product-warehouses/module.go b/internal/modules/inventory/product-warehouses/module.go index 378522c5..0dd8047e 100644 --- a/internal/modules/inventory/product-warehouses/module.go +++ b/internal/modules/inventory/product-warehouses/module.go @@ -7,6 +7,7 @@ import ( rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" sProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/services" + rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -17,8 +18,9 @@ type ProductWarehouseModule struct{} func (ProductWarehouseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) userRepo := rUser.NewUserRepository(db) + kandangRepo := rKandang.NewKandangRepository(db) - productWarehouseService := sProductWarehouse.NewProductWarehouseService(productWarehouseRepo, validate) + productWarehouseService := sProductWarehouse.NewProductWarehouseService(productWarehouseRepo, validate, kandangRepo) userService := sUser.NewUserService(userRepo, validate) 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 23cabb68..80c551eb 100644 --- a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go +++ b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go @@ -19,8 +19,12 @@ type ProductWarehouseRepository interface { 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) + GetByFlagAndWarehouseID(ctx context.Context, flagName string, warehouseId uint) ([]entity.ProductWarehouse, error) + GetFirstProductByFlag(ctx context.Context, flagName string) (*entity.Product, error) ApplyFlagsFilter(db *gorm.DB, flags []string) *gorm.DB AdjustQuantities(ctx context.Context, deltas map[uint]float64, modifier func(*gorm.DB) *gorm.DB) error + GetDetailByID(ctx context.Context, id uint) (*entity.ProductWarehouse, error) + IdExists(ctx context.Context, id uint) (bool, error) } type ProductWarehouseRepositoryImpl struct { @@ -44,6 +48,10 @@ func (r *ProductWarehouseRepositoryImpl) ExistsByID(ctx context.Context, id uint return repository.Exists[entity.ProductWarehouse](ctx, r.DB(), id) } +func (r *ProductWarehouseRepositoryImpl) IdExists(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{}). @@ -78,14 +86,14 @@ func (r *ProductWarehouseRepositoryImpl) GetProductWarehouseByProductAndWarehous 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.*"). + q := r.DB().WithContext(ctx).Model(&entity.ProductWarehouse{}). 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 + Order("product_warehouses.created_at DESC") + + // preload relations so nested Product and Warehouse are populated + err := q.Preload("Product").Preload("Warehouse").Find(&productWarehouses).Error if err != nil { return nil, err } @@ -100,12 +108,12 @@ func (r *ProductWarehouseRepositoryImpl) GetLatestByCategoryCodeAndWarehouseID(c } fmt.Println(warehouseId) err := query.WithContext(ctx). - Table("product_warehouses"). - Select("product_warehouses.*"). + Model(&entity.ProductWarehouse{}). 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"). + Preload("Product").Preload("Warehouse"). First(&productWarehouse).Error if err != nil { return nil, err @@ -146,3 +154,56 @@ func (r *ProductWarehouseRepositoryImpl) AdjustQuantities(ctx context.Context, d } return nil } + +func (r *ProductWarehouseRepositoryImpl) GetDetailByID(ctx context.Context, id uint) (*entity.ProductWarehouse, error) { + var productWarehouse entity.ProductWarehouse + err := r.DB().WithContext(ctx). + Preload("Product"). + Preload("Warehouse"). + Preload("Warehouse.Area"). + Preload("Warehouse.Location"). + First(&productWarehouse, id).Error + if err != nil { + return nil, err + } + return &productWarehouse, nil +} + +func (r *ProductWarehouseRepositoryImpl) GetFirstProductByCategoryCode(ctx context.Context, categoryCode string) (*entity.Product, error) { + var product entity.Product + err := r.DB().WithContext(ctx). + Joins("JOIN product_categories ON product_categories.id = products.product_category_id"). + Where("product_categories.code = ?", categoryCode). + First(&product).Error + if err != nil { + return nil, err + } + return &product, nil +} + +func (r *ProductWarehouseRepositoryImpl) GetByFlagAndWarehouseID(ctx context.Context, flagName string, warehouseId uint) ([]entity.ProductWarehouse, error) { + var productWarehouses []entity.ProductWarehouse + err := r.DB().WithContext(ctx).Model(&entity.ProductWarehouse{}). + 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 = ? AND product_warehouses.warehouse_id = ?", flagName, warehouseId). + Order("product_warehouses.created_at DESC"). + Preload("Product").Preload("Warehouse"). + Find(&productWarehouses).Error + if err != nil { + return nil, err + } + return productWarehouses, nil +} + +func (r *ProductWarehouseRepositoryImpl) GetFirstProductByFlag(ctx context.Context, flagName string) (*entity.Product, error) { + var product entity.Product + err := r.DB().WithContext(ctx). + Joins("JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = 'products'"). + Where("flags.name = ?", flagName). + First(&product).Error + if err != nil { + return nil, err + } + return &product, nil +} 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 cc925970..cc7d5b85 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -6,6 +6,7 @@ import ( entity "gitlab.com/mbugroup/lti-api.git/internal/entities" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/validations" + kandangrepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" "github.com/go-playground/validator/v10" @@ -20,16 +21,18 @@ type ProductWarehouseService interface { } type productWarehouseService struct { - Log *logrus.Logger - Validate *validator.Validate - Repository repository.ProductWarehouseRepository + Log *logrus.Logger + Validate *validator.Validate + Repository repository.ProductWarehouseRepository + KandangRepo kandangrepo.KandangRepository } -func NewProductWarehouseService(repo repository.ProductWarehouseRepository, validate *validator.Validate) ProductWarehouseService { +func NewProductWarehouseService(repo repository.ProductWarehouseRepository, validate *validator.Validate, kandangRepo kandangrepo.KandangRepository) ProductWarehouseService { return &productWarehouseService{ - Log: utils.Log, - Validate: validate, - Repository: repo, + Log: utils.Log, + Validate: validate, + Repository: repo, + KandangRepo: kandangRepo, } } @@ -69,6 +72,16 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) } } + if params.KandangId > 0 { + isKandangExist, err := s.KandangRepo.IdExists(c.Context(), params.KandangId) + if err != nil { + return nil, 0, err + } + if !isKandangExist { + return nil, 0, fiber.NewError(fiber.StatusNotFound, "Kandang not found") + } + } + offset := (params.Page - 1) * params.Limit cleanFlags := utils.ParseFlags(params.Flags) @@ -80,6 +93,11 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) db = db.Where("product_id = ?", params.ProductId) } + if params.KandangId != 0 { + db = db.Joins("JOIN warehouses ON product_warehouses.warehouse_id = warehouses.id"). + Where("warehouses.kandang_id = ?", params.KandangId) + } + if params.WarehouseId != 0 { db = db.Where("warehouse_id = ?", params.WarehouseId) } 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 3a3acb28..322d0a00 100644 --- a/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go +++ b/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go @@ -18,4 +18,5 @@ type Query struct { ProductId uint `query:"product_id" validate:"omitempty,number,min=1"` WarehouseId uint `query:"warehouse_id" validate:"omitempty,number,min=1"` Flags string `query:"flags" validate:"omitempty"` + KandangId uint `query:"kandang_id" validate:"omitempty,number,min=1"` } diff --git a/internal/modules/marketing/delivery-orderss/controllers/delivery-orders.controller.go b/internal/modules/marketing/delivery-orderss/controllers/delivery-orders.controller.go new file mode 100644 index 00000000..292381d0 --- /dev/null +++ b/internal/modules/marketing/delivery-orderss/controllers/delivery-orders.controller.go @@ -0,0 +1,124 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type DeliveryOrdersController struct { + DeliveryOrdersService service.DeliveryOrdersService +} + +func NewDeliveryOrdersController(deliveryOrdersService service.DeliveryOrdersService) *DeliveryOrdersController { + return &DeliveryOrdersController{ + DeliveryOrdersService: deliveryOrdersService, + } +} + +func (u *DeliveryOrdersController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + MarketingId: uint(c.QueryInt("marketing_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.DeliveryOrdersService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.MarketingListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all deliveryOrderss successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: result, + }) +} + +func (u *DeliveryOrdersController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := u.DeliveryOrdersService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get deliveryOrders successfully", + Data: *result, + }) +} + +func (u *DeliveryOrdersController) CreateOne(c *fiber.Ctx) error { + req := new(validation.Create) + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.DeliveryOrdersService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create delivery products successfully", + Data: result, + }) +} + +func (u *DeliveryOrdersController) UpdateOne(c *fiber.Ctx) error { + req := new(validation.Update) + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.DeliveryOrdersService.UpdateOne(c, req, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Update deliveryOrders successfully", + Data: result, + }) +} diff --git a/internal/modules/marketing/delivery-orderss/dto/delivery-orders.dto.go b/internal/modules/marketing/delivery-orderss/dto/delivery-orders.dto.go new file mode 100644 index 00000000..397bf0ef --- /dev/null +++ b/internal/modules/marketing/delivery-orderss/dto/delivery-orders.dto.go @@ -0,0 +1,336 @@ +package dto + +import ( + "fmt" + "sort" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" + productwarehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/dto" + customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" +) + +type MarketingBaseDTO struct { + Id uint `json:"id"` + SoNumber string `json:"so_number"` + SoDate time.Time `json:"so_date"` + Notes string `json:"notes,omitempty"` +} + +type MarketingListDTO struct { + MarketingBaseDTO + Customer *customerDTO.CustomerBaseDTO `json:"customer,omitempty"` + SalesPerson *userDTO.UserBaseDTO `json:"sales_person,omitempty"` + SoDocs string `json:"so_docs,omitempty"` + SalesOrder []MarketingProductDTO `json:"sales_order,omitempty"` + CreatedUser *userDTO.UserBaseDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + LatestApproval *approvalDTO.ApprovalBaseDTO `json:"latest_approval,omitempty"` +} + +type MarketingDetailDTO struct { + MarketingBaseDTO + Customer *customerDTO.CustomerBaseDTO `json:"customer,omitempty"` + SalesPerson *userDTO.UserBaseDTO `json:"sales_person,omitempty"` + SoDocs string `json:"so_docs,omitempty"` + SalesOrder []MarketingProductDTO `json:"sales_order,omitempty"` + DeliveryOrder []DeliveryGroupDTO `json:"delivery_order,omitempty"` + CreatedUser *userDTO.UserBaseDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + LatestApproval *approvalDTO.ApprovalBaseDTO `json:"latest_approval,omitempty"` +} +type MarketingDeliveryProductDTO struct { + Id uint `json:"id"` + MarketingProductId uint `json:"marketing_product_id"` + Qty float64 `json:"qty"` + UnitPrice float64 `json:"unit_price"` + TotalWeight float64 `json:"total_weight"` + AvgWeight float64 `json:"avg_weight"` + TotalPrice float64 `json:"total_price"` + DeliveryDate *time.Time `json:"delivery_date"` + VehicleNumber string `json:"vehicle_number"` + ProductWarehouse *productwarehouseDTO.ProductWarehousNestedDTO `json:"product_warehouse,omitempty"` +} + +type DeliveryItemDTO struct { + ProductWarehouse *productwarehouseDTO.ProductWarehousNestedDTO `json:"product_warehouse"` + Qty float64 `json:"qty"` + UnitPrice float64 `json:"unit_price"` + TotalWeight float64 `json:"total_weight"` + AvgWeight float64 `json:"avg_weight"` + TotalPrice float64 `json:"total_price"` + VehicleNumber string `json:"vehicle_number"` +} + +type DeliveryGroupDTO struct { + DoNumber string `json:"do_number"` + DeliveryDate *time.Time `json:"delivery_date"` + Warehouse *productwarehouseDTO.WarehouseBaseDTO `json:"warehouse,omitempty"` + Deliveries []DeliveryItemDTO `json:"deliveries"` +} + +type MarketingProductDTO struct { + Id uint `json:"id"` + MarketingId uint `json:"marketing_id"` + ProductWarehouseId uint `json:"product_warehouse_id"` + Qty float64 `json:"qty"` + UnitPrice float64 `json:"unit_price"` + AvgWeight float64 `json:"avg_weight"` + TotalWeight float64 `json:"total_weight"` + TotalPrice float64 `json:"total_price"` + ProductWarehouse *productwarehouseDTO.ProductWarehousNestedDTO `json:"product_warehouse,omitempty"` + VehicleNumber string `json:"vehicle_number,omitempty"` +} + +func ToMarketingBaseDTO(marketing *entity.Marketing) MarketingBaseDTO { + return MarketingBaseDTO{ + Id: marketing.Id, + SoNumber: marketing.SoNumber, + SoDate: marketing.SoDate, + Notes: marketing.Notes, + } +} + +func ToMarketingProductDTO(e entity.MarketingProduct) MarketingProductDTO { + var productWarehouse *productwarehouseDTO.ProductWarehousNestedDTO + if e.ProductWarehouse.Id != 0 { + mapped := productwarehouseDTO.ToProductWarehouseNestedDTO(e.ProductWarehouse) + productWarehouse = &mapped + } + + return MarketingProductDTO{ + Id: e.Id, + MarketingId: e.MarketingId, + ProductWarehouseId: e.ProductWarehouseId, + Qty: e.Qty, + UnitPrice: e.UnitPrice, + AvgWeight: e.AvgWeight, + TotalWeight: e.TotalWeight, + TotalPrice: e.TotalPrice, + ProductWarehouse: productWarehouse, + VehicleNumber: getVehicleNumber(e), + } +} + +func ToMarketingDeliveryProductDTO(e entity.MarketingDeliveryProduct) MarketingDeliveryProductDTO { + return MarketingDeliveryProductDTO{ + Id: e.Id, + MarketingProductId: e.MarketingProductId, + Qty: e.Qty, + UnitPrice: e.UnitPrice, + TotalWeight: e.TotalWeight, + AvgWeight: e.AvgWeight, + TotalPrice: e.TotalPrice, + DeliveryDate: e.DeliveryDate, + VehicleNumber: e.VehicleNumber, + } +} + +func ToMarketingListDTO(marketing *entity.Marketing, deliveryProducts []entity.MarketingDeliveryProduct) MarketingListDTO { + var createdUser *userDTO.UserBaseDTO + if marketing.CreatedUser.Id != 0 { + mapped := userDTO.ToUserBaseDTO(marketing.CreatedUser) + createdUser = &mapped + } + + var customer *customerDTO.CustomerBaseDTO + if marketing.Customer.Id != 0 { + mapped := customerDTO.ToCustomerBaseDTO(marketing.Customer) + customer = &mapped + } + + var salesPerson *userDTO.UserBaseDTO + if marketing.SalesPerson.Id != 0 { + mapped := userDTO.ToUserBaseDTO(marketing.SalesPerson) + salesPerson = &mapped + } + + var latestApproval *approvalDTO.ApprovalBaseDTO + if marketing.LatestApproval != nil { + mapped := approvalDTO.ToApprovalDTO(*marketing.LatestApproval) + latestApproval = &mapped + } + + var salesOrderProducts []MarketingProductDTO + if len(marketing.Products) > 0 { + salesOrderProducts = make([]MarketingProductDTO, len(marketing.Products)) + for i, product := range marketing.Products { + salesOrderProducts[i] = ToMarketingProductDTO(product) + } + } + + return MarketingListDTO{ + MarketingBaseDTO: ToMarketingBaseDTO(marketing), + Customer: customer, + SalesPerson: salesPerson, + SoDocs: marketing.SoDocs, + SalesOrder: salesOrderProducts, + CreatedUser: createdUser, + CreatedAt: marketing.CreatedAt, + UpdatedAt: marketing.UpdatedAt, + LatestApproval: latestApproval, + } +} + +func ToMarketingDetailDTO(marketing *entity.Marketing, deliveryProducts []entity.MarketingDeliveryProduct) MarketingDetailDTO { + var createdUser *userDTO.UserBaseDTO + if marketing.CreatedUser.Id != 0 { + mapped := userDTO.ToUserBaseDTO(marketing.CreatedUser) + createdUser = &mapped + } + + var customer *customerDTO.CustomerBaseDTO + if marketing.Customer.Id != 0 { + mapped := customerDTO.ToCustomerBaseDTO(marketing.Customer) + customer = &mapped + } + + var salesPerson *userDTO.UserBaseDTO + if marketing.SalesPerson.Id != 0 { + mapped := userDTO.ToUserBaseDTO(marketing.SalesPerson) + salesPerson = &mapped + } + + var salesOrderProducts []MarketingProductDTO + if len(marketing.Products) > 0 { + salesOrderProducts = make([]MarketingProductDTO, len(marketing.Products)) + for i, product := range marketing.Products { + salesOrderProducts[i] = ToMarketingProductDTO(product) + } + } + + var deliveryProductsDTOs []MarketingDeliveryProductDTO + if len(deliveryProducts) > 0 { + deliveryProductsDTOs = make([]MarketingDeliveryProductDTO, len(deliveryProducts)) + for i, dp := range deliveryProducts { + deliveryProductsDTOs[i] = ToMarketingDeliveryProductDTO(dp) + } + deliveryProductsDTOs = enrichDeliveryProductDTOsWithWarehouse(deliveryProductsDTOs, marketing) + } + + deliveryGroups := groupDeliveryProducts(deliveryProductsDTOs, marketing.SoNumber) + + var latestApproval *approvalDTO.ApprovalBaseDTO + if marketing.LatestApproval != nil { + mapped := approvalDTO.ToApprovalDTO(*marketing.LatestApproval) + latestApproval = &mapped + } + + return MarketingDetailDTO{ + MarketingBaseDTO: ToMarketingBaseDTO(marketing), + SoDocs: marketing.SoDocs, + Customer: customer, + SalesPerson: salesPerson, + SalesOrder: salesOrderProducts, + DeliveryOrder: deliveryGroups, + CreatedUser: createdUser, + CreatedAt: marketing.CreatedAt, + UpdatedAt: marketing.UpdatedAt, + LatestApproval: latestApproval, + } +} + +func ToMarketingListDTOs(marketings []entity.Marketing) []MarketingListDTO { + result := make([]MarketingListDTO, len(marketings)) + for i, m := range marketings { + result[i] = ToMarketingListDTO(&m, []entity.MarketingDeliveryProduct{}) + } + return result +} + +func enrichDeliveryProductDTOsWithWarehouse(deliveryProductDTOs []MarketingDeliveryProductDTO, marketing *entity.Marketing) []MarketingDeliveryProductDTO { + if len(deliveryProductDTOs) == 0 || marketing == nil || len(marketing.Products) == 0 { + return deliveryProductDTOs + } + + productMap := make(map[uint]*entity.MarketingProduct) + for i := range marketing.Products { + productMap[marketing.Products[i].Id] = &marketing.Products[i] + } + + for i := range deliveryProductDTOs { + if product, exists := productMap[deliveryProductDTOs[i].MarketingProductId]; exists { + if product.ProductWarehouse.Id != 0 { + mapped := productwarehouseDTO.ToProductWarehouseNestedDTO(product.ProductWarehouse) + deliveryProductDTOs[i].ProductWarehouse = &mapped + } + } + } + + return deliveryProductDTOs +} + +func groupDeliveryProducts(products []MarketingDeliveryProductDTO, soNumber string) []DeliveryGroupDTO { + groupMap := make(map[string]*DeliveryGroupDTO) + + for _, product := range products { + if product.DeliveryDate == nil { + continue + } + + var warehouseId uint + var warehouseName string + if product.ProductWarehouse != nil { + warehouseId = product.ProductWarehouse.Warehouse.Id + warehouseName = product.ProductWarehouse.Warehouse.Name + } + + key := fmt.Sprintf("%d_%s", warehouseId, product.DeliveryDate.Format("2006-01-02")) + + group, exists := groupMap[key] + if !exists { + group = &DeliveryGroupDTO{ + DeliveryDate: product.DeliveryDate, + Warehouse: &productwarehouseDTO.WarehouseBaseDTO{ + Id: warehouseId, + Name: warehouseName, + }, + Deliveries: []DeliveryItemDTO{}, + } + groupMap[key] = group + } + + deliveryItem := DeliveryItemDTO{ + ProductWarehouse: product.ProductWarehouse, + Qty: product.Qty, + UnitPrice: product.UnitPrice, + TotalWeight: product.TotalWeight, + AvgWeight: product.AvgWeight, + TotalPrice: product.TotalPrice, + VehicleNumber: product.VehicleNumber, + } + group.Deliveries = append(group.Deliveries, deliveryItem) + } + + var groups []DeliveryGroupDTO + for _, group := range groupMap { + groups = append(groups, *group) + } + + sort.Slice(groups, func(i, j int) bool { + if groups[i].DeliveryDate == nil || groups[j].DeliveryDate == nil { + return false + } + return groups[i].DeliveryDate.Before(*groups[j].DeliveryDate) + }) + + for i := range groups { + if groups[i].DeliveryDate != nil { + dateStr := groups[i].DeliveryDate.Format("20060102") + groups[i].DoNumber = fmt.Sprintf("%s-%s-%d", soNumber, dateStr, groups[i].Warehouse.Id) + } + } + + return groups +} + +func getVehicleNumber(e entity.MarketingProduct) string { + if e.DeliveryProduct != nil && e.DeliveryProduct.VehicleNumber != "" { + return e.DeliveryProduct.VehicleNumber + } + return "" +} diff --git a/internal/modules/marketing/delivery-orderss/module.go b/internal/modules/marketing/delivery-orderss/module.go new file mode 100644 index 00000000..99bd8396 --- /dev/null +++ b/internal/modules/marketing/delivery-orderss/module.go @@ -0,0 +1,39 @@ +package delivery_orderss + +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" + rMarketingDeliveryProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/marketing-delivery-products/repositories" + sDeliveryOrders "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/services" + rMarketing "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/repositories" + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +type DeliveryOrdersModule struct{} + +func (DeliveryOrdersModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + marketingRepo := rMarketing.NewMarketingRepository(db) + marketingProductRepo := rMarketing.NewMarketingProductRepository(db) + marketingDeliveryProductRepo := rMarketingDeliveryProduct.NewMarketingDeliveryProductRepository(db) + userRepo := rUser.NewUserRepository(db) + approvalRepo := commonRepo.NewApprovalRepository(db) + approvalSvc := commonSvc.NewApprovalService(approvalRepo) + + // Register workflow steps for MARKETINGS approval + if err := approvalSvc.RegisterWorkflowSteps(utils.ApprovalWorkflowMarketing, utils.MarketingApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register marketing approval workflow: %v", err)) + } + + deliveryOrdersService := sDeliveryOrders.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, approvalSvc, validate) + userService := sUser.NewUserService(userRepo, validate) + + DeliveryOrdersRoutes(router, userService, deliveryOrdersService) +} diff --git a/internal/modules/marketing/delivery-orderss/repositories/delivery-orders.repository.go b/internal/modules/marketing/delivery-orderss/repositories/delivery-orders.repository.go new file mode 100644 index 00000000..0a7d7f3d --- /dev/null +++ b/internal/modules/marketing/delivery-orderss/repositories/delivery-orders.repository.go @@ -0,0 +1,21 @@ +package repository + +import ( + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + "gorm.io/gorm" +) + +type DeliveryOrdersRepository interface { + repository.BaseRepository[entity.DeliveryOrders] +} + +type DeliveryOrdersRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.DeliveryOrders] +} + +func NewDeliveryOrdersRepository(db *gorm.DB) DeliveryOrdersRepository { + return &DeliveryOrdersRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.DeliveryOrders](db), + } +} diff --git a/internal/modules/marketing/delivery-orderss/route.go b/internal/modules/marketing/delivery-orderss/route.go new file mode 100644 index 00000000..09e48f29 --- /dev/null +++ b/internal/modules/marketing/delivery-orderss/route.go @@ -0,0 +1,30 @@ +package delivery_orderss + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/controllers" + deliveryOrders "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func DeliveryOrdersRoutes(v1 fiber.Router, u user.UserService, s deliveryOrders.DeliveryOrdersService) { + ctrl := controller.NewDeliveryOrdersController(s) + + v1.Get("/", ctrl.GetAll) + v1.Get("/:id", ctrl.GetOne) + + // Sisanya di group /delivery-orders + route := v1.Group("/delivery-orders") + + // 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.Post("/", ctrl.CreateOne) + route.Patch("/:id", ctrl.UpdateOne) + +} diff --git a/internal/modules/marketing/delivery-orderss/services/delivery-orders.service.go b/internal/modules/marketing/delivery-orderss/services/delivery-orders.service.go new file mode 100644 index 00000000..712c6ace --- /dev/null +++ b/internal/modules/marketing/delivery-orderss/services/delivery-orders.service.go @@ -0,0 +1,427 @@ +package service + +import ( + "context" + "errors" + "fmt" + "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" + marketingDeliveryProductRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/marketing-delivery-products/repositories" + productWarehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/dto" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/validations" + marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type DeliveryOrdersService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]dto.MarketingListDTO, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*dto.MarketingDetailDTO, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*dto.MarketingDetailDTO, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*dto.MarketingDetailDTO, error) +} + +type deliveryOrdersService struct { + Log *logrus.Logger + Validate *validator.Validate + MarketingRepo marketingRepo.MarketingRepository + MarketingProductRepo marketingRepo.MarketingProductRepository + MarketingDeliveryProductRepo marketingDeliveryProductRepo.MarketingDeliveryProductRepository + ApprovalSvc commonSvc.ApprovalService +} + +func NewDeliveryOrdersService( + marketingRepo marketingRepo.MarketingRepository, + marketingProductRepo marketingRepo.MarketingProductRepository, + marketingDeliveryProductRepo marketingDeliveryProductRepo.MarketingDeliveryProductRepository, + approvalSvc commonSvc.ApprovalService, + validate *validator.Validate, +) DeliveryOrdersService { + return &deliveryOrdersService{ + Log: utils.Log, + Validate: validate, + MarketingRepo: marketingRepo, + MarketingProductRepo: marketingProductRepo, + MarketingDeliveryProductRepo: marketingDeliveryProductRepo, + ApprovalSvc: approvalSvc, + } +} + +func (s deliveryOrdersService) withRelations(db *gorm.DB) *gorm.DB { + return db. + Preload("CreatedUser"). + Preload("Customer"). + Preload("SalesPerson"). + Preload("Products.ProductWarehouse.Product"). + Preload("Products.ProductWarehouse.Warehouse"). + Preload("Products.DeliveryProduct") +} + +func (s deliveryOrdersService) getMarketingWithDeliveries(c *fiber.Ctx, marketingId uint) (*dto.MarketingDetailDTO, error) { + marketing, err := s.MarketingRepo.GetByID(c.Context(), marketingId, s.withRelations) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch marketing") + } + + latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowMarketing, marketingId, nil) + if err != nil { + } + marketing.LatestApproval = latestApproval + + allDeliveryProducts, err := s.MarketingDeliveryProductRepo.GetByMarketingId(c.Context(), marketingId) + if err != nil { + allDeliveryProducts = []entity.MarketingDeliveryProduct{} + } + + responseDTO := dto.ToMarketingDetailDTO(marketing, allDeliveryProducts) + return &responseDTO, nil +} + +func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.Query) ([]dto.MarketingListDTO, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + marketings, total, err := s.MarketingRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = db. + Preload("CreatedUser"). + Preload("Customer"). + Preload("SalesPerson"). + Preload("Products.ProductWarehouse.Product"). + Preload("Products.ProductWarehouse.Warehouse"). + Preload("Products.DeliveryProduct") + + if params.MarketingId != 0 { + return db.Where("id = ?", params.MarketingId) + } + return db.Order("created_at DESC").Order("updated_at DESC") + }) + + if err != nil { + s.Log.Errorf("Failed to get marketings: %+v", err) + return nil, 0, err + } + for i := range marketings { + latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowMarketing, marketings[i].Id, func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + }) + if err != nil { + s.Log.Warnf("Failed to load approval for marketing %d: %+v", marketings[i].Id, err) + } + marketings[i].LatestApproval = latestApproval + } + + result := make([]dto.MarketingListDTO, len(marketings)) + for i, marketing := range marketings { + result[i] = dto.ToMarketingListDTO(&marketing, []entity.MarketingDeliveryProduct{}) + } + + return result, total, nil +} + +func (s deliveryOrdersService) GetOne(c *fiber.Ctx, id uint) (*dto.MarketingDetailDTO, error) { + + marketing, err := s.MarketingRepo.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Marketing not found") + } + if err != nil { + return nil, err + } + + allDeliveryProducts, err := s.MarketingDeliveryProductRepo.GetByMarketingId(c.Context(), id) + if err != nil { + allDeliveryProducts = []entity.MarketingDeliveryProduct{} + } + + if s.ApprovalSvc != nil { + approvals, err := s.ApprovalSvc.ListByTarget(c.Context(), utils.ApprovalWorkflowMarketing, marketing.Id, func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + }) + if err != nil { + + } else if len(approvals) > 0 { + if marketing.LatestApproval == nil { + latest := approvals[len(approvals)-1] + marketing.LatestApproval = &latest + } + } else { + marketing.LatestApproval = nil + } + } + + responseDTO := dto.ToMarketingDetailDTO(marketing, allDeliveryProducts) + return &responseDTO, nil +} + +func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) (*dto.MarketingDetailDTO, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + if err := commonSvc.EnsureRelations(c.Context(), + commonSvc.RelationCheck{Name: "Marketing", ID: &req.MarketingId, Exists: s.MarketingRepo.IdExists}, + ); err != nil { + return nil, err + } + + approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(s.MarketingRepo.DB())) + + latestApproval, err := approvalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowMarketing, req.MarketingId, nil) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check approval status") + } + if latestApproval == nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Marketing has not been submitted for approval") + } + if latestApproval.StepNumber < uint16(utils.MarketingStepSalesOrder) { + return nil, fiber.NewError(fiber.StatusBadRequest, "Marketing must be approved to Sales Order step before creating delivery order") + } + if latestApproval.StepNumber >= uint16(utils.MarketingDeliveryOrder) { + return nil, fiber.NewError(fiber.StatusBadRequest, "Delivery order already exists for this marketing") + } + if latestApproval.Action == nil || *latestApproval.Action != entity.ApprovalActionApproved { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Marketing is not approved - current status: %v", *latestApproval.Action)) + } + + err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + + marketingProductRepositoryTx := marketingRepo.NewMarketingProductRepository(dbTransaction) + marketingDeliveryProductRepositoryTx := marketingDeliveryProductRepo.NewMarketingDeliveryProductRepository(dbTransaction) + approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) + + allMarketingProducts, err := marketingProductRepositoryTx.GetByMarketingID(c.Context(), req.MarketingId) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("No marketing products found for marketing %d", req.MarketingId)) + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch marketing products") + } + + for _, requestedProduct := range req.DeliveryProducts { + var foundMarketingProduct *entity.MarketingProduct + for i := range allMarketingProducts { + if allMarketingProducts[i].Id == requestedProduct.MarketingProductId { + foundMarketingProduct = &allMarketingProducts[i] + break + } + } + if foundMarketingProduct == nil { + return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Marketing product %d not found for this marketing", requestedProduct.MarketingProductId)) + } + + deliveryProduct, err := marketingDeliveryProductRepositoryTx.GetByMarketingProductID(c.Context(), foundMarketingProduct.Id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Delivery product for marketing product %d not found", requestedProduct.MarketingProductId)) + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch delivery product") + } + + var itemDeliveryDate *time.Time + if requestedProduct.DeliveryDate != "" { + parsedDate, err := utils.ParseDateString(requestedProduct.DeliveryDate) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid delivery date format for product %d: %v", requestedProduct.MarketingProductId, err)) + } + itemDeliveryDate = &parsedDate + } + + deliveryProduct.Qty = requestedProduct.Qty + deliveryProduct.UnitPrice = requestedProduct.UnitPrice + deliveryProduct.AvgWeight = requestedProduct.AvgWeight + deliveryProduct.TotalWeight = requestedProduct.TotalWeight + deliveryProduct.TotalPrice = requestedProduct.TotalPrice + deliveryProduct.DeliveryDate = itemDeliveryDate + deliveryProduct.VehicleNumber = requestedProduct.VehicleNumber + + if requestedProduct.Qty > 0 { + if err := s.validateAndReduceProductWarehouse(c.Context(), dbTransaction, foundMarketingProduct, requestedProduct.Qty); err != nil { + return err + } + } + if err := marketingDeliveryProductRepositoryTx.UpdateOne(c.Context(), deliveryProduct.Id, deliveryProduct, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product") + } + + } + + actorID := uint(1) // TODO: ambil dari auth context + approvalAction := entity.ApprovalActionApproved + if _, err := approvalSvcTx.CreateApproval( + c.Context(), + utils.ApprovalWorkflowMarketing, + req.MarketingId, + utils.MarketingDeliveryOrder, + &approvalAction, + actorID, + nil); err != nil { + if !errors.Is(err, gorm.ErrDuplicatedKey) { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create delivery order approval") + } + } + + return nil + }) + + if err != nil { + if fiberErr, ok := err.(*fiber.Error); ok { + return nil, fiberErr + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create delivery order") + } + + return s.getMarketingWithDeliveries(c, req.MarketingId) +} + +func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*dto.MarketingDetailDTO, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + if err := commonSvc.EnsureRelations(c.Context(), + commonSvc.RelationCheck{Name: "Marketing", ID: &id, Exists: s.MarketingRepo.IdExists}, + ); err != nil { + return nil, err + } + + err := s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + + marketingProductRepositoryTx := marketingRepo.NewMarketingProductRepository(dbTransaction) + marketingDeliveryProductRepositoryTx := marketingDeliveryProductRepo.NewMarketingDeliveryProductRepository(dbTransaction) + + allMarketingProducts, err := marketingProductRepositoryTx.GetByMarketingID(c.Context(), id) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch marketing products") + } + + if len(req.DeliveryProducts) > 0 { + for _, requestedProduct := range req.DeliveryProducts { + + var foundMarketingProduct *entity.MarketingProduct + for i := range allMarketingProducts { + if allMarketingProducts[i].Id == requestedProduct.MarketingProductId { + foundMarketingProduct = &allMarketingProducts[i] + break + } + } + if foundMarketingProduct == nil { + return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Marketing product %d not found for this marketing", requestedProduct.MarketingProductId)) + } + + deliveryProduct, err := marketingDeliveryProductRepositoryTx.GetByMarketingProductID(c.Context(), foundMarketingProduct.Id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Delivery product for marketing product %d not found", requestedProduct.MarketingProductId)) + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch delivery product") + } + + var itemDeliveryDate *time.Time + if requestedProduct.DeliveryDate != "" { + parsedDate, err := utils.ParseDateString(requestedProduct.DeliveryDate) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid delivery date format for product %d: %v", requestedProduct.MarketingProductId, err)) + } + itemDeliveryDate = &parsedDate + } else if deliveryProduct.DeliveryDate != nil { + itemDeliveryDate = deliveryProduct.DeliveryDate + } + + oldQty := deliveryProduct.Qty + deliveryProduct.Qty = requestedProduct.Qty + deliveryProduct.UnitPrice = requestedProduct.UnitPrice + deliveryProduct.AvgWeight = requestedProduct.AvgWeight + deliveryProduct.TotalWeight = requestedProduct.TotalWeight + deliveryProduct.TotalPrice = requestedProduct.TotalPrice + deliveryProduct.DeliveryDate = itemDeliveryDate + deliveryProduct.VehicleNumber = requestedProduct.VehicleNumber + + qtyChange := requestedProduct.Qty - oldQty + if qtyChange > 0 { + if err := s.validateAndReduceProductWarehouse(c.Context(), dbTransaction, foundMarketingProduct, qtyChange); err != nil { + return err + } + } else if qtyChange < 0 { + if err := s.restoreProductWarehouseStock(c.Context(), dbTransaction, foundMarketingProduct, -qtyChange); err != nil { + return err + } + } + + if err := marketingDeliveryProductRepositoryTx.UpdateOne(c.Context(), deliveryProduct.Id, deliveryProduct, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product") + } + } + } + + return nil + }) + + if err != nil { + if fiberErr, ok := err.(*fiber.Error); ok { + return nil, fiberErr + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery order") + } + + return s.getMarketingWithDeliveries(c, id) +} + +func (s deliveryOrdersService) validateAndReduceProductWarehouse(ctx context.Context, tx *gorm.DB, marketingProduct *entity.MarketingProduct, qtyDeliver float64) error { + if marketingProduct == nil || marketingProduct.ProductWarehouseId == 0 { + return fiber.NewError(fiber.StatusInternalServerError, "Product warehouse not found") + } + + pwRepo := productWarehouseRepo.NewProductWarehouseRepository(tx) + + pw, err := pwRepo.GetByID(ctx, marketingProduct.ProductWarehouseId, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Product warehouse not found") + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to check stock") + } + + if pw.Quantity < qtyDeliver { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock for warehouse - available: %.2f, requested: %.2f", pw.Quantity, qtyDeliver)) + } + + pw.Quantity = pw.Quantity - qtyDeliver + if err := pwRepo.UpdateOne(ctx, pw.Id, pw, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update stock") + } + return nil +} + +func (s deliveryOrdersService) restoreProductWarehouseStock(ctx context.Context, tx *gorm.DB, marketingProduct *entity.MarketingProduct, qtyRestore float64) error { + if marketingProduct == nil || marketingProduct.ProductWarehouseId == 0 { + return fiber.NewError(fiber.StatusInternalServerError, "Product warehouse not found") + } + + pwRepo := productWarehouseRepo.NewProductWarehouseRepository(tx) + pw, err := pwRepo.GetByID(ctx, marketingProduct.ProductWarehouseId, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Product warehouse not found") + } + + return fiber.NewError(fiber.StatusInternalServerError, "Failed to check stock") + } + + pw.Quantity = pw.Quantity + qtyRestore + if err := pwRepo.UpdateOne(ctx, pw.Id, pw, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update stock") + } + + return nil +} diff --git a/internal/modules/marketing/delivery-orderss/validations/delivery-orders.validation.go b/internal/modules/marketing/delivery-orderss/validations/delivery-orders.validation.go new file mode 100644 index 00000000..3317e952 --- /dev/null +++ b/internal/modules/marketing/delivery-orderss/validations/delivery-orders.validation.go @@ -0,0 +1,33 @@ +package validation + +type DeliveryProduct struct { + MarketingProductId uint `json:"marketing_product_id" validate:"required,gt=0"` + Qty float64 `json:"qty" validate:"omitempty,gte=0"` + UnitPrice float64 `json:"unit_price" validate:"omitempty,gte=0"` + AvgWeight float64 `json:"avg_weight" validate:"omitempty,gte=0"` + TotalWeight float64 `json:"total_weight" validate:"omitempty,gte=0"` + TotalPrice float64 `json:"total_price" validate:"omitempty,gte=0"` + DeliveryDate string `json:"delivery_date" validate:"omitempty,datetime=2006-01-02"` + VehicleNumber string `json:"vehicle_number" validate:"omitempty,max=50"` +} + +type Create struct { + MarketingId uint `json:"marketing_id" validate:"required,gt=0"` + DeliveryProducts []DeliveryProduct `json:"delivery_products" validate:"required,min=1,dive"` +} + +type Update struct { + DeliveryProducts []DeliveryProduct `json:"delivery_products" validate:"omitempty,min=1,dive"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + MarketingId uint `query:"marketing_id" validate:"omitempty,gt=0"` +} + +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/marketing/module.go b/internal/modules/marketing/module.go new file mode 100644 index 00000000..9bf4f018 --- /dev/null +++ b/internal/modules/marketing/module.go @@ -0,0 +1,13 @@ +package marketing + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" +) + +type MarketingModule struct{} + +func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + RegisterRoutes(router, db, validate) +} diff --git a/internal/modules/marketing/route.go b/internal/modules/marketing/route.go new file mode 100644 index 00000000..1ab03896 --- /dev/null +++ b/internal/modules/marketing/route.go @@ -0,0 +1,27 @@ +package marketing + +import ( + "gitlab.com/mbugroup/lti-api.git/internal/modules" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + salesOrderss "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders" + deliveryOrderss "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss" + // MODULE IMPORTS +) + +func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + group := router.Group("/marketing") + + allModules := []modules.Module{ + salesOrderss.SalesOrdersModule{}, + deliveryOrderss.DeliveryOrdersModule{}, + // MODULE REGISTRY + } + + for _, m := range allModules { + m.RegisterRoutes(group, db, validate) + } +} diff --git a/internal/modules/marketing/sales-orders/controllers/sales-orders.controller.go b/internal/modules/marketing/sales-orders/controllers/sales-orders.controller.go new file mode 100644 index 00000000..16d3b5be --- /dev/null +++ b/internal/modules/marketing/sales-orders/controllers/sales-orders.controller.go @@ -0,0 +1,122 @@ +package controller + +import ( + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type SalesOrdersController struct { + SalesOrdersService service.SalesOrdersService +} + +func NewSalesOrdersController(salesOrdersService service.SalesOrdersService) *SalesOrdersController { + return &SalesOrdersController{ + SalesOrdersService: salesOrdersService, + } +} + +func (u *SalesOrdersController) CreateOne(c *fiber.Ctx) error { + req := new(validation.Create) + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.SalesOrdersService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create salesOrders successfully", + Data: dto.ToSalesOrdersListDTOFromMarketing(*result), + }) +} + +func (u *SalesOrdersController) UpdateOne(c *fiber.Ctx) error { + req := new(validation.Update) + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.SalesOrdersService.UpdateOne(c, req, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Update salesOrders successfully", + Data: dto.ToSalesOrdersListDTOFromMarketing(*result), + }) +} + +func (u *SalesOrdersController) DeleteOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := u.SalesOrdersService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete salesOrders successfully", + }) +} + +func (u *SalesOrdersController) Approval(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.SalesOrdersService.Approval(c, req) + if err != nil { + return err + } + + var ( + data interface{} + message = "Submit sales order approval successfully" + ) + if len(results) == 1 { + data = dto.ToSalesOrdersListDTOFromMarketing(results[0]) + } else { + message = "Submit sales order approvals successfully" + data = dto.ToSalesOrdersListDTOsFromMarketing(results) + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: message, + Data: data, + }) +} diff --git a/internal/modules/marketing/sales-orders/dto/sales-orders.dto.go b/internal/modules/marketing/sales-orders/dto/sales-orders.dto.go new file mode 100644 index 00000000..86bd5f84 --- /dev/null +++ b/internal/modules/marketing/sales-orders/dto/sales-orders.dto.go @@ -0,0 +1,90 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + productWarehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/dto" +) + +// === DTO Structs === + +type MarketingProductDTO struct { + Id uint `json:"id"` + Qty float64 `json:"qty"` + UnitPrice float64 `json:"unit_price"` + AvgWeight float64 `json:"avg_weight"` + TotalWeight float64 `json:"total_weight"` + TotalPrice float64 `json:"total_price"` + ProductWarehouse *productWarehouseDTO.ProductWarehousNestedDTO `json:"product_warehouse,omitempty"` +} + +type SalesOrdersListDTO struct { + Id uint `json:"id"` + SoNumber string `json:"so_number"` + SoDate time.Time `json:"so_date"` + Notes string `json:"notes,omitempty"` + SalesOrder []MarketingProductDTO `json:"sales_order,omitempty"` +} + +// === Mapper Functions === + +func ToMarketingProductDTO(e entity.MarketingProduct) MarketingProductDTO { + var productWarehouse *productWarehouseDTO.ProductWarehousNestedDTO + + if e.ProductWarehouse.Id != 0 { + mapped := productWarehouseDTO.ToProductWarehouseNestedDTO(e.ProductWarehouse) + productWarehouse = &mapped + } + + return MarketingProductDTO{ + Id: e.Id, + Qty: e.Qty, + UnitPrice: e.UnitPrice, + AvgWeight: e.AvgWeight, + TotalWeight: e.TotalWeight, + TotalPrice: e.TotalPrice, + ProductWarehouse: productWarehouse, + } +} + +func ToSalesOrdersListDTO(e entity.Marketing) SalesOrdersListDTO { + products := make([]MarketingProductDTO, len(e.Products)) + for i, p := range e.Products { + products[i] = ToMarketingProductDTO(p) + } + + return SalesOrdersListDTO{ + Id: e.Id, + SoNumber: e.SoNumber, + SoDate: e.SoDate, + Notes: e.Notes, + SalesOrder: products, + } +} + +func ToSalesOrdersListDTOFromMarketing(e entity.Marketing) SalesOrdersListDTO { + var salesOrder []MarketingProductDTO + if len(e.Products) > 0 { + salesOrder = make([]MarketingProductDTO, len(e.Products)) + for i, product := range e.Products { + salesOrder[i] = ToMarketingProductDTO(product) + } + } + + return SalesOrdersListDTO{ + Id: e.Id, + SoNumber: e.SoNumber, + SoDate: e.SoDate, + Notes: e.Notes, + SalesOrder: salesOrder, + } +} + +func ToSalesOrdersListDTOsFromMarketing(e []entity.Marketing) []SalesOrdersListDTO { + result := make([]SalesOrdersListDTO, len(e)) + for i, r := range e { + result[i] = ToSalesOrdersListDTOFromMarketing(r) + } + return result +} diff --git a/internal/modules/marketing/sales-orders/module.go b/internal/modules/marketing/sales-orders/module.go new file mode 100644 index 00000000..0d9583d0 --- /dev/null +++ b/internal/modules/marketing/sales-orders/module.go @@ -0,0 +1,39 @@ +package sales_orders + +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" + rSalesOrders "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/repositories" + sSalesOrders "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/services" + rCustomer "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/repositories" + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +type SalesOrdersModule struct{} + +func (SalesOrdersModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + marketingRepo := rSalesOrders.NewMarketingRepository(db) + userRepo := rUser.NewUserRepository(db) + customerRepo := rCustomer.NewCustomerRepository(db) + productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + + approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(db)) + + if err := approvalSvc.RegisterWorkflowSteps(utils.ApprovalWorkflowMarketing, utils.MarketingApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register marketing approval workflow: %v", err)) + } + + salesOrdersService := sSalesOrders.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc, validate) + userService := sUser.NewUserService(userRepo, validate) + + SalesOrdersRoutes(router, userService, salesOrdersService) +} diff --git a/internal/modules/marketing/sales-orders/repositories/marketing-delivery-products.repository.go b/internal/modules/marketing/sales-orders/repositories/marketing-delivery-products.repository.go new file mode 100644 index 00000000..95e9b3bb --- /dev/null +++ b/internal/modules/marketing/sales-orders/repositories/marketing-delivery-products.repository.go @@ -0,0 +1,21 @@ +package repository + +import ( + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type MarketingDeliveryProductRepository interface { + repository.BaseRepository[entity.MarketingDeliveryProduct] +} + +type MarketingDeliveryProductRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.MarketingDeliveryProduct] +} + +func NewMarketingDeliveryProductRepository(db *gorm.DB) MarketingDeliveryProductRepository { + return &MarketingDeliveryProductRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.MarketingDeliveryProduct](db), + } +} diff --git a/internal/modules/marketing/sales-orders/repositories/marketing-products.repository.go b/internal/modules/marketing/sales-orders/repositories/marketing-products.repository.go new file mode 100644 index 00000000..4d5eb43f --- /dev/null +++ b/internal/modules/marketing/sales-orders/repositories/marketing-products.repository.go @@ -0,0 +1,40 @@ +package repository + +import ( + "context" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type MarketingProductRepository interface { + repository.BaseRepository[entity.MarketingProduct] + GetByMarketingID(ctx context.Context, marketingID uint) ([]entity.MarketingProduct, error) + IdExists(ctx context.Context, id uint) (bool, error) +} + +type MarketingProductRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.MarketingProduct] +} + +func NewMarketingProductRepository(db *gorm.DB) MarketingProductRepository { + return &MarketingProductRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.MarketingProduct](db), + } +} + +func (r *MarketingProductRepositoryImpl) GetByMarketingID(ctx context.Context, marketingID uint) ([]entity.MarketingProduct, error) { + var products []entity.MarketingProduct + if err := r.DB().WithContext(ctx).Where("marketing_id = ?", marketingID).Find(&products).Error; err != nil { + return nil, err + } + if len(products) == 0 { + return products, gorm.ErrRecordNotFound + } + return products, nil +} + +func (r *MarketingProductRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.MarketingProduct](ctx, r.DB(), id) +} diff --git a/internal/modules/marketing/sales-orders/repositories/marketings.repository.go b/internal/modules/marketing/sales-orders/repositories/marketings.repository.go new file mode 100644 index 00000000..dd0f99ab --- /dev/null +++ b/internal/modules/marketing/sales-orders/repositories/marketings.repository.go @@ -0,0 +1,37 @@ +package repository + +import ( + "context" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type MarketingRepository interface { + repository.BaseRepository[entity.Marketing] + IdExists(ctx context.Context, id uint) (bool, error) + GetNextSequence(ctx context.Context) (uint, error) +} + +type MarketingRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Marketing] +} + +func NewMarketingRepository(db *gorm.DB) MarketingRepository { + return &MarketingRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Marketing](db), + } +} + +func (r *MarketingRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.Marketing](ctx, r.DB(), id) +} + +func (r *MarketingRepositoryImpl) GetNextSequence(ctx context.Context) (uint, error) { + var maxID uint + if err := r.DB().WithContext(ctx).Model(&entity.Marketing{}).Select("COALESCE(MAX(id), 0)").Scan(&maxID).Error; err != nil { + return 0, err + } + return maxID + 1, nil +} diff --git a/internal/modules/marketing/sales-orders/route.go b/internal/modules/marketing/sales-orders/route.go new file mode 100644 index 00000000..ae6d7a81 --- /dev/null +++ b/internal/modules/marketing/sales-orders/route.go @@ -0,0 +1,26 @@ +package sales_orders + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/controllers" + salesOrders "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func SalesOrdersRoutes(v1 fiber.Router, u user.UserService, s salesOrders.SalesOrdersService) { + ctrl := controller.NewSalesOrdersController(s) + + v1.Delete("/:id", ctrl.DeleteOne) + route := v1.Group("/sales-orders") + + // route.Post("/", m.Auth(u), ctrl.CreateOne) + // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) + // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + + route.Post("/", ctrl.CreateOne) + route.Patch("/:id", ctrl.UpdateOne) + + route.Post("/approvals", ctrl.Approval) +} diff --git a/internal/modules/marketing/sales-orders/services/sales-orders.service.go b/internal/modules/marketing/sales-orders/services/sales-orders.service.go new file mode 100644 index 00000000..62f694f3 --- /dev/null +++ b/internal/modules/marketing/sales-orders/services/sales-orders.service.go @@ -0,0 +1,551 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strings" + + 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" + rInvMarketingDeliveryProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/marketing-delivery-products/repositories" + productWarehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/validations" + customerRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/repositories" + userRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type SalesOrdersService interface { + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Marketing, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Marketing, error) + DeleteOne(ctx *fiber.Ctx, id uint) error + Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.Marketing, error) +} + +type salesOrdersService struct { + Log *logrus.Logger + Validate *validator.Validate + MarketingRepo repository.MarketingRepository + CustomerRepo customerRepo.CustomerRepository + ProductWarehouseRepo productWarehouseRepo.ProductWarehouseRepository + UserRepo userRepo.UserRepository + ApprovalSvc commonSvc.ApprovalService +} + +func NewSalesOrdersService(marketingRepo repository.MarketingRepository, customerRepo customerRepo.CustomerRepository, productWarehouseRepo productWarehouseRepo.ProductWarehouseRepository, userRepo userRepo.UserRepository, approvalSvc commonSvc.ApprovalService, validate *validator.Validate) SalesOrdersService { + return &salesOrdersService{ + Log: utils.Log, + Validate: validate, + MarketingRepo: marketingRepo, + CustomerRepo: customerRepo, + ProductWarehouseRepo: productWarehouseRepo, + UserRepo: userRepo, + ApprovalSvc: approvalSvc, + } +} + +func (s salesOrdersService) withRelations(db *gorm.DB) *gorm.DB { + return db. + Preload("CreatedUser"). + Preload("Customer"). + Preload("SalesPerson"). + Preload("Products.ProductWarehouse.Product"). + Preload("Products.ProductWarehouse.Warehouse") +} + +func (s salesOrdersService) getOne(c *fiber.Ctx, id uint) (*entity.Marketing, error) { + marketing, err := s.MarketingRepo.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "SalesOrders not found") + } + if err != nil { + s.Log.Errorf("Failed get marketing by id: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch sales order") + } + + if s.ApprovalSvc != nil { + approvals, err := s.ApprovalSvc.ListByTarget(c.Context(), utils.ApprovalWorkflowMarketing, id, func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + }) + if err == nil && len(approvals) > 0 { + latest := approvals[len(approvals)-1] + marketing.LatestApproval = &latest + } + } + + return marketing, nil +} + +func (s *salesOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Marketing, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + if err := commonSvc.EnsureRelations(c.Context(), + commonSvc.RelationCheck{Name: "Customer", ID: &req.CustomerId, Exists: s.CustomerRepo.IdExists}, + ); err != nil { + return nil, err + } + + for _, item := range req.MarketingProducts { + if err := commonSvc.EnsureRelations(c.Context(), + commonSvc.RelationCheck{Name: "ProductWarehouse", ID: &item.ProductWarehouseId, Exists: s.ProductWarehouseRepo.IdExists}, + ); err != nil { + return nil, err + } + } + + soDate, err := utils.ParseDateString(req.Date) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid date format") + } + + nextSeq, err := s.MarketingRepo.GetNextSequence(c.Context()) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to generate SO number") + } + soNumber := fmt.Sprintf("SO-%05d", nextSeq) + + var marketing *entity.Marketing + err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + + marketingRepoTx := repository.NewMarketingRepository(dbTransaction) + marketingProductRepoTx := repository.NewMarketingProductRepository(dbTransaction) + invDeliveryRepoTx := rInvMarketingDeliveryProduct.NewMarketingDeliveryProductRepository(dbTransaction) + approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) + + marketing = &entity.Marketing{ + CustomerId: req.CustomerId, + SoNumber: soNumber, + SoDate: soDate, + SalesPersonId: req.SalesPersonId, + Notes: req.Notes, + CreatedBy: 1, + } + if err := marketingRepoTx.CreateOne(c.Context(), marketing, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create salesOrders") + } + + if len(req.MarketingProducts) > 0 { + for _, product := range req.MarketingProducts { + if err := s.createMarketingProductWithDelivery(c.Context(), marketing.Id, product, marketingProductRepoTx, invDeliveryRepoTx); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create marketing product") + } + } + } + + actorID := uint(1) // TODO: ambil dari auth context + approvalAction := entity.ApprovalActionCreated + if _, err := approvalSvcTx.CreateApproval( + c.Context(), + utils.ApprovalWorkflowMarketing, + marketing.Id, + utils.MarketingStepPengajuan, + &approvalAction, + actorID, + nil); err != nil { + if !errors.Is(err, gorm.ErrDuplicatedKey) { + fiber.NewError(fiber.StatusInternalServerError, "Failed to create approval") + } + } + + return nil + }) + if err != nil { + if fiberErr, ok := err.(*fiber.Error); ok { + return nil, fiberErr + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create salesOrders") + } + + marketing, err = s.MarketingRepo.GetByID(c.Context(), marketing.Id, s.withRelations) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch created sales order") + } + + return marketing, nil +} + +func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Marketing, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + if err := commonSvc.EnsureRelations(c.Context(), + commonSvc.RelationCheck{Name: "Marketing", ID: &id, Exists: s.MarketingRepo.IdExists}, + commonSvc.RelationCheck{Name: "Customer", ID: &req.CustomerId, Exists: s.CustomerRepo.IdExists}, + commonSvc.RelationCheck{Name: "SalesPerson", ID: &req.SalesPersonId, Exists: s.UserRepo.IdExists}, + ); err != nil { + return nil, err + } + + latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowMarketing, id, nil) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check approval status") + } + if latestApproval != nil && latestApproval.StepNumber >= 3 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Cannot update sales order after delivery order approval") + } + + if len(req.MarketingProducts) > 0 { + for _, item := range req.MarketingProducts { + if err := commonSvc.EnsureRelations(c.Context(), + commonSvc.RelationCheck{Name: "ProductWarehouse", ID: &item.ProductWarehouseId, Exists: s.ProductWarehouseRepo.IdExists}, + ); err != nil { + return nil, err + } + } + } + + err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + + marketingRepoTx := repository.NewMarketingRepository(dbTransaction) + marketingProductRepoTx := repository.NewMarketingProductRepository(dbTransaction) + approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) + invDeliveryRepoTx := rInvMarketingDeliveryProduct.NewMarketingDeliveryProductRepository(dbTransaction) + + updateBody := make(map[string]any) + if req.CustomerId != 0 { + updateBody["customer_id"] = req.CustomerId + } + if req.SalesPersonId != 0 { + updateBody["sales_person_id"] = req.SalesPersonId + } + if req.Date != "" { + soDate, err := utils.ParseDateString(req.Date) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid date format") + } + updateBody["so_date"] = soDate + } + if req.Notes != "" { + updateBody["notes"] = req.Notes + } + + if len(updateBody) > 0 { + if err := marketingRepoTx.PatchOne(c.Context(), id, updateBody, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update sales order") + } + } + + if len(req.MarketingProducts) > 0 { + + oldProducts, err := marketingProductRepoTx.GetByMarketingID(c.Context(), id) + if err != nil && err != gorm.ErrRecordNotFound { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch existing products") + } + + oldByPW := make(map[uint]*entity.MarketingProduct) + for i := range oldProducts { + p := oldProducts[i] + oldByPW[p.ProductWarehouseId] = &p + } + + reqByPW := make(map[uint]validation.CreateMarketingProduct) + for _, rp := range req.MarketingProducts { + reqByPW[rp.ProductWarehouseId] = rp + } + + for _, rp := range req.MarketingProducts { + if old, ok := oldByPW[rp.ProductWarehouseId]; ok { + + updateBody := map[string]any{ + "product_warehouse_id": rp.ProductWarehouseId, + "qty": rp.Qty, + "unit_price": rp.UnitPrice, + "avg_weight": rp.AvgWeight, + "total_weight": rp.TotalWeight, + "total_price": rp.TotalPrice, + } + if err := marketingProductRepoTx.PatchOne(c.Context(), old.Id, updateBody, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update marketing product") + } + + // Ensure delivery product exists; if not, create default + if _, err := invDeliveryRepoTx.GetByMarketingProductID(c.Context(), old.Id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + mdp := &entity.MarketingDeliveryProduct{ + MarketingProductId: old.Id, + Qty: 0, + UnitPrice: 0, + TotalWeight: 0, + AvgWeight: 0, + TotalPrice: 0, + DeliveryDate: nil, + VehicleNumber: rp.VehicleNumber, + } + if err := invDeliveryRepoTx.CreateOne(c.Context(), mdp, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create marketing delivery product") + } + } else { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to check delivery product") + } + } + } else { + // Create new marketing product (use helper) + if err := s.createMarketingProductWithDelivery(c.Context(), id, rp, marketingProductRepoTx, invDeliveryRepoTx); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create marketing product") + } + } + } + + // 2) Delete missing old products (prevent deletion if deliveries exist) + for _, old := range oldProducts { + if _, ok := reqByPW[old.ProductWarehouseId]; !ok { + // Check delivery product for this marketing product + deliveryProduct, err := invDeliveryRepoTx.GetByMarketingProductID(c.Context(), old.Id) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to check existing delivery product") + } + if err == nil { + // If delivery exists (delivery_date not nil or qty > 0), prevent deletion + if deliveryProduct.DeliveryDate != nil || deliveryProduct.Qty > 0 { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot delete marketing product %d because it has delivery records", old.Id)) + } + // safe to delete delivery product record + if err := invDeliveryRepoTx.DeleteOne(c.Context(), deliveryProduct.Id); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete marketing delivery product") + } + } + // Delete marketing product + if err := marketingProductRepoTx.DeleteOne(c.Context(), old.Id); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete marketing product") + } + } + } + } + + if latestApproval != nil && latestApproval.StepNumber == 2 { + actorID := uint(1) // todo: ambil dari auth context + resetNote := "" + action := entity.ApprovalActionApproved + _, err := approvalSvcTx.CreateApproval( + c.Context(), + utils.ApprovalWorkflowMarketing, + id, + utils.MarketingStepPengajuan, + &action, + actorID, + &resetNote) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to reset approval status") + } + } + + return nil + }) + + if err != nil { + if fiberErr, ok := err.(*fiber.Error); ok { + return nil, fiberErr + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update sales order") + } + + return s.getOne(c, id) +} + +func (s salesOrdersService) DeleteOne(c *fiber.Ctx, id uint) error { + marketing, err := s.MarketingRepo.GetByID(c.Context(), id, s.withRelations) + + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "SalesOrders not found") + } + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch sales order") + } + + err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + + marketingProductRepoTx := repository.NewMarketingProductRepository(dbTransaction) + marketingDeliveryProductRepoTx := repository.NewMarketingDeliveryProductRepository(dbTransaction) + marketingRepoTx := repository.NewMarketingRepository(dbTransaction) + + if len(marketing.Products) > 0 { + for _, product := range marketing.Products { + if err := marketingDeliveryProductRepoTx.DeleteMany(c.Context(), func(db *gorm.DB) *gorm.DB { + return db.Where("marketing_product_id = ?", product.Id) + }); err != nil && err != gorm.ErrRecordNotFound { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete sales order products") + } + } + } + + if err := marketingProductRepoTx.DeleteMany(c.Context(), func(db *gorm.DB) *gorm.DB { + return db.Where("marketing_id = ?", id) + }); err != nil && err != gorm.ErrRecordNotFound { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete sales order products") + } + + if err := marketingRepoTx.DeleteOne(c.Context(), id); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete sales order") + } + + return nil + }) + + if err != nil { + if fiberErr, ok := err.(*fiber.Error); ok { + return fiberErr + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete sales order") + } + + return nil +} + +func (s salesOrdersService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entity.Marketing, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(s.MarketingRepo.DB())) + + var action entity.ApprovalAction + switch strings.ToUpper(strings.TrimSpace(req.Action)) { + case string(entity.ApprovalActionRejected): + action = entity.ApprovalActionRejected + case string(entity.ApprovalActionApproved): + action = entity.ApprovalActionApproved + default: + return nil, fiber.NewError(fiber.StatusBadRequest, "action must be APPROVED or REJECTED") + } + + approvableIDs := utils.UniqueUintSlice(req.ApprovableIds) + if len(approvableIDs) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "approvable_ids must contain at least one id") + } + + for _, id := range approvableIDs { + if err := commonSvc.EnsureRelations(c.Context(), + commonSvc.RelationCheck{Name: "Marketing", ID: &id, Exists: s.MarketingRepo.IdExists}, + ); err != nil { + return nil, err + } + + latestApproval, err := approvalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowMarketing, id, nil) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check approval status") + } + if latestApproval == nil { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("No approval found for Marketing %d - sales orders must be created first", id)) + } + + if action == entity.ApprovalActionApproved { + switch latestApproval.StepNumber { + case uint16(utils.MarketingStepPengajuan): + case uint16(utils.MarketingStepSalesOrder): + default: + return nil, fiber.NewError(fiber.StatusBadRequest, + fmt.Sprintf("Marketing %d cannot be approved - current step is %d", id, latestApproval.StepNumber)) + } + } + } + + err := s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + + approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) + + for _, approvableID := range approvableIDs { + latestApproval, err := approvalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowMarketing, approvableID, nil) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to check current approval step") + } + + if latestApproval == nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("No approval found for Marketing %d", approvableID)) + } + + var nextStep approvalutils.ApprovalStep + currentStep := latestApproval.StepNumber + + if action == entity.ApprovalActionApproved { + + if currentStep == uint16(utils.MarketingStepPengajuan) { + nextStep = utils.MarketingStepSalesOrder + } else if currentStep == uint16(utils.MarketingStepSalesOrder) { + nextStep = utils.MarketingDeliveryOrder + } else { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Marketing %d already completed all approval steps", approvableID)) + } + } else { + + nextStep = approvalutils.ApprovalStep(currentStep) + } + + actorID := uint(1) // todo ambil dari auth context + if _, err := approvalSvc.CreateApproval( + c.Context(), + utils.ApprovalWorkflowMarketing, + approvableID, + nextStep, + &action, + actorID, + req.Notes, + ); err != nil { + s.Log.Errorf("Failed to create approval for %d: %+v", approvableID, err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create approval") + } + } + return nil + }) + + if err != nil { + if fiberErr, ok := err.(*fiber.Error); ok { + return nil, fiberErr + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to record approval") + } + + updated := make([]entity.Marketing, 0, len(approvableIDs)) + for _, id := range approvableIDs { + marketing, err := s.getOne(c, id) + if err != nil { + return nil, err + } + updated = append(updated, *marketing) + } + + return updated, nil +} + +func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Context, marketingId uint, rp validation.CreateMarketingProduct, marketingProductRepo repository.MarketingProductRepository, invDeliveryRepo rInvMarketingDeliveryProduct.MarketingDeliveryProductRepository) error { + + marketingProduct := &entity.MarketingProduct{ + MarketingId: marketingId, + ProductWarehouseId: rp.ProductWarehouseId, + Qty: rp.Qty, + UnitPrice: rp.UnitPrice, + AvgWeight: rp.AvgWeight, + TotalWeight: rp.TotalWeight, + TotalPrice: rp.TotalPrice, + } + if err := marketingProductRepo.CreateOne(ctx, marketingProduct, nil); err != nil { + return err + } + + marketingDeliveryProduct := &entity.MarketingDeliveryProduct{ + MarketingProductId: marketingProduct.Id, + Qty: 0, + UnitPrice: 0, + TotalWeight: 0, + AvgWeight: 0, + TotalPrice: 0, + DeliveryDate: nil, + VehicleNumber: rp.VehicleNumber, + } + if err := invDeliveryRepo.CreateOne(ctx, marketingDeliveryProduct, nil); err != nil { + return err + } + + return nil +} diff --git a/internal/modules/marketing/sales-orders/validations/sales-orders.validation.go b/internal/modules/marketing/sales-orders/validations/sales-orders.validation.go new file mode 100644 index 00000000..47d2e616 --- /dev/null +++ b/internal/modules/marketing/sales-orders/validations/sales-orders.validation.go @@ -0,0 +1,33 @@ +package validation + +type Create struct { + CustomerId uint `json:"customer_id" validate:"required,gt=0"` + SalesPersonId uint `json:"sales_person_id" validate:"required,gt=0"` + Date string `json:"date" validate:"required,datetime=2006-01-02"` + Notes string `json:"notes" validate:"omitempty,max=500"` + MarketingProducts []CreateMarketingProduct `json:"marketing_products" validate:"required,min=1,dive"` +} + +type CreateMarketingProduct struct { + VehicleNumber string `json:"vehicle_number" validate:"required,min=1,max=50"` + ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,gt=0"` + UnitPrice float64 `json:"unit_price" validate:"required,gt=0"` + TotalWeight float64 `json:"total_weight" validate:"required,gt=0"` + Qty float64 `json:"qty" validate:"required,gt=0"` + AvgWeight float64 `json:"avg_weight" validate:"required,gt=0"` + TotalPrice float64 `json:"total_price" validate:"required,gt=0"` +} + +type Update struct { + CustomerId uint `json:"customer_id" validate:"omitempty,gt=0"` + SalesPersonId uint `json:"sales_person_id" validate:"omitempty,gt=0"` + Date string `json:"date" validate:"omitempty,datetime=2006-01-02"` + Notes string `json:"notes" validate:"omitempty,max=500"` + MarketingProducts []CreateMarketingProduct `json:"marketing_products" validate:"omitempty,min=1,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/master/customers/repositories/customer.repository.go b/internal/modules/master/customers/repositories/customer.repository.go index 13a08211..68877548 100644 --- a/internal/modules/master/customers/repositories/customer.repository.go +++ b/internal/modules/master/customers/repositories/customer.repository.go @@ -12,6 +12,7 @@ type CustomerRepository interface { repository.BaseRepository[entity.Customer] PicExists(ctx context.Context, areaId uint) (bool, error) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) + IdExists(ctx context.Context, id uint) (bool, error) } type CustomerRepositoryImpl struct { @@ -33,3 +34,7 @@ func (r *CustomerRepositoryImpl) PicExists(ctx context.Context, picId uint) (boo func (r *CustomerRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) { return repository.ExistsByName[entity.Customer](ctx, r.db, name, excludeID) } + +func (r *CustomerRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.Customer](ctx, r.db, id) +} diff --git a/internal/modules/master/kandangs/dto/kandang.dto.go b/internal/modules/master/kandangs/dto/kandang.dto.go index deed483c..284ca166 100644 --- a/internal/modules/master/kandangs/dto/kandang.dto.go +++ b/internal/modules/master/kandangs/dto/kandang.dto.go @@ -14,6 +14,7 @@ type KandangBaseDTO struct { Id uint `json:"id"` Name string `json:"name"` Status string `json:"status"` + Capacity float64 `json:"capacity"` Location *locationDTO.LocationBaseDTO `json:"location"` Pic *userDTO.UserBaseDTO `json:"pic"` } @@ -48,6 +49,7 @@ func ToKandangBaseDTO(e entity.Kandang) KandangBaseDTO { Id: e.Id, Name: e.Name, Status: e.Status, + Capacity: e.Capacity, Location: location, Pic: pic, } diff --git a/internal/modules/master/kandangs/repositories/kandang.repository.go b/internal/modules/master/kandangs/repositories/kandang.repository.go index 8f32a7b2..04b6a914 100644 --- a/internal/modules/master/kandangs/repositories/kandang.repository.go +++ b/internal/modules/master/kandangs/repositories/kandang.repository.go @@ -21,6 +21,7 @@ type KandangRepository interface { 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 + IdExists(ctx context.Context, id uint) (bool, error) } type KandangRepositoryImpl struct { @@ -59,6 +60,10 @@ func (r *KandangRepositoryImpl) ProjectFlockExists(ctx context.Context, projectF return count > 0, nil } +func (r *KandangRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.Kandang](ctx, r.db, id) +} + func (r *KandangRepositoryImpl) HasActiveKandangForProjectFlock(ctx context.Context, projectFlockID uint, excludeID *uint) (bool, error) { var count int64 q := r.db.WithContext(ctx). diff --git a/internal/modules/master/kandangs/route.go b/internal/modules/master/kandangs/route.go index 6a425b64..1e384b1f 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,7 +13,7 @@ func KandangRoutes(v1 fiber.Router, u user.UserService, s kandang.KandangService ctrl := controller.NewKandangController(s) route := v1.Group("/kandangs") - route.Use(m.Auth(u)) + // 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 1c0eed6a..e65348fc 100644 --- a/internal/modules/master/kandangs/services/kandang.service.go +++ b/internal/modules/master/kandangs/services/kandang.service.go @@ -134,6 +134,7 @@ func (s *kandangService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit createBody := &entity.Kandang{ Name: req.Name, LocationId: req.LocationId, + Capacity: req.Capacity, Status: status, PicId: req.PicId, CreatedBy: 1, @@ -194,6 +195,10 @@ func (s kandangService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) updateBody["pic_id"] = *req.PicId } + if req.Capacity != nil { + updateBody["capacity"] = *req.Capacity + } + finalStatus := strings.ToUpper(existing.Status) if req.Status != nil { status := strings.ToUpper(*req.Status) diff --git a/internal/modules/master/kandangs/validations/kandang.validation.go b/internal/modules/master/kandangs/validations/kandang.validation.go index f6886991..6d7c090b 100644 --- a/internal/modules/master/kandangs/validations/kandang.validation.go +++ b/internal/modules/master/kandangs/validations/kandang.validation.go @@ -1,19 +1,21 @@ package validation type Create struct { - Name string `json:"name" validate:"required_strict,min=3"` - Status string `json:"status,omitempty" validate:"omitempty,min=3"` - LocationId uint `json:"location_id" validate:"required_strict,number,gt=0"` - PicId uint `json:"pic_id" validate:"required_strict,number,gt=0"` - ProjectFlockId *uint `json:"project_flock_id" validate:"omitempty,number,gt=0"` + Name string `json:"name" validate:"required_strict,min=3"` + Status string `json:"status,omitempty" validate:"omitempty,min=3"` + Capacity float64 `json:"capacity" validate:"required_strict,gt=0"` + LocationId uint `json:"location_id" validate:"required_strict,number,gt=0"` + PicId uint `json:"pic_id" validate:"required_strict,number,gt=0"` + ProjectFlockId *uint `json:"project_flock_id" validate:"omitempty,number,gt=0"` } type Update struct { - Name *string `json:"name,omitempty" validate:"omitempty"` - Status *string `json:"status,omitempty" validate:"omitempty,min=3"` - LocationId *uint `json:"location_id,omitempty" validate:"omitempty,number,gt=0"` - PicId *uint `json:"pic_id,omitempty" validate:"omitempty,number,gt=0"` - ProjectFlockId *uint `json:"project_flock_id,omitempty" validate:"omitempty,number,gt=0"` + Name *string `json:"name,omitempty" validate:"omitempty"` + Status *string `json:"status,omitempty" validate:"omitempty,min=3"` + Capacity *float64 `json:"capacity" validate:"omitempty,gt=0"` + LocationId *uint `json:"location_id,omitempty" validate:"omitempty,number,gt=0"` + PicId *uint `json:"pic_id,omitempty" validate:"omitempty,number,gt=0"` + ProjectFlockId *uint `json:"project_flock_id,omitempty" validate:"omitempty,number,gt=0"` } type Query struct { diff --git a/internal/modules/master/uoms/dto/uom.dto.go b/internal/modules/master/uoms/dto/uom.dto.go index 476309b2..2e614de0 100644 --- a/internal/modules/master/uoms/dto/uom.dto.go +++ b/internal/modules/master/uoms/dto/uom.dto.go @@ -15,7 +15,8 @@ type UomBaseDTO struct { } type UomListDTO struct { - UomBaseDTO + Id uint `json:"id"` + Name string `json:"name"` CreatedUser *userDTO.UserBaseDTO `json:"created_user"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -42,7 +43,8 @@ func ToUomListDTO(e entity.Uom) UomListDTO { } return UomListDTO{ - UomBaseDTO: ToUomBaseDTO(e), + Id: e.Id, + Name: e.Name, CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt, CreatedUser: createdUser, diff --git a/internal/modules/master/warehouses/repositories/warehouse.repository.go b/internal/modules/master/warehouses/repositories/warehouse.repository.go index 956c30ef..ff05b3a1 100644 --- a/internal/modules/master/warehouses/repositories/warehouse.repository.go +++ b/internal/modules/master/warehouses/repositories/warehouse.repository.go @@ -16,6 +16,8 @@ type WarehouseRepository interface { NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) IdExists(ctx context.Context, id uint) (bool, error) GetByKandangID(ctx context.Context, kandangId uint) (*entity.Warehouse, error) + GetLatestByKandangID(ctx context.Context, kandangId uint) (*entity.Warehouse, error) + GetDetailByID(ctx context.Context, id uint) (*entity.Warehouse, error) } type WarehouseRepositoryImpl struct { @@ -60,3 +62,28 @@ func (r *WarehouseRepositoryImpl) GetByKandangID(ctx context.Context, kandangId } return &warehouse, nil } + +func (r *WarehouseRepositoryImpl) GetDetailByID(ctx context.Context, id uint) (*entity.Warehouse, error) { + var warehouse entity.Warehouse + err := r.db.WithContext(ctx). + Preload("Area"). + Preload("Location"). + First(&warehouse, id).Error + if err != nil { + return nil, err + } + return &warehouse, nil +} + +func (r *WarehouseRepositoryImpl) GetLatestByKandangID(ctx context.Context, kandangId uint) (*entity.Warehouse, error) { + var warehouse entity.Warehouse + err := r.db.WithContext(ctx). + Where("kandang_id = ?", kandangId). + Where("deleted_at IS NULL"). + Order("id DESC"). + First(&warehouse).Error + if err != nil { + return nil, err + } + return &warehouse, nil +} diff --git a/internal/modules/production/chickins/controllers/chickin.controller.go b/internal/modules/production/chickins/controllers/chickin.controller.go index fadcbc3e..7f8e0d5b 100644 --- a/internal/modules/production/chickins/controllers/chickin.controller.go +++ b/internal/modules/production/chickins/controllers/chickin.controller.go @@ -1,7 +1,6 @@ package controller import ( - "math" "strconv" "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/dto" @@ -22,30 +21,84 @@ func NewChickinController(chickinService service.ChickinService) *ChickinControl } } -func (u *ChickinController) GetAll(c *fiber.Ctx) error { - query := &validation.Query{ - Page: c.QueryInt("page", 1), - Limit: c.QueryInt("limit", 10), - ProjectFlockKandangId: uint(c.QueryInt("project_flock_kandang_id", 0)), +// func (u *ChickinController) GetAll(c *fiber.Ctx) error { +// query := &validation.Query{ +// Page: c.QueryInt("page", 1), +// Limit: c.QueryInt("limit", 10), +// ProjectFlockKandangId: uint(c.QueryInt("project_flock_kandang_id", 0)), +// } + +// result, totalResults, err := u.ChickinService.GetAll(c, query) +// if err != nil { +// return err +// } + +// return c.Status(fiber.StatusOK). +// JSON(response.SuccessWithPaginate[dto.ChickinListDTO]{ +// Code: fiber.StatusOK, +// Status: "success", +// Message: "Get all chickins successfully", +// Meta: response.Meta{ +// Page: query.Page, +// Limit: query.Limit, +// TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), +// TotalResults: totalResults, +// }, +// Data: dto.ToChickinListDTOs(result), +// }) +// } + +// func (u *ChickinController) GetOne(c *fiber.Ctx) error { +// param := c.Params("id") + +// id, err := strconv.Atoi(param) +// if err != nil { +// return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") +// } + +// result, err := u.ChickinService.GetOne(c, uint(id)) +// if err != nil { +// return err +// } + +// return c.Status(fiber.StatusOK). +// JSON(response.Success{ +// Code: fiber.StatusOK, +// Status: "success", +// Message: "Get chickin successfully", +// Data: dto.ToChickinListDTO(*result), +// }) +// } + +func (u *ChickinController) CreateOne(c *fiber.Ctx) error { + req := new(validation.Create) + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") } - result, totalResults, err := u.ChickinService.GetAll(c, query) + results, err := u.ChickinService.CreateOne(c, req) if err != nil { return err } - return c.Status(fiber.StatusOK). - JSON(response.SuccessWithPaginate[dto.ChickinListDTO]{ - Code: fiber.StatusOK, + var ( + data interface{} + message = "Create chickin successfully" + ) + if len(results) == 1 { + data = dto.ToChickinListDTO(results[0]) + } else { + message = "Create chickins successfully" + data = dto.ToChickinListDTOs(results) + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, Status: "success", - Message: "Get all chickins successfully", - Meta: response.Meta{ - Page: query.Page, - Limit: query.Limit, - TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), - TotalResults: totalResults, - }, - Data: dto.ToChickinListDTOs(result), + Message: message, + Data: data, }) } @@ -67,95 +120,85 @@ func (u *ChickinController) GetOne(c *fiber.Ctx) error { Code: fiber.StatusOK, Status: "success", Message: "Get chickin successfully", - Data: dto.ToChickinListDTO(*result), + Data: dto.ToChickinDetailDTO(*result), }) } -func (u *ChickinController) CreateOne(c *fiber.Ctx) error { - req := new(validation.Create) +// func (u *ChickinController) UpdateOne(c *fiber.Ctx) error { +// req := new(validation.Update) +// param := c.Params("id") + +// id, err := strconv.Atoi(param) +// if err != nil { +// return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") +// } + +// if err := c.BodyParser(req); err != nil { +// return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") +// } + +// result, err := u.ChickinService.UpdateOne(c, req, uint(id)) +// if err != nil { +// return err +// } + +// return c.Status(fiber.StatusOK). +// JSON(response.Success{ +// Code: fiber.StatusOK, +// Status: "success", +// Message: "Update chickin successfully", +// Data: dto.ToChickinListDTO(*result), +// }) +// } + +// func (u *ChickinController) DeleteOne(c *fiber.Ctx) error { +// param := c.Params("id") + +// id, err := strconv.Atoi(param) +// if err != nil { +// return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") +// } + +// if err := u.ChickinService.DeleteOne(c, uint(id)); err != nil { +// return err +// } + +// return c.Status(fiber.StatusOK). +// JSON(response.Common{ +// Code: fiber.StatusOK, +// Status: "success", +// Message: "Delete chickin successfully", +// }) +// } + +func (u *ChickinController) Approval(c *fiber.Ctx) error { + req := new(validation.Approve) if err := c.BodyParser(req); err != nil { return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") } - result, err := u.ChickinService.CreateOne(c, req) + results, err := u.ChickinService.Approval(c, req) if err != nil { return err } - return c.Status(fiber.StatusCreated). - JSON(response.Success{ - Code: fiber.StatusCreated, - Status: "success", - Message: "Create chickin successfully", - Data: dto.ToChickinListDTO(*result), - }) -} - -func (u *ChickinController) UpdateOne(c *fiber.Ctx) error { - req := new(validation.Update) - param := c.Params("id") - - id, err := strconv.Atoi(param) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") - } - - if err := c.BodyParser(req); err != nil { - return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") - } - - result, err := u.ChickinService.UpdateOne(c, req, uint(id)) - if err != nil { - return err + var ( + data interface{} + message = "Submit chickin approval successfully" + ) + if len(results) == 1 { + data = dto.ToChickinListDTO(results[0]) + } else { + message = "Submit chickin approvals successfully" + data = dto.ToChickinListDTOs(results) } return c.Status(fiber.StatusOK). JSON(response.Success{ Code: fiber.StatusOK, Status: "success", - Message: "Update chickin successfully", - Data: dto.ToChickinListDTO(*result), - }) -} - -func (u *ChickinController) DeleteOne(c *fiber.Ctx) error { - param := c.Params("id") - - id, err := strconv.Atoi(param) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") - } - - if err := u.ChickinService.DeleteOne(c, uint(id)); err != nil { - return err - } - - return c.Status(fiber.StatusOK). - JSON(response.Common{ - Code: fiber.StatusOK, - Status: "success", - Message: "Delete chickin successfully", - }) -} - -func (u *ChickinController) Approve(c *fiber.Ctx) error { - param := c.Params("id") - - id, err := strconv.Atoi(param) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") - } - - if err := u.ChickinService.Approve(c, uint(id)); err != nil { - return err - } - - return c.Status(fiber.StatusOK). - JSON(response.Success{ - Code: fiber.StatusOK, - Status: "success", - Message: "Approve chickin successfully", - Data: nil, + Message: message, + Data: data, }) } diff --git a/internal/modules/production/chickins/dto/chickin.dto.go b/internal/modules/production/chickins/dto/chickin.dto.go index 3b69d4d4..ad6d10f8 100644 --- a/internal/modules/production/chickins/dto/chickin.dto.go +++ b/internal/modules/production/chickins/dto/chickin.dto.go @@ -9,18 +9,29 @@ 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" + productDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/dto" + warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/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" ) // === DTO Structs (ordered) === +type ProductWarehouseDTO struct { + Id uint `json:"id"` + Product *productDTO.ProductBaseDTO `json:"product,omitempty"` + Warehouse *warehouseDTO.WarehouseBaseDTO `json:"warehouse,omitempty"` +} + type ChickinBaseDTO struct { - Id uint `json:"id"` - ProjectFlockKandang *ProjectFlockKandangDTO `json:"project_flock_kandang"` - ChickInDate time.Time `json:"chick_in_date"` - Quantity float64 `json:"quantity"` - Note string `json:"note"` + Id uint `json:"id"` + ProjectFlockKandangId uint `json:"project_flock_kandang_id"` + ChickInDate time.Time `json:"chick_in_date"` + ProductWarehouseId uint `json:"product_warehouse_id"` + ProductWarehouse *ProductWarehouseDTO `json:"product_warehouse,omitempty"` + UsageQty float64 `json:"usage_qty"` + PendingUsageQty float64 `json:"pending_usage_qty"` + Notes string `json:"notes"` } type ProjectFlockDTO struct { @@ -45,21 +56,32 @@ type ChickinSimpleDTO struct { Id uint `json:"id"` ProjectFlockKandangId uint `json:"project_flock_kandang_id"` ChickInDate time.Time `json:"chick_in_date"` - Quantity float64 `json:"quantity"` - Note string `json:"note"` + ProductWarehouseId uint `json:"product_warehouse_id"` + UsageQty float64 `json:"usage_qty"` + PendingUsageQty float64 `json:"pending_usage_qty"` + Notes string `json:"notes"` CreatedBy uint `json:"created_by"` } type ChickinListDTO struct { ChickinBaseDTO - ProjectFlockKandang *ProjectFlockKandangDTO `json:"project_flock_kandang"` - CreatedUser *userBaseDTO.UserBaseDTO `json:"created_user"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + CreatedUser *userBaseDTO.UserBaseDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type ChickinDetailDTO struct { - ChickinListDTO + Id uint `json:"id"` + ProjectFlockKandangId uint `json:"project_flock_kandang_id"` + ChickInDate time.Time `json:"chick_in_date"` + ProductWarehouseId uint `json:"product_warehouse_id"` + UsageQty float64 `json:"usage_qty"` + PendingUsageQty float64 `json:"pending_usage_qty"` + Notes string `json:"notes"` + CreatedBy uint `json:"created_by"` + CreatedUser *userBaseDTO.UserBaseDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // === Mapper Functions (ordered) === @@ -138,17 +160,29 @@ func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangD } func ToChickinBaseDTO(e entity.ProjectChickin) ChickinBaseDTO { - var pfk *ProjectFlockKandangDTO - if e.ProjectFlockKandang.Id != 0 { - mapped := ToProjectFlockKandangDTO(e.ProjectFlockKandang) - pfk = &mapped + var projectFlockKandangId uint + // Check if ProjectFlockKandang relation is loaded + if e.ProjectFlockKandang != nil && e.ProjectFlockKandang.Id != 0 { + projectFlockKandangId = e.ProjectFlockKandang.Id + } else if e.ProjectFlockKandangId != 0 { + // If relation is not loaded but ID is available, use the ID + projectFlockKandangId = e.ProjectFlockKandangId } + + var productWarehouse *ProductWarehouseDTO + if e.ProductWarehouse != nil && e.ProductWarehouse.Id != 0 { + productWarehouse = toProductWarehouseDTO(e.ProductWarehouse) + } + return ChickinBaseDTO{ - Id: e.Id, - ProjectFlockKandang: pfk, - ChickInDate: e.ChickInDate, - Quantity: e.Quantity, - Note: e.Note, + Id: e.Id, + ProjectFlockKandangId: projectFlockKandangId, + ChickInDate: e.ChickInDate, + ProductWarehouseId: e.ProductWarehouseId, + ProductWarehouse: productWarehouse, + UsageQty: e.UsageQty, + PendingUsageQty: e.PendingUsageQty, + Notes: e.Notes, } } @@ -157,29 +191,25 @@ func ToChickinSimpleDTO(e entity.ProjectChickin) ChickinSimpleDTO { Id: e.Id, ProjectFlockKandangId: e.ProjectFlockKandangId, ChickInDate: e.ChickInDate, - Quantity: e.Quantity, - Note: e.Note, + ProductWarehouseId: e.ProductWarehouseId, + UsageQty: e.UsageQty, + PendingUsageQty: e.PendingUsageQty, + Notes: e.Notes, CreatedBy: e.CreatedBy, } } func ToChickinListDTO(e entity.ProjectChickin) ChickinListDTO { var createdUser *userBaseDTO.UserBaseDTO - if e.CreatedUser.Id != 0 { - mapped := userBaseDTO.ToUserBaseDTO(e.CreatedUser) + if e.CreatedUser != nil && e.CreatedUser.Id != 0 { + mapped := userBaseDTO.ToUserBaseDTO(*e.CreatedUser) createdUser = &mapped } - var pfk *ProjectFlockKandangDTO - if e.ProjectFlockKandang.Id != 0 { - mapped := ToProjectFlockKandangDTO(e.ProjectFlockKandang) - pfk = &mapped - } return ChickinListDTO{ - ChickinBaseDTO: ToChickinBaseDTO(e), - ProjectFlockKandang: pfk, - CreatedUser: createdUser, - CreatedAt: e.CreatedAt, - UpdatedAt: e.UpdatedAt, + ChickinBaseDTO: ToChickinBaseDTO(e), + CreatedUser: createdUser, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, } } @@ -200,7 +230,53 @@ func ToChickinSimpleDTOs(e []entity.ProjectChickin) []ChickinSimpleDTO { } func ToChickinDetailDTO(e entity.ProjectChickin) ChickinDetailDTO { + var createdUser *userBaseDTO.UserBaseDTO + if e.CreatedUser != nil && e.CreatedUser.Id != 0 { + mapped := userBaseDTO.ToUserBaseDTO(*e.CreatedUser) + createdUser = &mapped + } + return ChickinDetailDTO{ - ChickinListDTO: ToChickinListDTO(e), + Id: e.Id, + ProjectFlockKandangId: e.ProjectFlockKandangId, + ChickInDate: e.ChickInDate, + ProductWarehouseId: e.ProductWarehouseId, + UsageQty: e.UsageQty, + PendingUsageQty: e.PendingUsageQty, + Notes: e.Notes, + CreatedBy: e.CreatedBy, + CreatedUser: createdUser, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, } } + +func ToChickinDetailDTOs(e []entity.ProjectChickin) []ChickinDetailDTO { + result := make([]ChickinDetailDTO, len(e)) + for i, r := range e { + result[i] = ToChickinDetailDTO(r) + } + return result +} + +// === Helper Functions === + +// ToProductWarehouseDTO adalah exported helper untuk mapping ProductWarehouse (shared logic) +func ToProductWarehouseDTO(pw *entity.ProductWarehouse) *ProductWarehouseDTO { + if pw == nil { + return nil + } + + product := productDTO.ToProductBaseDTO(pw.Product) + warehouse := warehouseDTO.ToWarehouseBaseDTO(pw.Warehouse) + + return &ProductWarehouseDTO{ + Id: pw.Id, + Product: &product, + Warehouse: &warehouse, + } +} + +func toProductWarehouseDTO(pw *entity.ProductWarehouse) *ProductWarehouseDTO { + return ToProductWarehouseDTO(pw) +} diff --git a/internal/modules/production/chickins/module.go b/internal/modules/production/chickins/module.go index f1e0baea..f6dd554b 100644 --- a/internal/modules/production/chickins/module.go +++ b/internal/modules/production/chickins/module.go @@ -1,10 +1,15 @@ package chickins 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" rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" @@ -15,6 +20,8 @@ import ( rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + utils "gitlab.com/mbugroup/lti-api.git/internal/utils" ) type ChickinModule struct{} @@ -32,6 +39,12 @@ func (ChickinModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * userRepo := rUser.NewUserRepository(db) + approvalRepo := commonRepo.NewApprovalRepository(db) + approvalService := commonSvc.NewApprovalService(approvalRepo) + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowProjectFlockKandang, utils.ProjectFlockKandangApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register project flock kandang approval workflow: %v", err)) + } + chickinService := sChickin.NewChickinService(chickinRepo, kandangRepo, warehouseRepo, productWarehouseRepo, projectFlockRepo, projectflockkandangrepo, projectflockpopulationrepo, chickinDetailRepo, validate) userService := sUser.NewUserService(userRepo, validate) diff --git a/internal/modules/production/chickins/repositories/project_chickin.repository.go b/internal/modules/production/chickins/repositories/project_chickin.repository.go index 64e2e4b4..a98dab67 100644 --- a/internal/modules/production/chickins/repositories/project_chickin.repository.go +++ b/internal/modules/production/chickins/repositories/project_chickin.repository.go @@ -11,21 +11,26 @@ import ( type ProjectChickinRepository interface { repository.BaseRepository[entity.ProjectChickin] GetFirstByProjectFlockID(ctx context.Context, projectFlockID uint) (*entity.ProjectChickin, error) + GetByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) ([]entity.ProjectChickin, error) + GetPendingByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) ([]entity.ProjectChickin, error) + GetTotalPendingUsageQtyByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) } type ChickinRepositoryImpl struct { *repository.BaseRepositoryImpl[entity.ProjectChickin] + db *gorm.DB } func NewChickinRepository(db *gorm.DB) ProjectChickinRepository { return &ChickinRepositoryImpl{ BaseRepositoryImpl: repository.NewBaseRepository[entity.ProjectChickin](db), + db: db, } } func (r *ChickinRepositoryImpl) GetFirstByProjectFlockID(ctx context.Context, projectFlockID uint) (*entity.ProjectChickin, error) { var chickin entity.ProjectChickin - err := r.DB().WithContext(ctx). + err := r.db.WithContext(ctx). Where("project_floc_id = ?", projectFlockID). Where("deleted_at IS NULL"). First(&chickin).Error @@ -34,3 +39,43 @@ func (r *ChickinRepositoryImpl) GetFirstByProjectFlockID(ctx context.Context, pr } return &chickin, nil } + +func (r *ChickinRepositoryImpl) GetByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) ([]entity.ProjectChickin, error) { + var chickins []entity.ProjectChickin + err := r.db.WithContext(ctx). + Where("project_flock_kandang_id = ?", projectFlockKandangID). + Order("created_at DESC"). + Find(&chickins).Error + if err != nil { + return nil, err + } + return chickins, nil +} + +func (r *ChickinRepositoryImpl) GetPendingByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) ([]entity.ProjectChickin, error) { + var chickins []entity.ProjectChickin + err := r.db.WithContext(ctx). + Where("project_flock_kandang_id = ?", projectFlockKandangID). + Where("usage_qty = 0"). + Where("pending_usage_qty > 0"). + Order("created_at DESC"). + Find(&chickins).Error + if err != nil { + return nil, err + } + return chickins, nil +} + +func (r *ChickinRepositoryImpl) GetTotalPendingUsageQtyByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) { + var total float64 + err := r.db.WithContext(ctx). + Model(&entity.ProjectChickin{}). + Where("project_flock_kandang_id = ?", projectFlockKandangID). + Where("pending_usage_qty > 0"). + Select("COALESCE(SUM(pending_usage_qty), 0)"). + Row().Scan(&total) + if err != nil { + return 0, err + } + return total, nil +} diff --git a/internal/modules/production/chickins/repositories/project_chickin_detail.repository.go b/internal/modules/production/chickins/repositories/project_chickin_detail.repository.go index 42c267ec..f5be8770 100644 --- a/internal/modules/production/chickins/repositories/project_chickin_detail.repository.go +++ b/internal/modules/production/chickins/repositories/project_chickin_detail.repository.go @@ -1,6 +1,8 @@ package repository import ( + "context" + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gorm.io/gorm" @@ -8,6 +10,10 @@ import ( type ProjectChickinDetailRepository interface { repository.BaseRepository[entity.ProjectChickinDetail] + CreateOne(ctx context.Context, entity *entity.ProjectChickinDetail, modifier func(*gorm.DB) *gorm.DB) error + DeleteMany(ctx context.Context, modifier func(*gorm.DB) *gorm.DB) error + GetByProjectChickinID(ctx context.Context, projectChickinID uint) ([]entity.ProjectChickinDetail, error) + WithTxRepo(tx *gorm.DB) ProjectChickinDetailRepository } type ChickinDetailRepositoryImpl struct { @@ -19,3 +25,22 @@ func NewChickinDetailRepository(db *gorm.DB) ProjectChickinDetailRepository { BaseRepositoryImpl: repository.NewBaseRepository[entity.ProjectChickinDetail](db), } } + +func (r *ChickinDetailRepositoryImpl) WithTxRepo(tx *gorm.DB) ProjectChickinDetailRepository { + return &ChickinDetailRepositoryImpl{BaseRepositoryImpl: repository.NewBaseRepository[entity.ProjectChickinDetail](tx)} +} + +func (r *ChickinDetailRepositoryImpl) DB() *gorm.DB { + return r.BaseRepositoryImpl.DB() +} + +func (r *ChickinDetailRepositoryImpl) GetByProjectChickinID(ctx context.Context, projectChickinID uint) ([]entity.ProjectChickinDetail, error) { + var records []entity.ProjectChickinDetail + if err := r.DB().WithContext(ctx).Where("project_chickin_id = ?", projectChickinID).Find(&records).Error; err != nil { + return nil, err + } + if len(records) == 0 { + return nil, gorm.ErrRecordNotFound + } + return records, nil +} diff --git a/internal/modules/production/chickins/route.go b/internal/modules/production/chickins/route.go index 25879bc2..a558dd29 100644 --- a/internal/modules/production/chickins/route.go +++ b/internal/modules/production/chickins/route.go @@ -15,10 +15,10 @@ func ChickinRoutes(v1 fiber.Router, u user.UserService, s chickin.ChickinService route := v1.Group("/chickins") route.Use(m.Auth(u)) - route.Get("/", ctrl.GetAll) + // route.Get("/", ctrl.GetAll) route.Post("/", ctrl.CreateOne) route.Get("/:id", ctrl.GetOne) - route.Patch("/:id", ctrl.UpdateOne) - route.Delete("/:id", ctrl.DeleteOne) - route.Post("/:id/approve", ctrl.Approve) + // route.Patch("/:id", ctrl.UpdateOne) + // route.Delete("/:id", ctrl.DeleteOne) + route.Post("/approvals", ctrl.Approval) } diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 5a6f4e71..a130740a 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -2,7 +2,11 @@ package service import ( "errors" + "fmt" + "strings" + 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" KandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" @@ -21,10 +25,10 @@ import ( type ChickinService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectChickin, int64, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectChickin, error) - CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.ProjectChickin, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) ([]entity.ProjectChickin, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.ProjectChickin, error) DeleteOne(ctx *fiber.Ctx, id uint) error - Approve(ctx *fiber.Ctx, id uint) error + Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectChickin, error) } type chickinService struct { @@ -76,6 +80,7 @@ func (s chickinService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity } offset := (params.Page - 1) * params.Limit + chickins, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.ProjectFlockKandangId != 0 { @@ -103,112 +108,159 @@ func (s chickinService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectChickin, e return chickin, nil } -func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.ProjectChickin, error) { +func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]entity.ProjectChickin, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } - projectflockkandang, err := s.ProjectflockKandangRepo.GetByID(c.Context(), req.ProjectFlockKandangId) + projectFlockKandang, err := s.ProjectflockKandangRepo.GetByID(c.Context(), req.ProjectFlockKandangId) if err != nil { - s.Log.Errorf("Failed to get projectflock kandang: %+v", err) - return nil, err + return nil, fiber.NewError(fiber.StatusNotFound, "Project Flock Kandang not found") } - warehouse, err := s.WarehouseRepo.GetByKandangID(c.Context(), projectflockkandang.KandangId) + warehouse, err := s.WarehouseRepo.GetByKandangID(c.Context(), projectFlockKandang.KandangId) if err != nil { - s.Log.Errorf("Failed to get warehouse: %+v", err) - return nil, err + return nil, fiber.NewError(fiber.StatusNotFound, "Warehouse for Kandang not found") } - // 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 - } - if len(productWarehouses) == 0 { - return nil, fiber.NewError(fiber.StatusNotFound, "Product Warehouse not found for the given Project Flock and Warehouse") - } - totalQuantity := 0.0 - for _, pw := range productWarehouses { - totalQuantity += pw.Quantity - } + category := strings.ToUpper(strings.TrimSpace(projectFlockKandang.ProjectFlock.Category)) - if totalQuantity < 1 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Insufficient quantity in Product Warehouses") - } + actorID := uint(1) // todo nanti ambil dari auth context + newChikins := make([]*entity.ProjectChickin, 0) - chickinDate, err := utils.ParseDateString(req.ChickInDate) - if err != nil { - s.Log.Errorf("Failed to parse chickin date: %+v", err) - return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid ChickInDate format") - } - newChickin := &entity.ProjectChickin{ - ProjectFlockKandangId: projectflockkandang.Id, - ChickInDate: chickinDate, - Quantity: totalQuantity, - Note: req.Note, - CreatedBy: 1, //todo: ganti dengan user login - } - err = s.Repository.CreateOne(c.Context(), newChickin, nil) - if err != nil { - s.Log.Errorf("Failed to create chickin: %+v", err) - return nil, err - } + for _, chickinReq := range req.ChickinRequests { - // Update semua product warehouse: set quantity jadi 0 - for _, pw := range productWarehouses { - err = s.ProductWarehouseRepo.PatchOne(c.Context(), pw.Id, map[string]any{ - "quantity": 0, - }, nil) + productWarehouse, err := s.ProductWarehouseRepo.GetByID(c.Context(), chickinReq.ProductWarehouseId, nil) if err != nil { - s.Log.Errorf("Failed to update product warehouse quantity: %+v", err) - return nil, err + return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Product warehouse %d not found", chickinReq.ProductWarehouseId)) } - newChickinDetail := &entity.ProjectChickinDetail{ - ProjectChickinId: newChickin.Id, - ProductWarehouseId: pw.Id, - Quantity: pw.Quantity, - CreatedBy: 1, // todo: ganti dengan user login + if productWarehouse.WarehouseId != warehouse.Id { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Product warehouse %d is not bound to kandang's warehouse", chickinReq.ProductWarehouseId)) } - err = s.ProjectChickinDetailRepo.CreateOne(c.Context(), newChickinDetail, nil) + + chickinDate, err := utils.ParseDateString(chickinReq.ChickInDate) if err != nil { - s.Log.Errorf("Failed to create chickin detail: %+v", err) - return nil, err + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid ChickInDate format for product warehouse %d", chickinReq.ProductWarehouseId)) } - } - existingPopulation, err := s.ProjectflockPopulationRepo.GetByProjectFlockKandangID(c.Context(), req.ProjectFlockKandangId) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - s.Log.Errorf("Failed to get project flock population: %+v", err) - return nil, err - } - if existingPopulation != nil { - - err = s.ProjectflockPopulationRepo.PatchOne(c.Context(), existingPopulation.Id, map[string]any{ - "reserved_quantity": newChickin.Quantity + existingPopulation.ReservedQuantity, - }, nil) + availableQty, err := s.calculateAvailableQuantity(c, req.ProjectFlockKandangId, productWarehouse, category) if err != nil { - s.Log.Errorf("Failed to update project flock population: %+v", err) - return nil, err + return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to calculate available quantity for product warehouse %d", chickinReq.ProductWarehouseId)) } - } else { - newPopulation := &entity.ProjectFlockPopulation{ + + if availableQty <= 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("No available quantity for product warehouse %d", chickinReq.ProductWarehouseId)) + } + + newChickin := &entity.ProjectChickin{ ProjectFlockKandangId: req.ProjectFlockKandangId, - InitialQuantity: 0, - CurrentQuantity: 0, - ReservedQuantity: newChickin.Quantity, - CreatedBy: 1, // todo: ganti dengan user login - } - err = s.ProjectflockPopulationRepo.CreateOne(c.Context(), newPopulation, nil) - if err != nil { - s.Log.Errorf("Failed to create project flock population: %+v", err) - return nil, err + ChickInDate: chickinDate, + UsageQty: 0, + PendingUsageQty: availableQty, + ProductWarehouseId: chickinReq.ProductWarehouseId, + Notes: chickinReq.Note, + CreatedBy: actorID, } + + newChikins = append(newChikins, newChickin) } - return s.GetOne(c, newChickin.Id) + if len(newChikins) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "No chickins to create") + } + + existingChikins, err := s.Repository.GetByProjectFlockKandangID(c.Context(), req.ProjectFlockKandangId) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check existing chickins") + } + + isFirstTime := len(existingChikins) == 0 + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + + approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) + productWarehouseTx := s.ProductWarehouseRepo.WithTx(dbTransaction) + + if err := s.Repository.WithTx(dbTransaction).CreateMany(c.Context(), newChikins, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create chickins") + } + + latest, err := approvalSvcTx.LatestByTarget(c.Context(), utils.ApprovalWorkflowProjectFlockKandang, projectFlockKandang.Id, nil) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get latest approval") + } + + if category == string(utils.ProjectFlockCategoryLaying) { + for _, chickin := range newChikins { + updates := map[string]any{"quantity": gorm.Expr("quantity - ?", chickin.PendingUsageQty)} + + if err := productWarehouseTx.PatchOne(c.Context(), chickin.ProductWarehouseId, updates, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Product warehouse %d not found", chickin.ProductWarehouseId)) + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update product warehouse quantity") + } + } + } + + var approvalAction entity.ApprovalAction + if isFirstTime { + approvalAction = entity.ApprovalActionCreated + } else { + approvalAction = entity.ApprovalActionUpdated + } + + if latest == nil { + if _, err := approvalSvcTx.CreateApproval( + c.Context(), + utils.ApprovalWorkflowProjectFlockKandang, + projectFlockKandang.Id, + utils.ProjectFlockKandangStepPengajuan, + &approvalAction, + actorID, + nil); err != nil { + if !errors.Is(err, gorm.ErrDuplicatedKey) { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create approval") + } + } + } else if latest.StepNumber != uint16(utils.ProjectFlockKandangStepPengajuan) { + if _, err := approvalSvcTx.CreateApproval( + c.Context(), + utils.ApprovalWorkflowProjectFlockKandang, + projectFlockKandang.Id, + utils.ProjectFlockKandangStepPengajuan, + &approvalAction, + actorID, + nil); err != nil { + if !errors.Is(err, gorm.ErrDuplicatedKey) { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create approval") + } + } + } + + return nil + }) + if err != nil { + if fiberErr, ok := err.(*fiber.Error); ok { + return nil, fiberErr + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create chickins") + } + + result := make([]entity.ProjectChickin, 0, len(newChikins)) + for _, chickin := range newChikins { + loaded, err := s.GetOne(c, chickin.Id) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to reload chickin %d with relations: %v", chickin.Id, err)) + } + result = append(result, *loaded) + } + if len(result) == 0 { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load created chickins") + } + + return result, nil } func (s chickinService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.ProjectChickin, error) { @@ -222,7 +274,7 @@ func (s chickinService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) updateBody["chick_in_date"] = req.ChickInDate } if req.Note != "" { - updateBody["note"] = req.Note + updateBody["notes"] = req.Note } if len(updateBody) == 0 { return s.GetOne(c, id) @@ -240,174 +292,335 @@ func (s chickinService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) } func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { - db := s.Repository.DB() - tx := db.WithContext(c.Context()).Begin() - if tx.Error != nil { - s.Log.Errorf("Failed to begin transaction: %+v", tx.Error) - return tx.Error - } - rollback := func(err error) error { - if rerr := tx.Rollback().Error; rerr != nil { - s.Log.Errorf("Rollback failed: %+v", rerr) + if err := s.Repository.DeleteOne(c.Context(), id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Chickin not found") } return err } - chickinRepoTx := s.Repository.WithTx(tx) - pfkRepoTx := s.ProjectflockKandangRepo.WithTx(tx) - productWarehouseRepoTx := s.ProductWarehouseRepo.WithTx(tx) + return nil +} - chickin, err := chickinRepoTx.GetByID(c.Context(), id, nil) - if errors.Is(err, gorm.ErrRecordNotFound) { - return rollback(fiber.NewError(fiber.StatusNotFound, "Chickin not found")) - } - if err != nil { - s.Log.Errorf("Failed get chickin by id: %+v", err) - return rollback(err) - } +func (s chickinService) calculateAvailableQuantity(ctx *fiber.Ctx, projectFlockKandangID uint, productWarehouse *entity.ProductWarehouse, category string) (float64, error) { + availableQty := productWarehouse.Quantity - var population entity.ProjectFlockPopulation - if err := tx.WithContext(c.Context()).Where("project_flock_kandang_id = ?", chickin.ProjectFlockKandangId).First(&population).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return rollback(fiber.NewError(fiber.StatusNotFound, "Project flock population not found")) - } - s.Log.Errorf("Failed to get project flock population: %+v", err) - return rollback(err) - } + if category == string(utils.ProjectFlockCategoryGrowing) { + var totalPendingQty float64 - newReserved := population.ReservedQuantity - chickin.Quantity - if newReserved < 0 { - newReserved = 0 - } - if err := tx.WithContext(c.Context()).Model(&entity.ProjectFlockPopulation{}).Where("id = ?", population.Id).Updates(map[string]any{"reserved_quantity": newReserved}).Error; err != nil { - s.Log.Errorf("Failed to update project flock population: %+v", err) - return rollback(err) - } + chickins, err := s.Repository.GetByProjectFlockKandangID(ctx.Context(), projectFlockKandangID) + if err == nil { + for _, chickin := range chickins { - 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 { - return false, err - } - if len(details) == 0 { - return false, nil + if chickin.ProductWarehouseId == productWarehouse.Id && chickin.DeletedAt.Time.IsZero() && chickin.PendingUsageQty > 0 { + totalPendingQty += chickin.PendingUsageQty + } + } } - for _, d := range details { - var pw entity.ProductWarehouse - if err := tx.WithContext(c.Context()).Where("id = ?", d.ProductWarehouseId).First(&pw).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { + availableQty = productWarehouse.Quantity - totalPendingQty + if availableQty < 0 { + availableQty = 0 + } + } else if category == string(utils.ProjectFlockCategoryLaying) { + var totalPopulation float64 + var totalPendingQty float64 + populations, err := s.ProjectflockPopulationRepo.GetByProjectFlockKandangIDAndProductWarehouseID(ctx.Context(), projectFlockKandangID, productWarehouse.Id) + if err == nil { + for _, pop := range populations { + totalPopulation += pop.TotalQty + } + } + chickins, err := s.Repository.GetByProjectFlockKandangID(ctx.Context(), projectFlockKandangID) + if err == nil { + for _, chickin := range chickins { + + if chickin.ProductWarehouseId == productWarehouse.Id && chickin.DeletedAt.Time.IsZero() && chickin.PendingUsageQty > 0 { + totalPendingQty += chickin.PendingUsageQty + } + } + } + availableQty = productWarehouse.Quantity - totalPopulation - totalPendingQty + if availableQty < 0 { + availableQty = 0 + } + } + + return availableQty, nil +} + +func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entity.ProjectChickin, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(s.Repository.DB())) + + var action entity.ApprovalAction + switch strings.ToUpper(strings.TrimSpace(req.Action)) { + case string(entity.ApprovalActionRejected): + action = entity.ApprovalActionRejected + case string(entity.ApprovalActionApproved): + action = entity.ApprovalActionApproved + default: + return nil, fiber.NewError(fiber.StatusBadRequest, "action must be APPROVED or REJECTED") + } + + approvableIDs := utils.UniqueUintSlice(req.ApprovableIds) + if len(approvableIDs) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "approvable_ids must contain at least one id") + } + + for _, id := range approvableIDs { + idCopy := id + if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "ProjectFlockKandang", ID: &idCopy, Exists: s.ProjectflockKandangRepo.IdExists}); err != nil { + return nil, err + } + + latestApproval, err := approvalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowProjectFlockKandang, id, nil) + + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check approval status") + } + if latestApproval == nil { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("No approval found for ProjectFlockKandang %d - chickins must be created first", id)) + } + if latestApproval.StepNumber != uint16(utils.ProjectFlockKandangStepPengajuan) { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("ProjectFlockKandang %d cannot be approved - current status is not in PENGAJUAN stage", id)) + } + } + + step := utils.ProjectFlockKandangStepPengajuan + if action == entity.ApprovalActionApproved { + step = utils.ProjectFlockKandangStepDisetujui + } + + err := s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + + approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) + chickinRepoTx := repository.NewChickinRepository(dbTransaction) + productWarehouseTx := s.ProductWarehouseRepo.WithTx(dbTransaction) + + for _, approvableID := range approvableIDs { + actorID := uint(1) // todo nanti ambil dari auth context + if _, err := approvalSvc.CreateApproval( + c.Context(), + utils.ApprovalWorkflowProjectFlockKandang, + approvableID, + step, + &action, + actorID, + req.Notes, + ); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create approval") + } + + if action == entity.ApprovalActionApproved { + chickins, err := chickinRepoTx.GetByProjectFlockKandangID(c.Context(), approvableID) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to get chickins for approval %d", approvableID)) + } + + kandangForApproval, err := s.ProjectflockKandangRepo.GetByID(c.Context(), approvableID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("ProjectFlockKandang %d not found", approvableID)) + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get ProjectFlockKandang") + } + + category := strings.ToUpper(strings.TrimSpace(kandangForApproval.ProjectFlock.Category)) + + if category == string(utils.ProjectFlockCategoryGrowing) { + warehouse, err := s.WarehouseRepo.GetByKandangID(c.Context(), kandangForApproval.KandangId) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Warehouse for kandang %d not found", kandangForApproval.KandangId)) + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get warehouse") + } + + targetPW, err := s.getOrCreateProductWarehouse(c, warehouse.Id, "PULLET", dbTransaction, actorID) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get/create PULLET product warehouse") + } + if err := s.convertChickinsToTarget(c, chickins, targetPW, dbTransaction, actorID); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to convert chickins to target") + } + } else if category == string(utils.ProjectFlockCategoryLaying) { + warehouse, err := s.WarehouseRepo.GetByKandangID(c.Context(), kandangForApproval.KandangId) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Warehouse for kandang %d not found", kandangForApproval.KandangId)) + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get warehouse") + } + + targetPW, err := s.getOrCreateProductWarehouse(c, warehouse.Id, "PULLET", dbTransaction, actorID) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get/create PULLET product warehouse") + } + if err := s.convertChickinsToTarget(c, chickins, targetPW, dbTransaction, actorID); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to convert chickins to target") + } + } + } + if action == entity.ApprovalActionRejected { + chickins, err := chickinRepoTx.GetPendingByProjectFlockKandangID(c.Context(), approvableID) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to get pending chickins for rejection %d", approvableID)) + } + + if len(chickins) == 0 { continue } - return false, err - } - updatedQuantity := pw.Quantity + d.Quantity - if err := productWarehouseRepoTx.PatchOne(c.Context(), pw.Id, map[string]any{"quantity": updatedQuantity}, nil); err != nil { - return false, err + kandangForRejection, err := s.ProjectflockKandangRepo.GetByID(c.Context(), approvableID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("ProjectFlockKandang %d not found", approvableID)) + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get ProjectFlockKandang") + } + + categoryForRejection := strings.ToUpper(strings.TrimSpace(kandangForRejection.ProjectFlock.Category)) + + for _, chickin := range chickins { + + if categoryForRejection == string(utils.ProjectFlockCategoryGrowing) { + updates := map[string]any{"quantity": gorm.Expr("quantity + ?", chickin.PendingUsageQty)} + + if err := productWarehouseTx.PatchOne(c.Context(), chickin.ProductWarehouseId, updates, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Product warehouse %d not found during rejection", chickin.ProductWarehouseId)) + } + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to restore product warehouse quantity for chickin %d", chickin.Id)) + } + } + + if err := chickinRepoTx.DeleteOne(c.Context(), chickin.Id); err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to delete rejected chickin %d", chickin.Id)) + } + } + } } } + return nil + }) - if err := tx.WithContext(c.Context()).Where("project_chickin_id = ?", chickin.Id).Delete(&entity.ProjectChickinDetail{}).Error; err != nil { - return false, err - } - return true, nil - } - - restored, err := restoreFromDetails() if err != nil { - s.Log.Errorf("Failed to restore from chickin details: %+v", err) - return rollback(err) + if fiberErr, ok := err.(*fiber.Error); ok { + return nil, fiberErr + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to record approval") } - if !restored { + updated := make([]entity.ProjectChickin, 0) + for _, kandangID := range approvableIDs { + var chickins []entity.ProjectChickin + if err := s.Repository.DB().WithContext(c.Context()).Where("project_flock_kandang_id = ?", kandangID).Find(&chickins).Error; err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load approved chickins") + } + updated = append(updated, chickins...) + } - projectflockkandang, err := pfkRepoTx.GetByID(c.Context(), population.ProjectFlockKandangId) + return updated, nil +} + +func (s *chickinService) getOrCreateProductWarehouse(ctx *fiber.Ctx, warehouseId uint, categoryCode string, dbTransaction *gorm.DB, actorID uint) (*entity.ProductWarehouse, error) { + + products, err := s.ProductWarehouseRepo.GetByFlagAndWarehouseID(ctx.Context(), categoryCode, warehouseId) + if err == nil && len(products) > 0 { + return &products[0], nil + } + + product, err := s.ProductWarehouseRepo.GetFirstProductByFlag(ctx.Context(), categoryCode) + if err != nil { + return nil, fmt.Errorf("failed to get %s product: %w", categoryCode, err) + } + if product == nil { + return nil, fmt.Errorf("no %s product found in system", categoryCode) + } + + newPW := &entity.ProductWarehouse{ + ProductId: product.Id, + WarehouseId: warehouseId, + Quantity: 0, + CreatedBy: actorID, + } + + if err := s.ProductWarehouseRepo.WithTx(dbTransaction).CreateOne(ctx.Context(), newPW, nil); err != nil { + return nil, fmt.Errorf("failed to create %s product warehouse: %w", categoryCode, err) + } + + return newPW, nil +} + +func (s *chickinService) convertChickinsToTarget(ctx *fiber.Ctx, chickins []entity.ProjectChickin, targetPW *entity.ProductWarehouse, dbTransaction *gorm.DB, actorID uint) error { + + if targetPW == nil || targetPW.Id == 0 { + return fmt.Errorf("invalid target product warehouse") + } + + chickinRepoTx := repository.NewChickinRepository(dbTransaction) + productWarehouseTx := s.ProductWarehouseRepo.WithTx(dbTransaction) + ProjectFlockPopulationRepotx := s.ProjectflockPopulationRepo.WithTx(dbTransaction) + + for _, chickin := range chickins { + + populationExists, err := ProjectFlockPopulationRepotx.ExistsByProjectChickinID(ctx.Context(), chickin.Id) if err != nil { - s.Log.Errorf("Failed to get projectflock kandang: %+v", err) - return rollback(err) + return fmt.Errorf("failed to check population existence for chickin %d: %w", chickin.Id, err) } - var warehouse entity.Warehouse - if err := tx.WithContext(c.Context()).Where("kandang_id = ?", projectflockkandang.KandangId).First(&warehouse).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return rollback(fiber.NewError(fiber.StatusNotFound, "Warehouse not found for kandang")) + if populationExists { + s.Log.Infof("population already exists for chickin %d, skipping", chickin.Id) + continue + } + + quantityToConvert := chickin.PendingUsageQty + + if err := chickinRepoTx.PatchOne(ctx.Context(), chickin.Id, map[string]any{ + "usage_qty": quantityToConvert, + "pending_usage_qty": 0, + }, nil); err != nil { + return fmt.Errorf("failed to update chickin %d qty: %w", chickin.Id, err) + } + + if chickin.ProductWarehouseId != targetPW.Id { + if err := productWarehouseTx.PatchOne(ctx.Context(), chickin.ProductWarehouseId, map[string]any{ + "quantity": gorm.Expr("quantity - ?", quantityToConvert), + }, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Source product warehouse %d not found", chickin.ProductWarehouseId)) + } + return fmt.Errorf("failed to deduct source warehouse quantity for chickin %d: %w", chickin.Id, err) } - s.Log.Errorf("Failed to get warehouse: %+v", err) - return rollback(err) } - productWarehouse, err := s.ProductWarehouseRepo.GetLatestByCategoryCodeAndWarehouseID( - c.Context(), - "DOC", - warehouse.Id, - tx, - ) - if err != nil { + if err := productWarehouseTx.PatchOne(ctx.Context(), targetPW.Id, map[string]any{ + "quantity": gorm.Expr("quantity + ?", quantityToConvert), + }, nil); 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")) + return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Target product warehouse %d not found", targetPW.Id)) } - s.Log.Errorf("Failed to get product warehouse: %+v", err) - return rollback(err) + return fmt.Errorf("failed to update target warehouse quantity: %w", err) } - updatedQuantity := productWarehouse.Quantity + chickin.Quantity - if err := productWarehouseRepoTx.PatchOne(c.Context(), productWarehouse.Id, map[string]any{"quantity": updatedQuantity}, nil); err != nil { - s.Log.Errorf("Failed to update product warehouse quantity: %+v", err) - return rollback(err) + population := &entity.ProjectFlockPopulation{ + ProjectChickinId: chickin.Id, + ProductWarehouseId: targetPW.Id, + TotalQty: quantityToConvert, + TotalUsedQty: 0, + Notes: chickin.Notes, + CreatedBy: actorID, } - } - - // delete chickin (single place) - if err := chickinRepoTx.DeleteOne(c.Context(), id); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return rollback(fiber.NewError(fiber.StatusNotFound, "Chickin not found")) + if err := ProjectFlockPopulationRepotx.CreateOne(ctx.Context(), population, nil); err != nil { + return err } - s.Log.Errorf("Failed to delete chickin: %+v", err) - return rollback(err) - } - - if err := tx.Commit().Error; err != nil { - s.Log.Errorf("Failed to commit transaction: %+v", err) - return rollback(err) - } - - return nil -} - -func (s *chickinService) Approve(c *fiber.Ctx, id uint) error { - - // todo: ini contoh akhir jika sudah approved - - chickin, err := s.Repository.GetByID( - c.Context(), - id, - nil, - ) - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "Chickin not found") - } - if err != nil { - s.Log.Errorf("Failed get chickin by id: %+v", err) - return err - } - - population, err := s.ProjectflockPopulationRepo.GetByProjectFlockKandangID(c.Context(), chickin.ProjectFlockKandangId) - if err != nil { - s.Log.Errorf("Failed to get project flock population: %+v", err) - return err - } - - err = s.ProjectflockPopulationRepo.PatchOne(c.Context(), population.Id, map[string]any{ - "reserved_quantity": population.ReservedQuantity - chickin.Quantity, - "initial_quantity": population.InitialQuantity + chickin.Quantity, - "current_quantity": population.CurrentQuantity + chickin.Quantity, - }, nil) - if err != nil { - s.Log.Errorf("Failed to update project flock population: %+v", err) - return err } return nil diff --git a/internal/modules/production/chickins/validations/chickin.validation.go b/internal/modules/production/chickins/validations/chickin.validation.go index 9747ee07..ebc6487f 100644 --- a/internal/modules/production/chickins/validations/chickin.validation.go +++ b/internal/modules/production/chickins/validations/chickin.validation.go @@ -1,9 +1,14 @@ 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` + ProjectFlockKandangId uint `json:"project_flock_kandang_id" validate:"required,number,min=1"` + ChickinRequests []ChickinRequestItem `json:"chickin_requests" validate:"required,min=1,dive"` +} + +type ChickinRequestItem struct { + ChickInDate string `json:"chick_in_date" validate:"required,datetime=2006-01-02"` + ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"` + Note string `json:"note" validate:"omitempty"` } type Update struct { @@ -16,3 +21,9 @@ type Query struct { Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"` } + +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/production/project-flock-kandangs/controllers/project_flock_kandang.controller.go b/internal/modules/production/project-flock-kandangs/controllers/project_flock_kandang.controller.go new file mode 100644 index 00000000..d65cedfd --- /dev/null +++ b/internal/modules/production/project-flock-kandangs/controllers/project_flock_kandang.controller.go @@ -0,0 +1,93 @@ +package controller + +import ( + "math" + "strconv" + + flockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/dto" + "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project-flock-kandangs/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project-flock-kandangs/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project-flock-kandangs/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type ProjectFlockKandangController struct { + ProjectFlockKandangService service.ProjectFlockKandangService +} + +func NewProjectFlockKandangController(projectFlockKandangService service.ProjectFlockKandangService) *ProjectFlockKandangController { + return &ProjectFlockKandangController{ + ProjectFlockKandangService: projectFlockKandangService, + } +} + +func (u *ProjectFlockKandangController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + ProjectFlockId: uint(c.QueryInt("project_flock_id", 0)), + KandangId: uint(c.QueryInt("kandang_id", 0)), + Category: c.Query("category", ""), + AreaId: uint(c.QueryInt("area_id", 0)), + SortBy: c.Query("sort_by", ""), + SortOrder: c.Query("sort_order", ""), + StepName: c.Query("step_name", ""), + } + + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + results, totalResults, flockMap, err := u.ProjectFlockKandangService.GetAll(c, query) + if err != nil { + return err + } + + data := make([]dto.ProjectFlockKandangListDTO, 0) + for _, result := range results { + var flock *flockDTO.FlockBaseDTO + if flockMap != nil { + flock = flockMap[result.ProjectFlock.Id] + } + data = append(data, dto.ToProjectFlockKandangListDTOWithFlock(result, flock)) + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.ProjectFlockKandangListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all projectFlockKandangs successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: data, + }) +} + +func (u *ProjectFlockKandangController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, availableQtys, productWarehouses, flock, err := u.ProjectFlockKandangService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get projectFlockKandang successfully", + Data: dto.ToProjectFlockKandangDetailDTOWithAvailableQtyAndFlock(*result, availableQtys, productWarehouses, flock), + }) +} diff --git a/internal/modules/production/project-flock-kandangs/dto/project_flock_kandang.dto.go b/internal/modules/production/project-flock-kandangs/dto/project_flock_kandang.dto.go new file mode 100644 index 00000000..1a076232 --- /dev/null +++ b/internal/modules/production/project-flock-kandangs/dto/project_flock_kandang.dto.go @@ -0,0 +1,268 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" + areaDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto" + fcrDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs/dto" + flockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/dto" + locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto" + productDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/dto" + warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" + chickinDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/dto" + projectFlockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/dto" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" +) + +// === DTO Structs (ordered) === + +type ProjectFlockKandangBaseDTO struct { + Id uint `json:"id"` + KandangId uint `json:"kandang_id"` + ProjectFlockId uint `json:"project_flock_id"` +} + +type ProjectFlockDTO struct { + Id uint `json:"id"` + Name string `json:"flock_name,omitempty"` + Period int `json:"period"` + Flock *flockDTO.FlockBaseDTO `json:"flock,omitempty"` + Area *areaDTO.AreaBaseDTO `json:"area,omitempty"` + Category string `json:"category"` + Fcr *fcrDTO.FcrBaseDTO `json:"fcr,omitempty"` + Location *locationDTO.LocationBaseDTO `json:"location,omitempty"` + CreatedUser *userDTO.UserBaseDTO `json:"created_user,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type KandangDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + Status string `json:"status"` +} + +type ProductWarehouseDTO struct { + Id uint `json:"id"` + Product *productDTO.ProductBaseDTO `json:"product,omitempty"` + Warehouse *warehouseDTO.WarehouseBaseDTO `json:"warehouse,omitempty"` +} + +type AvailableQtyDTO struct { + AvailableQty float64 `json:"available_qty"` + ProductWarehouse *ProductWarehouseDTO `json:"product_warehouse,omitempty"` +} + +type ProjectFlockKandangListDTO struct { + ProjectFlockKandangBaseDTO + ProjectFlock *ProjectFlockDTO `json:"project_flock,omitempty"` + Kandang *KandangDTO `json:"kandang,omitempty"` + CreatedUser *userDTO.UserBaseDTO `json:"created_user,omitempty"` + CreatedAt time.Time `json:"created_at"` + Approval *approvalDTO.ApprovalBaseDTO `json:"approval,omitempty"` +} + +type ProjectFlockKandangDetailDTO struct { + ProjectFlockKandangListDTO + Chickins []chickinDTO.ChickinBaseDTO `json:"chickins,omitempty"` + AvailableQtys []AvailableQtyDTO `json:"available_qtys,omitempty"` +} + +// === Mapper Functions (ordered) === + +func ToProjectFlockKandangBaseDTO(e entity.ProjectFlockKandang) ProjectFlockKandangBaseDTO { + return ProjectFlockKandangBaseDTO{ + Id: e.Id, + KandangId: e.KandangId, + ProjectFlockId: e.ProjectFlockId, + } +} + +func toProjectFlockDTO(pf *projectFlockDTO.ProjectFlockListDTO) *ProjectFlockDTO { + if pf == nil { + return nil + } + + return &ProjectFlockDTO{ + Id: pf.Id, + Name: pf.FlockName, + Period: pf.Period, + Flock: pf.Flock, + Area: pf.Area, + Category: pf.Category, + Fcr: pf.Fcr, + Location: pf.Location, + CreatedUser: pf.CreatedUser, + CreatedAt: pf.CreatedAt, + UpdatedAt: pf.UpdatedAt, + } +} + +func ToProjectFlockKandangDetailDTOWithAvailableQty(e entity.ProjectFlockKandang, availableQtyMap map[uint]float64, productWarehouses []entity.ProductWarehouse) ProjectFlockKandangDetailDTO { + return ToProjectFlockKandangDetailDTOWithAvailableQtyAndFlock(e, availableQtyMap, productWarehouses, nil) +} + +func ToProjectFlockKandangDetailDTOWithAvailableQtyAndFlock(e entity.ProjectFlockKandang, availableQtyMap map[uint]float64, productWarehouses []entity.ProductWarehouse, flock *flockDTO.FlockBaseDTO) ProjectFlockKandangDetailDTO { + var projectFlockSummary *projectFlockDTO.ProjectFlockListDTO + if e.ProjectFlock.Id != 0 { + mapped := projectFlockDTO.ToProjectFlockListDTO(e.ProjectFlock, flock) + projectFlockSummary = &mapped + } + + listDTO := ProjectFlockKandangListDTO{ + ProjectFlockKandangBaseDTO: ToProjectFlockKandangBaseDTO(e), + ProjectFlock: toProjectFlockDTO(projectFlockSummary), + Kandang: toKandangDTO(e.Kandang), + CreatedAt: e.CreatedAt, + CreatedUser: toCreatedUserDTO(e.ProjectFlock), + Approval: toApprovalDTO(e), + } + + return ProjectFlockKandangDetailDTO{ + ProjectFlockKandangListDTO: listDTO, + Chickins: toChickinDTOs(e.Chickins), + AvailableQtys: toAvailableQtyDTOsFromMap(e.Chickins, availableQtyMap, productWarehouses), + } +} + +func toKandangDTO(kandang entity.Kandang) *KandangDTO { + if kandang.Id == 0 { + return nil + } + + return &KandangDTO{ + Id: kandang.Id, + Name: kandang.Name, + Status: kandang.Status, + } +} + +func toFlockDTO(flock *entity.Flock) *flockDTO.FlockBaseDTO { + if flock == nil || flock.Id == 0 { + return nil + } + + return &flockDTO.FlockBaseDTO{ + Id: flock.Id, + Name: flock.Name, + } +} + +func toApprovalDTO(e entity.ProjectFlockKandang) *approvalDTO.ApprovalBaseDTO { + if e.LatestApproval != nil { + mapped := approvalDTO.ToApprovalDTO(*e.LatestApproval) + return &mapped + } + return nil +} + +func ToProjectFlockKandangListDTO(e entity.ProjectFlockKandang) ProjectFlockKandangListDTO { + return ToProjectFlockKandangListDTOWithFlock(e, nil) +} + +func ToProjectFlockKandangListDTOWithFlock(e entity.ProjectFlockKandang, flock *flockDTO.FlockBaseDTO) ProjectFlockKandangListDTO { + var projectFlockSummary *projectFlockDTO.ProjectFlockListDTO + if e.ProjectFlock.Id != 0 { + mapped := projectFlockDTO.ToProjectFlockListDTOWithFlock(e.ProjectFlock, flock) + projectFlockSummary = &mapped + } + + return ProjectFlockKandangListDTO{ + ProjectFlockKandangBaseDTO: ToProjectFlockKandangBaseDTO(e), + ProjectFlock: toProjectFlockDTO(projectFlockSummary), + Kandang: toKandangDTO(e.Kandang), + CreatedAt: e.CreatedAt, + CreatedUser: toCreatedUserDTO(e.ProjectFlock), + Approval: toApprovalDTO(e), + } +} + +func ToProjectFlockKandangListDTOs(e []entity.ProjectFlockKandang) []ProjectFlockKandangListDTO { + result := make([]ProjectFlockKandangListDTO, len(e)) + for i, r := range e { + result[i] = ToProjectFlockKandangListDTO(r) + } + return result +} + +func toCreatedUserDTO(pf entity.ProjectFlock) *userDTO.UserBaseDTO { + if pf.CreatedUser.Id != 0 { + mapped := userDTO.ToUserBaseDTO(pf.CreatedUser) + return &mapped + } else if pf.CreatedBy != 0 { + return &userDTO.UserBaseDTO{ + Id: pf.CreatedBy, + IdUser: int64(pf.CreatedBy), + } + } + return nil +} + +func toChickinDTOs(chickins []entity.ProjectChickin) []chickinDTO.ChickinBaseDTO { + if len(chickins) == 0 { + return nil + } + + result := make([]chickinDTO.ChickinBaseDTO, len(chickins)) + for i, ch := range chickins { + result[i] = chickinDTO.ToChickinBaseDTO(ch) + } + return result +} + +func toAvailableQtyDTOsFromMap(chickins []entity.ProjectChickin, availableQtyMap map[uint]float64, productWarehouses []entity.ProductWarehouse) []AvailableQtyDTO { + if len(availableQtyMap) == 0 { + return nil + } + + // First, build map from chickins + pwMap := make(map[uint]*entity.ProductWarehouse) + for _, chickin := range chickins { + if chickin.ProductWarehouse != nil && chickin.ProductWarehouse.Id != 0 { + pwMap[chickin.ProductWarehouseId] = chickin.ProductWarehouse + } + } + + // Then, add productWarehouses that are not in chickins yet + for i := range productWarehouses { + if _, exists := pwMap[productWarehouses[i].Id]; !exists { + pwMap[productWarehouses[i].Id] = &productWarehouses[i] + } + } + + result := make([]AvailableQtyDTO, 0, len(availableQtyMap)) + for pwId, availableQty := range availableQtyMap { + pw, exists := pwMap[pwId] + if !exists || pw == nil { + continue + } + + pwDTO := ToProductWarehouseDTO(pw) + + result = append(result, AvailableQtyDTO{ + AvailableQty: availableQty, + ProductWarehouse: pwDTO, + }) + } + + return result +} + +func ToProductWarehouseDTO(pw *entity.ProductWarehouse) *ProductWarehouseDTO { + if pw == nil { + return nil + } + + chickinPwDTO := chickinDTO.ToProductWarehouseDTO(pw) + if chickinPwDTO == nil { + return nil + } + + return &ProductWarehouseDTO{ + Id: chickinPwDTO.Id, + Product: chickinPwDTO.Product, + Warehouse: chickinPwDTO.Warehouse, + } +} diff --git a/internal/modules/production/project-flock-kandangs/module.go b/internal/modules/production/project-flock-kandangs/module.go new file mode 100644 index 00000000..3e7e8fa0 --- /dev/null +++ b/internal/modules/production/project-flock-kandangs/module.go @@ -0,0 +1,45 @@ +package project_flock_kandangs + +import ( + "fmt" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + sProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project-flock-kandangs/services" + rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + + 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" + rFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/repositories" + rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + "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" +) + +type ProjectFlockKandangModule struct{} + +func (ProjectFlockKandangModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) + projectFlockPopulationRepo := rProjectFlockKandang.NewProjectFlockPopulationRepository(db) + userRepo := rUser.NewUserRepository(db) + warehouseRepo := rWarehouse.NewWarehouseRepository(db) + productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + flockRepo := rFlock.NewFlockRepository(db) + + approvalRepo := commonRepo.NewApprovalRepository(db) + approvalService := commonSvc.NewApprovalService(approvalRepo) + // register workflow steps for project flock kandang approvals + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowProjectFlockKandang, utils.ProjectFlockKandangApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register project flock kandang approval workflow: %v", err)) + } + + projectFlockKandangService := sProjectFlockKandang.NewProjectFlockKandangService(projectFlockKandangRepo, approvalService, warehouseRepo, productWarehouseRepo, projectFlockPopulationRepo, flockRepo, validate) + userService := sUser.NewUserService(userRepo, validate) + + ProjectFlockKandangRoutes(router, userService, projectFlockKandangService) +} diff --git a/internal/modules/production/project-flock-kandangs/route.go b/internal/modules/production/project-flock-kandangs/route.go new file mode 100644 index 00000000..8057e847 --- /dev/null +++ b/internal/modules/production/project-flock-kandangs/route.go @@ -0,0 +1,26 @@ +package project_flock_kandangs + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project-flock-kandangs/controllers" + projectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project-flock-kandangs/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func ProjectFlockKandangRoutes(v1 fiber.Router, u user.UserService, s projectFlockKandang.ProjectFlockKandangService) { + ctrl := controller.NewProjectFlockKandangController(s) + + route := v1.Group("/project-flock-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.Get("/", ctrl.GetAll) + route.Get("/:id", ctrl.GetOne) + +} diff --git a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go new file mode 100644 index 00000000..ec61a7e4 --- /dev/null +++ b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go @@ -0,0 +1,248 @@ +package service + +import ( + "errors" + + 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" + flockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/dto" + flockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/repositories" + rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project-flock-kandangs/validations" + 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" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type ProjectFlockKandangService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlockKandang, int64, map[uint]*flockDTO.FlockBaseDTO, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectFlockKandang, map[uint]float64, []entity.ProductWarehouse, *flockDTO.FlockBaseDTO, error) +} + +// Note: map[uint]float64 adalah mapping dari ProductWarehouse ID ke calculated available quantity + +type projectFlockKandangService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.ProjectFlockKandangRepository + ApprovalSvc commonSvc.ApprovalService + WarehouseRepo rWarehouse.WarehouseRepository + ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository + PopulationRepo repository.ProjectFlockPopulationRepository + FlockRepo flockRepository.FlockRepository +} + +func NewProjectFlockKandangService(repo repository.ProjectFlockKandangRepository, approvalSvc commonSvc.ApprovalService, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, populationRepo repository.ProjectFlockPopulationRepository, flockRepo flockRepository.FlockRepository, validate *validator.Validate) ProjectFlockKandangService { + return &projectFlockKandangService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + ApprovalSvc: approvalSvc, + WarehouseRepo: warehouseRepo, + ProductWarehouseRepo: productWarehouseRepo, + PopulationRepo: populationRepo, + FlockRepo: flockRepo, + } +} + +func (s projectFlockKandangService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlockKandang, int64, map[uint]*flockDTO.FlockBaseDTO, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, nil, err + } + + offset := (params.Page - 1) * params.Limit + + projectFlockKandangs, total, err := s.Repository.GetAllWithFilters(c.Context(), offset, params.Limit, params) + + if err != nil { + s.Log.Errorf("Failed to get projectFlockKandangs: %+v", err) + return nil, 0, nil, err + } + + if s.ApprovalSvc != nil { + projectFlockKandangIDs := make([]uint, len(projectFlockKandangs)) + for i, pfk := range projectFlockKandangs { + projectFlockKandangIDs[i] = pfk.Id + } + + approvalMap, err := s.ApprovalSvc.LatestByTargets(c.Context(), utils.ApprovalWorkflowProjectFlockKandang, projectFlockKandangIDs, func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + }) + if err != nil { + s.Log.Warnf("Failed to fetch approvals for projectFlockKandangs: %+v", err) + } else { + for i := range projectFlockKandangs { + if approval, ok := approvalMap[projectFlockKandangs[i].Id]; ok { + projectFlockKandangs[i].LatestApproval = approval + } + } + } + } + + flockMap := make(map[uint]*flockDTO.FlockBaseDTO) + for i := range projectFlockKandangs { + if projectFlockKandangs[i].ProjectFlock.Id != 0 { + baseName := pfutils.DeriveBaseName(projectFlockKandangs[i].ProjectFlock.FlockName) + if baseName != "" { + flock, err := s.FlockRepo.GetByName(c.Context(), baseName) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Warnf("Failed to fetch flock %q: %+v", baseName, err) + } else if flock != nil { + + flockMap[projectFlockKandangs[i].ProjectFlock.Id] = &flockDTO.FlockBaseDTO{ + Id: flock.Id, + Name: flock.Name, + } + } + } + } + } + + return projectFlockKandangs, total, flockMap, nil +} + +func (s projectFlockKandangService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlockKandang, map[uint]float64, []entity.ProductWarehouse, *flockDTO.FlockBaseDTO, error) { + projectFlockKandang, err := s.Repository.GetByID(c.Context(), id) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil, nil, nil, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found") + } + if err != nil { + return nil, nil, nil, nil, err + } + + if len(projectFlockKandang.Chickins) > 0 && s.ApprovalSvc != nil { + latest, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowProjectFlockKandang, projectFlockKandang.Id, nil) + if err != nil { + s.Log.Errorf("Failed to fetch latest kandang approval for projectFlockKandang %d: %+v", projectFlockKandang.Id, err) + } + + if latest != nil { + projectFlockKandang.LatestApproval = latest + } + } + + availableQtyMap, err := s.getAvailableQuantities(c, projectFlockKandang) + if err != nil { + s.Log.Errorf("Failed to fetch available quantities for kandang %d: %+v", projectFlockKandang.Kandang.Id, err) + availableQtyMap = nil + } + + var productWarehouses []entity.ProductWarehouse + if len(availableQtyMap) > 0 { + pwIds := make([]uint, 0, len(availableQtyMap)) + for pwId := range availableQtyMap { + pwIds = append(pwIds, pwId) + } + productWarehouses, err = s.ProductWarehouseRepo.GetByIDs(c.Context(), pwIds, func(db *gorm.DB) *gorm.DB { + return db.Preload("Product").Preload("Warehouse") + }) + if err != nil { + s.Log.Warnf("Failed to fetch product warehouses: %+v", err) + productWarehouses = []entity.ProductWarehouse{} + } + } + var flockResult *flockDTO.FlockBaseDTO + if projectFlockKandang.ProjectFlock.Id != 0 { + baseName := pfutils.DeriveBaseName(projectFlockKandang.ProjectFlock.FlockName) + if baseName != "" { + flock, err := s.FlockRepo.GetByName(c.Context(), baseName) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Warnf("Failed to fetch flock %q: %+v", baseName, err) + } else if flock != nil { + flockResult = &flockDTO.FlockBaseDTO{ + Id: flock.Id, + Name: flock.Name, + } + } + } + } + + return projectFlockKandang, availableQtyMap, productWarehouses, flockResult, nil +} + +func (s projectFlockKandangService) getAvailableQuantities(c *fiber.Ctx, projectFlockKandang *entity.ProjectFlockKandang) (map[uint]float64, error) { + if projectFlockKandang.Kandang.Id == 0 || s.WarehouseRepo == nil || s.ProductWarehouseRepo == nil { + return nil, nil + } + + warehouse, err := s.WarehouseRepo.GetByKandangID(c.Context(), projectFlockKandang.Kandang.Id) + if err != nil || warehouse == nil { + return nil, nil + } + + var productCategoryCode string + if projectFlockKandang.ProjectFlock.Category == string(utils.ProjectFlockCategoryGrowing) { + productCategoryCode = "DOC" + } else if projectFlockKandang.ProjectFlock.Category == string(utils.ProjectFlockCategoryLaying) { + productCategoryCode = "PULLET" + } else { + return nil, nil + } + + products, err := s.ProductWarehouseRepo.GetByFlagAndWarehouseID(c.Context(), productCategoryCode, warehouse.Id) + if err != nil || len(products) == 0 { + return nil, nil + } + + result := make(map[uint]float64) + for _, pw := range products { + availableQty, err := s.calculateAvailableQuantityForProductWarehouse(c, projectFlockKandang, &pw) + if err != nil { + s.Log.Warnf("Failed to calculate available quantity for product warehouse %d: %v", pw.Id, err) + } + + if availableQty > 0 { + result[pw.Id] = availableQty + } + } + + return result, nil +} + +func (s projectFlockKandangService) calculateAvailableQuantityForProductWarehouse(c *fiber.Ctx, projectFlockKandang *entity.ProjectFlockKandang, productWarehouse *entity.ProductWarehouse) (float64, error) { + availableQty := productWarehouse.Quantity + + if projectFlockKandang.ProjectFlock.Category == string(utils.ProjectFlockCategoryGrowing) { + var totalPendingQty float64 + + for _, chickin := range projectFlockKandang.Chickins { + if chickin.ProductWarehouseId == productWarehouse.Id && chickin.DeletedAt.Time.IsZero() && chickin.PendingUsageQty > 0 { + totalPendingQty += chickin.PendingUsageQty + } + } + + availableQty = productWarehouse.Quantity - totalPendingQty + if availableQty < 0 { + availableQty = 0 + } + } else if projectFlockKandang.ProjectFlock.Category == string(utils.ProjectFlockCategoryLaying) { + var totalPopulation float64 + var totalPendingQty float64 + + populations, err := s.PopulationRepo.GetByProjectFlockKandangIDAndProductWarehouseID(c.Context(), projectFlockKandang.Id, productWarehouse.Id) + if err == nil { + for _, pop := range populations { + totalPopulation += pop.TotalQty + } + } + + for _, chickin := range projectFlockKandang.Chickins { + if chickin.ProductWarehouseId == productWarehouse.Id && chickin.DeletedAt.Time.IsZero() && chickin.PendingUsageQty > 0 { + totalPendingQty += chickin.PendingUsageQty + } + } + + availableQty = productWarehouse.Quantity - totalPopulation - totalPendingQty + if availableQty < 0 { + availableQty = 0 + } + } + + return availableQty, nil +} diff --git a/internal/modules/production/project-flock-kandangs/validations/project_flock_kandang.validation.go b/internal/modules/production/project-flock-kandangs/validations/project_flock_kandang.validation.go new file mode 100644 index 00000000..93e0256a --- /dev/null +++ b/internal/modules/production/project-flock-kandangs/validations/project_flock_kandang.validation.go @@ -0,0 +1,24 @@ +package validation + +type Create struct { + ProjectFlockId uint `json:"project_flock_id" validate:"required"` + KandangId uint `json:"kandang_id" validate:"required"` +} + +type Update struct { + ProjectFlockId *uint `json:"project_flock_id,omitempty" validate:"omitempty"` + KandangId *uint `json:"kandang_id,omitempty" validate:"omitempty"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` + ProjectFlockId uint `query:"project_flock_id" validate:"omitempty"` + KandangId uint `query:"kandang_id" validate:"omitempty"` + Category string `query:"category" validate:"omitempty,oneof=Growing Laying"` + AreaId uint `query:"area_id" validate:"omitempty"` + SortBy string `query:"sort_by" validate:"omitempty,oneof=created_at period"` + SortOrder string `query:"sort_order" validate:"omitempty,oneof=ASC DESC"` + StepName string `query:"step_name" validate:"omitempty,max=50"` +} diff --git a/internal/modules/production/project_flocks/controllers/projectflock.controller.go b/internal/modules/production/project_flocks/controllers/projectflock.controller.go index d3b0061c..67b04f99 100644 --- a/internal/modules/production/project_flocks/controllers/projectflock.controller.go +++ b/internal/modules/production/project_flocks/controllers/projectflock.controller.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" + flockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/dto" service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations" @@ -71,6 +72,10 @@ func (u *ProjectflockController) GetAll(c *fiber.Ctx) error { if period := c.QueryInt("period", 0); period > 0 { query.Period = period } + if category := c.Query("category", ""); category != "" { + query.Category = category + + } if kandangRaw := c.Query("kandang_id", c.Query("kandang_ids", "")); kandangRaw != "" { ids, err := parseUintList(kandangRaw) @@ -80,11 +85,20 @@ func (u *ProjectflockController) GetAll(c *fiber.Ctx) error { query.KandangIds = ids } - result, totalResults, err := u.ProjectflockService.GetAll(c, query) + result, totalResults, flockMap, err := u.ProjectflockService.GetAll(c, query) if err != nil { return err } + data := make([]dto.ProjectFlockListDTO, 0) + for _, projectFlock := range result { + var flock *flockDTO.FlockBaseDTO + if flockMap != nil { + flock = flockMap[projectFlock.Id] + } + data = append(data, dto.ToProjectFlockListDTO(projectFlock, flock)) + } + return c.Status(fiber.StatusOK). JSON(response.SuccessWithPaginate[dto.ProjectFlockListDTO]{ Code: fiber.StatusOK, @@ -96,7 +110,7 @@ func (u *ProjectflockController) GetAll(c *fiber.Ctx) error { TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), TotalResults: totalResults, }, - Data: dto.ToProjectFlockListDTOs(result), + Data: data, }) } @@ -108,7 +122,7 @@ func (u *ProjectflockController) GetOne(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") } - result, err := u.ProjectflockService.GetOne(c, uint(id)) + result, flock, err := u.ProjectflockService.GetOne(c, uint(id)) if err != nil { return err } @@ -118,7 +132,7 @@ func (u *ProjectflockController) GetOne(c *fiber.Ctx) error { Code: fiber.StatusOK, Status: "success", Message: "Get projectflock successfully", - Data: dto.ToProjectFlockListDTO(*result), + Data: dto.ToProjectFlockListDTO(*result, flock), }) } @@ -139,7 +153,7 @@ func (u *ProjectflockController) CreateOne(c *fiber.Ctx) error { Code: fiber.StatusCreated, Status: "success", Message: "Create projectflock successfully", - Data: dto.ToProjectFlockListDTO(*result), + Data: dto.ToProjectFlockListDTO(*result, nil), }) } @@ -166,7 +180,7 @@ func (u *ProjectflockController) UpdateOne(c *fiber.Ctx) error { Code: fiber.StatusOK, Status: "success", Message: "Update projectflock successfully", - Data: dto.ToProjectFlockListDTO(*result), + Data: dto.ToProjectFlockListDTO(*result, nil), }) } @@ -206,7 +220,7 @@ func (u *ProjectflockController) Approval(c *fiber.Ctx) error { message = "Submit projectflock approval successfully" ) if len(results) == 1 { - data = dto.ToProjectFlockListDTO(results[0]) + data = dto.ToProjectFlockListDTO(results[0], nil) } else { message = "Submit projectflock approvals successfully" data = dto.ToProjectFlockListDTOs(results) diff --git a/internal/modules/production/project_flocks/dto/projectflock.dto.go b/internal/modules/production/project_flocks/dto/projectflock.dto.go index bfadf3e2..2230d865 100644 --- a/internal/modules/production/project_flocks/dto/projectflock.dto.go +++ b/internal/modules/production/project_flocks/dto/projectflock.dto.go @@ -10,6 +10,7 @@ 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" @@ -24,16 +25,21 @@ type ProjectFlockBaseDTO struct { type ProjectFlockListDTO struct { ProjectFlockBaseDTO - // Flock *flockDTO.FlockBaseDTO `json:"flock,omitempty"` - Area *areaDTO.AreaBaseDTO `json:"area,omitempty"` - Category string `json:"category"` - Fcr *fcrDTO.FcrBaseDTO `json:"fcr,omitempty"` - Location *locationDTO.LocationBaseDTO `json:"location,omitempty"` - Kandangs []kandangDTO.KandangBaseDTO `json:"kandangs,omitempty"` - CreatedUser *userDTO.UserBaseDTO `json:"created_user"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Approval approvalDTO.ApprovalBaseDTO `json:"approval"` + Flock *flockDTO.FlockBaseDTO `json:"flock,omitempty"` + Area *areaDTO.AreaBaseDTO `json:"area,omitempty"` + Category string `json:"category"` + Fcr *fcrDTO.FcrBaseDTO `json:"fcr,omitempty"` + Location *locationDTO.LocationBaseDTO `json:"location,omitempty"` + Kandangs []KandangWithProjectFlockIdDTO `json:"kandangs,omitempty"` + CreatedUser *userDTO.UserBaseDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Approval approvalDTO.ApprovalBaseDTO `json:"approval"` +} + +type KandangWithProjectFlockIdDTO struct { + kandangDTO.KandangBaseDTO + ProjectFlockKandangId uint `json:"project_flock_kandang_id"` } type ProjectFlockDetailDTO struct { @@ -45,27 +51,32 @@ type FlockPeriodDTO struct { NextPeriod int `json:"next_period"` } -func ToProjectFlockListDTO(e entity.ProjectFlock) ProjectFlockListDTO { +func ToProjectFlockListDTO(e entity.ProjectFlock, flock *flockDTO.FlockBaseDTO) ProjectFlockListDTO { var createdUser *userDTO.UserBaseDTO if e.CreatedUser.Id != 0 { mapped := userDTO.ToUserBaseDTO(e.CreatedUser) createdUser = &mapped } - var kandangSummaries []kandangDTO.KandangBaseDTO + var kandangSummaries []KandangWithProjectFlockIdDTO if len(e.Kandangs) > 0 { - kandangSummaries = make([]kandangDTO.KandangBaseDTO, len(e.Kandangs)) + kandangSummaries = make([]KandangWithProjectFlockIdDTO, len(e.Kandangs)) for i, kandang := range e.Kandangs { - kandangSummaries[i] = kandangDTO.ToKandangBaseDTO(kandang) + + var pfkId uint + for _, kh := range e.KandangHistory { + if kh.KandangId == kandang.Id { + pfkId = kh.Id + break + } + } + kandangSummaries[i] = KandangWithProjectFlockIdDTO{ + KandangBaseDTO: kandangDTO.ToKandangBaseDTO(kandang), + ProjectFlockKandangId: pfkId, + } } } - // 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 { mapped := areaDTO.ToAreaBaseDTO(e.Area) @@ -84,6 +95,11 @@ func ToProjectFlockListDTO(e entity.ProjectFlock) ProjectFlockListDTO { locationSummary = &mapped } + var flockSummary *flockDTO.FlockBaseDTO + if flock != nil && flock.Id != 0 { + flockSummary = flock + } + latestApproval := defaultProjectFlockLatestApproval(e) if e.LatestApproval != nil { snapshot := approvalDTO.ToApprovalDTO(*e.LatestApproval) @@ -92,7 +108,7 @@ func ToProjectFlockListDTO(e entity.ProjectFlock) ProjectFlockListDTO { return ProjectFlockListDTO{ ProjectFlockBaseDTO: createProjectFlockBaseDTO(e), - // Flock: flockSummary, + Flock: flockSummary, Area: areaSummary, Kandangs: kandangSummaries, Category: e.Category, @@ -105,17 +121,38 @@ func ToProjectFlockListDTO(e entity.ProjectFlock) ProjectFlockListDTO { } } +func ToProjectFlockListDTOWithFlock(e entity.ProjectFlock, flock *flockDTO.FlockBaseDTO) ProjectFlockListDTO { + return ToProjectFlockListDTO(e, flock) +} + func ToProjectFlockListDTOs(items []entity.ProjectFlock) []ProjectFlockListDTO { result := make([]ProjectFlockListDTO, len(items)) for i, item := range items { - result[i] = ToProjectFlockListDTO(item) + result[i] = ToProjectFlockListDTO(item, nil) } return result } -func ToProjectFlockDetailDTO(e entity.ProjectFlock) ProjectFlockDetailDTO { +func ToProjectFlockListDTOsWithFlocks(items []entity.ProjectFlock, flocks map[uint]*entity.Flock) []ProjectFlockListDTO { + result := make([]ProjectFlockListDTO, len(items)) + for i, item := range items { + var flock *flockDTO.FlockBaseDTO + if flocks != nil { + if f := flocks[item.Id]; f != nil { + flock = &flockDTO.FlockBaseDTO{ + Id: f.Id, + Name: f.Name, + } + } + } + result[i] = ToProjectFlockListDTOWithFlock(item, flock) + } + return result +} + +func ToProjectFlockDetailDTO(e entity.ProjectFlock, flock *flockDTO.FlockBaseDTO) ProjectFlockDetailDTO { return ProjectFlockDetailDTO{ - ProjectFlockListDTO: ToProjectFlockListDTO(e), + ProjectFlockListDTO: ToProjectFlockListDTO(e, flock), } } diff --git a/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go b/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go index cb4b0d5f..a2b56dce 100644 --- a/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go +++ b/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go @@ -9,8 +9,20 @@ import ( ) type ProjectFlockPopulationRepository interface { - repository.BaseRepository[entity.ProjectFlockPopulation] - GetByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (*entity.ProjectFlockPopulation, error) + // domain-specific + GetByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) ([]entity.ProjectFlockPopulation, error) + ExistsByProjectChickinID(ctx context.Context, projectChickinID uint) (bool, error) + GetByProjectChickinIDAndProductWarehouseID(ctx context.Context, projectChickinID uint, productWarehouseID uint) ([]entity.ProjectFlockPopulation, error) + GetByProjectFlockKandangIDAndProductWarehouseID(ctx context.Context, projectFlockKandangID uint, productWarehouseID uint) ([]entity.ProjectFlockPopulation, error) + GetTotalQtyByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) + + // subset of base repository methods used by services + CreateOne(ctx context.Context, entity *entity.ProjectFlockPopulation, modifier func(*gorm.DB) *gorm.DB) error + PatchOne(ctx context.Context, id uint, updates map[string]any, modifier func(*gorm.DB) *gorm.DB) error + + // transaction helpers + WithTx(tx *gorm.DB) ProjectFlockPopulationRepository + DB() *gorm.DB } type projectFlockPopulationRepositoryImpl struct { @@ -23,13 +35,74 @@ func NewProjectFlockPopulationRepository(db *gorm.DB) ProjectFlockPopulationRepo } } -func (r *projectFlockPopulationRepositoryImpl) GetByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (*entity.ProjectFlockPopulation, error) { - var record entity.ProjectFlockPopulation +func (r *projectFlockPopulationRepositoryImpl) WithTx(tx *gorm.DB) ProjectFlockPopulationRepository { + return &projectFlockPopulationRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.ProjectFlockPopulation](tx), + } +} + +func (r *projectFlockPopulationRepositoryImpl) DB() *gorm.DB { + return r.BaseRepositoryImpl.DB() +} + +func (r *projectFlockPopulationRepositoryImpl) GetByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) ([]entity.ProjectFlockPopulation, error) { + var records []entity.ProjectFlockPopulation err := r.DB().WithContext(ctx). - Where("project_flock_kandang_id = ?", projectFlockKandangID). - First(&record).Error + Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id"). + Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangID). + Preload("ProjectChickin"). + Find(&records).Error if err != nil { return nil, err } - return &record, nil + return records, nil +} + +func (r *projectFlockPopulationRepositoryImpl) ExistsByProjectChickinID(ctx context.Context, projectChickinID uint) (bool, error) { + var count int64 + err := r.DB().WithContext(ctx). + Where("project_chickin_id = ?", projectChickinID). + Model(&entity.ProjectFlockPopulation{}). + Count(&count).Error + if err != nil { + return false, err + } + return count > 0, nil +} + +func (r *projectFlockPopulationRepositoryImpl) GetByProjectChickinIDAndProductWarehouseID(ctx context.Context, projectChickinID uint, productWarehouseID uint) ([]entity.ProjectFlockPopulation, error) { + var records []entity.ProjectFlockPopulation + err := r.DB().WithContext(ctx). + Where("project_chickin_id = ? AND product_warehouse_id = ?", projectChickinID, productWarehouseID). + Find(&records).Error + if err != nil { + return nil, err + } + return records, nil +} + +func (r *projectFlockPopulationRepositoryImpl) GetByProjectFlockKandangIDAndProductWarehouseID(ctx context.Context, projectFlockKandangID uint, productWarehouseID uint) ([]entity.ProjectFlockPopulation, error) { + var records []entity.ProjectFlockPopulation + err := r.DB().WithContext(ctx). + Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id"). + Where("project_chickins.project_flock_kandang_id = ? AND project_flock_populations.product_warehouse_id = ?", projectFlockKandangID, productWarehouseID). + Find(&records).Error + if err != nil { + return nil, err + } + return records, nil +} + +func (r *projectFlockPopulationRepositoryImpl) GetTotalQtyByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) { + var total float64 + err := r.DB().WithContext(ctx). + Table("project_flock_populations"). + Select("COALESCE(SUM(total_qty), 0) AS total_qty"). + Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id"). + Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangID). + Scan(&total).Error + if err != nil { + return 0, err + } + return total, nil } diff --git a/internal/modules/production/project_flocks/repositories/projectflock.repository.go b/internal/modules/production/project_flocks/repositories/projectflock.repository.go index bb653fe9..41cbde12 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock.repository.go @@ -117,7 +117,9 @@ func (r *ProjectflockRepositoryImpl) withDefaultRelations(db *gorm.DB) *gorm.DB Preload("Area"). Preload("Fcr"). Preload("Location"). - Preload("Kandangs") + Preload("Kandangs"). + Preload("KandangHistory"). + Preload("KandangHistory.Kandang") } func (r *ProjectflockRepositoryImpl) applyQueryFilters(db *gorm.DB, params *validation.Query) *gorm.DB { @@ -125,6 +127,9 @@ func (r *ProjectflockRepositoryImpl) applyQueryFilters(db *gorm.DB, params *vali return db } + if params.Category != "" { + db = db.Where("project_flocks.category = ?", params.Category) + } if params.AreaId > 0 { db = db.Where("project_flocks.area_id = ?", params.AreaId) } 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 e6a36c87..a2ab8ebe 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go @@ -4,7 +4,9 @@ import ( "context" "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-flock-kandangs/validations" "gorm.io/gorm" ) @@ -13,12 +15,14 @@ type ProjectFlockKandangRepository interface { GetByProjectFlockAndKandang(ctx context.Context, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, error) CreateMany(ctx context.Context, records []*entity.ProjectFlockKandang) error DeleteMany(ctx context.Context, projectFlockID uint, kandangIDs []uint) error - GetAll(ctx context.Context) ([]entity.ProjectFlockKandang, error) + GetAll(ctx context.Context, offset int, limit int, modifier func(*gorm.DB) *gorm.DB) ([]entity.ProjectFlockKandang, int64, error) + GetAllWithFilters(ctx context.Context, offset int, limit int, params interface{}) ([]entity.ProjectFlockKandang, int64, error) 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 + IdExists(ctx context.Context, id uint) (bool, error) DB() *gorm.DB } @@ -48,9 +52,36 @@ func (r *projectFlockKandangRepositoryImpl) DeleteMany(ctx context.Context, proj Delete(&entity.ProjectFlockKandang{}).Error } -func (r *projectFlockKandangRepositoryImpl) GetAll(ctx context.Context) ([]entity.ProjectFlockKandang, error) { +func (r *projectFlockKandangRepositoryImpl) GetAll(ctx context.Context, offset int, limit int, modifier func(*gorm.DB) *gorm.DB) ([]entity.ProjectFlockKandang, int64, error) { var records []entity.ProjectFlockKandang - if err := r.db.WithContext(ctx). + var total int64 + + q := r.db.WithContext(ctx) + + if modifier != nil { + q = modifier(q) + } + + if err := q.Model(&entity.ProjectFlockKandang{}).Count(&total).Error; err != nil { + return nil, 0, err + } + + if err := q.Offset(offset).Limit(limit).Find(&records).Error; err != nil { + return nil, 0, err + } + + return records, total, nil +} + +func (r *projectFlockKandangRepositoryImpl) GetAllWithFilters(ctx context.Context, offset int, limit int, params interface{}) ([]entity.ProjectFlockKandang, int64, error) { + var records []entity.ProjectFlockKandang + var total int64 + + query, ok := params.(*validation.Query) + + q := r.db.WithContext(ctx). + Joins("JOIN \"kandangs\" ON \"project_flock_kandangs\".\"kandang_id\" = \"kandangs\".\"id\""). + Joins("JOIN \"project_flocks\" ON \"project_flock_kandangs\".\"project_flock_id\" = \"project_flocks\".\"id\""). Preload("ProjectFlock"). Preload("ProjectFlock.Fcr"). Preload("ProjectFlock.Area"). @@ -59,11 +90,78 @@ func (r *projectFlockKandangRepositoryImpl) GetAll(ctx context.Context) ([]entit Preload("ProjectFlock.Kandangs"). Preload("ProjectFlock.KandangHistory"). Preload("Kandang"). - Order("project_flock_id ASC, created_at ASC"). - Find(&records).Error; err != nil { - return nil, err + Preload("Chickins"). + Preload("Chickins.CreatedUser"). + Preload("Chickins.ProductWarehouse") + + if ok && query != nil && query.StepName != "" { + q = q.Where(` + EXISTS ( + SELECT 1 FROM "approvals" + WHERE "approvals"."approvable_id" = "project_flock_kandangs"."id" + AND "approvals"."approvable_type" = ? + AND LOWER("approvals"."step_name") = LOWER(?) + AND "approvals"."id" IN ( + SELECT "id" FROM "approvals" + WHERE "approvable_id" = "project_flock_kandangs"."id" + AND "approvable_type" = ? + ORDER BY "action_at" DESC + LIMIT 1 + ) + ) + `, "PROJECT_FLOCK_KANDANGS", query.StepName, "PROJECT_FLOCK_KANDANGS") } - return records, nil + + if ok && query != nil { + if query.Search != "" { + escapedSearch := strings.NewReplacer("\\", "\\\\", "%", "\\%", "_", "\\_").Replace(query.Search) + q = q.Where( + r.db.Where("LOWER(\"kandangs\".\"name\") LIKE LOWER(?) ESCAPE '\\'", "%"+escapedSearch+"%"). + Or("LOWER(\"project_flocks\".\"flock_name\") LIKE LOWER(?) ESCAPE '\\'", "%"+escapedSearch+"%"), + ) + } + + if query.ProjectFlockId > 0 { + q = q.Where("\"project_flock_kandangs\".\"project_flock_id\" = ?", query.ProjectFlockId) + } + + if query.KandangId > 0 { + q = q.Where("\"project_flock_kandangs\".\"kandang_id\" = ?", query.KandangId) + } + + if query.Category != "" { + q = q.Where("\"project_flocks\".\"category\" = ?", query.Category) + } + + if query.AreaId > 0 { + q = q.Where("\"project_flocks\".\"area_id\" = ?", query.AreaId) + } + } + + if err := q.Model(&entity.ProjectFlockKandang{}).Count(&total).Error; err != nil { + return nil, 0, err + } + + sortBy := "\"project_flock_kandangs\".\"created_at\" DESC" + if ok && query != nil && query.SortBy != "" { + sortOrder := "DESC" + if query.SortOrder == "ASC" { + sortOrder = "ASC" + } + + switch query.SortBy { + case "created_at": + sortBy = "\"project_flock_kandangs\".\"created_at\" " + sortOrder + case "period": + sortBy = "\"project_flocks\".\"period\" " + sortOrder + } + } + + if err := q.Order(sortBy).Offset(offset).Limit(limit).Find(&records).Error; err != nil { + return nil, 0, err + } + + return records, total, nil } func (r *projectFlockKandangRepositoryImpl) WithTx(tx *gorm.DB) ProjectFlockKandangRepository { @@ -73,6 +171,9 @@ func (r *projectFlockKandangRepositoryImpl) WithTx(tx *gorm.DB) ProjectFlockKand func (r *projectFlockKandangRepositoryImpl) DB() *gorm.DB { return r.db } +func (r *projectFlockKandangRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.ProjectFlockKandang](ctx, r.db, id) +} func (r *projectFlockKandangRepositoryImpl) GetByID(ctx context.Context, id uint) (*entity.ProjectFlockKandang, error) { record := new(entity.ProjectFlockKandang) @@ -85,6 +186,11 @@ func (r *projectFlockKandangRepositoryImpl) GetByID(ctx context.Context, id uint Preload("ProjectFlock.Kandangs"). Preload("ProjectFlock.KandangHistory"). Preload("Kandang"). + Preload("Chickins"). + Preload("Chickins.CreatedUser"). + Preload("Chickins.ProductWarehouse"). + Preload("Chickins.ProductWarehouse.Product"). + Preload("Chickins.ProductWarehouse.Warehouse"). First(record, id).Error; err != nil { return nil, err } @@ -103,6 +209,11 @@ func (r *projectFlockKandangRepositoryImpl) GetByProjectFlockAndKandang(ctx cont Preload("ProjectFlock.Kandangs"). Preload("ProjectFlock.KandangHistory"). Preload("Kandang"). + Preload("Chickins"). + Preload("Chickins.CreatedUser"). + Preload("Chickins.ProductWarehouse"). + Preload("Chickins.ProductWarehouse.Product"). + Preload("Chickins.ProductWarehouse.Warehouse"). First(record).Error; err != nil { return nil, err } diff --git a/internal/modules/production/project_flocks/route.go b/internal/modules/production/project_flocks/route.go index 8128f943..39e283ab 100644 --- a/internal/modules/production/project_flocks/route.go +++ b/internal/modules/production/project_flocks/route.go @@ -13,6 +13,12 @@ func ProjectflockRoutes(v1 fiber.Router, u user.UserService, s projectflock.Proj 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.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) @@ -20,6 +26,7 @@ func ProjectflockRoutes(v1 fiber.Router, u user.UserService, s projectflock.Proj route.Get("/:id", ctrl.GetOne) route.Patch("/:id", ctrl.UpdateOne) route.Delete("/:id", ctrl.DeleteOne) + route.Get("/kandangs/:project_flock_kandang_id/periods", ctrl.GetFlockPeriodSummary) route.Get("/kandangs/lookup", ctrl.LookupProjectFlockKandang) route.Post("/approvals", ctrl.Approval) 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 9497ecbb..5b92b0db 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -12,6 +12,7 @@ import ( 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" + flockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/dto" 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" @@ -28,8 +29,8 @@ import ( ) type ProjectflockService interface { - GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, error) - GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, error) + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, map[uint]*flockDTO.FlockBaseDTO, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, *flockDTO.FlockBaseDTO, 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) @@ -81,9 +82,21 @@ func NewProjectflockService( } } -func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, error) { +func (s projectflockService) withRelations(db *gorm.DB) *gorm.DB { + return db. + Preload("CreatedUser"). + Preload("Flock"). + Preload("Area"). + Preload("Fcr"). + Preload("Location"). + Preload("Kandangs"). + Preload("KandangHistory"). + Preload("KandangHistory.Kandang") +} + +func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, map[uint]*flockDTO.FlockBaseDTO, error) { if err := s.Validate.Struct(params); err != nil { - return nil, 0, err + return nil, 0, nil, err } if params.Page <= 0 { @@ -99,7 +112,7 @@ func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e if err != nil { s.Log.Errorf("Failed to get projectflocks: %+v", err) - return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flocks") + return nil, 0, nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flocks") } if s.ApprovalSvc != nil && len(projectflocks) > 0 { @@ -122,10 +135,28 @@ func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e } } - return projectflocks, total, nil + flockMap := make(map[uint]*flockDTO.FlockBaseDTO) + for i := range projectflocks { + if projectflocks[i].FlockName != "" { + baseName := pfutils.DeriveBaseName(projectflocks[i].FlockName) + if baseName != "" { + flock, err := s.FlockRepo.GetByName(c.Context(), baseName) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Warnf("Failed to fetch flock %q: %+v", baseName, err) + } else if flock != nil { + flockMap[projectflocks[i].Id] = &flockDTO.FlockBaseDTO{ + Id: flock.Id, + Name: flock.Name, + } + } + } + } + } + + return projectflocks, total, flockMap, nil } -func (s projectflockService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlock, error) { +func (s projectflockService) getOneEntityOnly(c *fiber.Ctx, id uint) (*entity.ProjectFlock, error) { 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") @@ -154,6 +185,52 @@ func (s projectflockService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlock return projectflock, nil } +func (s projectflockService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlock, *flockDTO.FlockBaseDTO, error) { + projectflock, err := s.Repository.GetByID(c.Context(), id, s.Repository.WithDefaultRelations()) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found") + } + if err != nil { + s.Log.Errorf("Failed get projectflock by id: %+v", err) + return nil, nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") + } + + if s.ApprovalSvc != nil { + approvals, err := s.ApprovalSvc.ListByTarget(c.Context(), s.approvalWorkflow, id, func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + }) + if err != nil { + s.Log.Warnf("Unable to load approvals for projectflock %d: %+v", id, err) + } else if len(approvals) > 0 { + if projectflock.LatestApproval == nil { + latest := approvals[len(approvals)-1] + projectflock.LatestApproval = &latest + } + } else { + projectflock.LatestApproval = nil + } + } + + // Fetch Flock master data for this ProjectFlock + var flockResult *flockDTO.FlockBaseDTO + if projectflock.FlockName != "" { + baseName := pfutils.DeriveBaseName(projectflock.FlockName) + if baseName != "" { + flock, err := s.FlockRepo.GetByName(c.Context(), baseName) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Warnf("Failed to fetch flock %q: %+v", baseName, err) + } else if flock != nil { + flockResult = &flockDTO.FlockBaseDTO{ + Id: flock.Id, + Name: flock.Name, + } + } + } + } + + return projectflock, flockResult, nil +} + func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.ProjectFlock, error) { if err := s.Validate.Struct(req); err != nil { return nil, err @@ -268,7 +345,7 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create project flock") } - return s.GetOne(c, createBody.Id) + return s.getOneEntityOnly(c, createBody.Id) } func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.ProjectFlock, error) { @@ -385,7 +462,7 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id hasChanges := hasBodyChanges || hasKandangChanges if !hasChanges { - return s.GetOne(c, id) + return s.getOneEntityOnly(c, id) } err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { @@ -507,7 +584,7 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update project flock") } - return s.GetOne(c, id) + return s.getOneEntityOnly(c, id) } func (s projectflockService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlock, error) { @@ -601,7 +678,7 @@ func (s projectflockService) Approval(c *fiber.Ctx, req *validation.Approve) ([] updated := make([]entity.ProjectFlock, 0, len(approvableIDs)) for _, approvableID := range approvableIDs { - project, err := s.GetOne(c, approvableID) + project, err := s.getOneEntityOnly(c, approvableID) if err != nil { return nil, err } diff --git a/internal/modules/production/project_flocks/validations/projectflock.validation.go b/internal/modules/production/project_flocks/validations/projectflock.validation.go index 7932e07e..33f20725 100644 --- a/internal/modules/production/project_flocks/validations/projectflock.validation.go +++ b/internal/modules/production/project_flocks/validations/projectflock.validation.go @@ -27,6 +27,7 @@ type Query struct { AreaId uint `query:"area_id" validate:"omitempty,number,gt=0"` LocationId uint `query:"location_id" validate:"omitempty,number,gt=0"` Period int `query:"period" validate:"omitempty,number,gt=0"` + Category string `query:"category" validate:"omitempty"` KandangIds []uint `query:"kandang_id" validate:"omitempty,dive,gt=0"` } diff --git a/internal/modules/production/recordings/dto/recording.dto.go b/internal/modules/production/recordings/dto/recording.dto.go index e8d04758..21fccd41 100644 --- a/internal/modules/production/recordings/dto/recording.dto.go +++ b/internal/modules/production/recordings/dto/recording.dto.go @@ -2,10 +2,12 @@ package dto import ( "math" + "strings" "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" + productWarehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/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" @@ -17,19 +19,19 @@ type RecordingBaseDTO struct { 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"` + Day int `json:"day"` + ProjectFlockCategory string `json:"project_flock_category"` + TotalDepletionQty float64 `json:"total_depletion_qty"` + CumDepletionRate float64 `json:"cum_depletion_rate"` + DailyGain float64 `json:"daily_gain"` + AvgDailyGain float64 `json:"avg_daily_gain"` + CumIntake int `json:"cum_intake"` + FcrValue float64 `json:"fcr_value"` + TotalChickQty float64 `json:"total_chick_qty"` 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"` + EggGradingStatus *string `json:"egg_grading_status"` + EggGradingPendingQty *int `json:"egg_grading_pending_qty"` + EggGradingCompletedQty *int `json:"egg_grading_completed_qty"` } type RecordingListDTO struct { @@ -54,23 +56,24 @@ type RecordingBodyWeightDTO struct { } type RecordingDepletionDTO struct { - ProductWarehouseId uint `json:"product_warehouse_id"` - Qty float64 `json:"qty"` - ProductWarehouse *RecordingProductWarehouseDTO `json:"product_warehouse,omitempty"` + ProductWarehouseId uint `json:"product_warehouse_id"` + Qty float64 `json:"qty"` + ProductWarehouse productWarehouseDTO.ProductWarehouseDTO `json:"product_warehouse"` } 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"` + ProductWarehouseId uint `json:"product_warehouse_id"` + UsageAmount float64 `json:"usage_amount"` + PendingQty *float64 `json:"pending_qty,omitempty"` + ProductWarehouse productWarehouseDTO.ProductWarehouseDTO `json:"product_warehouse"` } type RecordingEggDTO struct { - ProductWarehouseId uint `json:"product_warehouse_id"` - Qty int `json:"qty"` - ProductWarehouse *RecordingProductWarehouseDTO `json:"product_warehouse,omitempty"` - Gradings []RecordingEggGradingDTO `json:"gradings,omitempty"` + Id uint `json:"id"` + ProductWarehouseId uint `json:"product_warehouse_id"` + Qty int `json:"qty"` + ProductWarehouse productWarehouseDTO.ProductWarehouseDTO `json:"product_warehouse"` + Gradings []RecordingEggGradingDTO `json:"gradings,omitempty"` } type RecordingProductWarehouseDTO struct { @@ -89,12 +92,46 @@ type RecordingEggGradingDTO struct { // === Mapper Functions === func ToRecordingBaseDTO(e entity.Recording) RecordingBaseDTO { - var projectFlockCategory *string + var ( + projectFlockCategory string + day int + totalDepletionQty float64 + cumDepletionRate float64 + dailyGain float64 + avgDailyGain float64 + cumIntake int + fcrValue float64 + totalChickQty float64 + ) + + if e.Day != nil { + day = *e.Day + } + if e.TotalDepletionQty != nil { + totalDepletionQty = *e.TotalDepletionQty + } + if e.CumDepletionRate != nil { + cumDepletionRate = *e.CumDepletionRate + } + if e.DailyGain != nil { + dailyGain = *e.DailyGain + } + if e.AvgDailyGain != nil { + avgDailyGain = *e.AvgDailyGain + } + if e.CumIntake != nil { + cumIntake = *e.CumIntake + } + if e.FcrValue != nil { + fcrValue = *e.FcrValue + } + if e.TotalChickQty != nil { + totalChickQty = *e.TotalChickQty + } + if e.ProjectFlockKandang != nil && e.ProjectFlockKandang.ProjectFlock.Id != 0 { category := e.ProjectFlockKandang.ProjectFlock.Category - if category != "" { - projectFlockCategory = &category - } + projectFlockCategory = category } latestApproval := defaultRecordingLatestApproval(e) @@ -109,15 +146,15 @@ func ToRecordingBaseDTO(e entity.Recording) RecordingBaseDTO { Id: e.Id, ProjectFlockKandangId: e.ProjectFlockKandangId, RecordDatetime: e.RecordDatetime, - Day: e.Day, + Day: day, ProjectFlockCategory: projectFlockCategory, - TotalDepletionQty: e.TotalDepletionQty, - CumDepletionRate: e.CumDepletionRate, - DailyGain: e.DailyGain, - AvgDailyGain: e.AvgDailyGain, - CumIntake: e.CumIntake, - FcrValue: e.FcrValue, - TotalChickQty: e.TotalChickQty, + TotalDepletionQty: totalDepletionQty, + CumDepletionRate: cumDepletionRate, + DailyGain: dailyGain, + AvgDailyGain: avgDailyGain, + CumIntake: cumIntake, + FcrValue: fcrValue, + TotalChickQty: totalChickQty, Approval: latestApproval, EggGradingStatus: gradingStatus, EggGradingPendingQty: gradingPending, @@ -149,12 +186,21 @@ func ToRecordingListDTOs(e []entity.Recording) []RecordingListDTO { } func ToRecordingDetailDTO(e entity.Recording) RecordingDetailDTO { + listDTO := ToRecordingListDTO(e) + + var eggs []RecordingEggDTO + if strings.EqualFold(listDTO.ProjectFlockCategory, string(utils.ProjectFlockCategoryLaying)) { + eggs = ToRecordingEggDTOs(e.Eggs) + } else if len(e.Eggs) > 0 { + eggs = ToRecordingEggDTOs(e.Eggs) + } + return RecordingDetailDTO{ - RecordingListDTO: ToRecordingListDTO(e), + RecordingListDTO: listDTO, BodyWeights: ToRecordingBodyWeightDTOs(e.BodyWeights), Depletions: ToRecordingDepletionDTOs(e.Depletions), Stocks: ToRecordingStockDTOs(e.Stocks), - Eggs: ToRecordingEggDTOs(e.Eggs), + Eggs: eggs, } } @@ -176,7 +222,7 @@ func ToRecordingDepletionDTOs(depletions []entity.RecordingDepletion) []Recordin result[i] = RecordingDepletionDTO{ ProductWarehouseId: d.ProductWarehouseId, Qty: d.Qty, - ProductWarehouse: toRecordingProductWarehouseDTO(&d.ProductWarehouse), + ProductWarehouse: mapProductWarehouseDTO(&d.ProductWarehouse), } } return result @@ -185,11 +231,16 @@ func ToRecordingDepletionDTOs(depletions []entity.RecordingDepletion) []Recordin func ToRecordingStockDTOs(stocks []entity.RecordingStock) []RecordingStockDTO { result := make([]RecordingStockDTO, len(stocks)) for i, s := range stocks { + var usageAmount float64 + if s.UsageQty != nil { + usageAmount = *s.UsageQty + } + result[i] = RecordingStockDTO{ ProductWarehouseId: s.ProductWarehouseId, - UsageAmount: s.UsageQty, + UsageAmount: usageAmount, PendingQty: s.PendingQty, - ProductWarehouse: toRecordingProductWarehouseDTO(&s.ProductWarehouse), + ProductWarehouse: mapProductWarehouseDTO(&s.ProductWarehouse), } } return result @@ -199,9 +250,10 @@ func ToRecordingEggDTOs(eggs []entity.RecordingEgg) []RecordingEggDTO { result := make([]RecordingEggDTO, len(eggs)) for i, egg := range eggs { result[i] = RecordingEggDTO{ + Id: egg.Id, ProductWarehouseId: egg.ProductWarehouseId, Qty: egg.Qty, - ProductWarehouse: toRecordingProductWarehouseDTO(&egg.ProductWarehouse), + ProductWarehouse: mapProductWarehouseDTO(&egg.ProductWarehouse), Gradings: ToRecordingEggGradingDTOs(egg.GradingEggs), } } @@ -224,25 +276,17 @@ func ToRecordingEggGradingDTOs(gradings []entity.GradingEgg) []RecordingEggGradi return result } -func toRecordingProductWarehouseDTO(pw *entity.ProductWarehouse) *RecordingProductWarehouseDTO { - if pw == nil || pw.Id == 0 { - return nil +func mapProductWarehouseDTO(pw *entity.ProductWarehouse) productWarehouseDTO.ProductWarehouseDTO { + if pw == nil { + return productWarehouseDTO.ProductWarehouseDTO{} } - dto := RecordingProductWarehouseDTO{ - Id: pw.Id, - ProductId: pw.ProductId, - WarehouseId: pw.WarehouseId, + mapped := productWarehouseDTO.ToProductWarehouseDTO(pw) + if mapped == nil { + return productWarehouseDTO.ProductWarehouseDTO{} } - if pw.Product.Id != 0 { - dto.ProductName = pw.Product.Name - } - if pw.Warehouse.Id != 0 { - dto.WarehouseName = pw.Warehouse.Name - } - - return &dto + return *mapped } const goodEggProductWarehouseID uint = 5 diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index 832c9ce0..d9512edd 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -254,18 +254,22 @@ func (r *RecordingRepositoryImpl) FindPreviousRecording(tx *gorm.DB, projectFloc } func (r *RecordingRepositoryImpl) GetTotalChick(tx *gorm.DB, projectFlockKandangId uint) (int64, error) { - var population entity.ProjectFlockPopulation + var total float64 err := tx. - Where("project_flock_kandang_id = ?", projectFlockKandangId). - Order("created_at DESC"). - First(&population).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return 0, nil - } + Table("project_flock_populations"). + Select("COALESCE(SUM(project_flock_populations.total_qty - project_flock_populations.total_used_qty), 0) AS total_qty"). + Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id"). + Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangId). + Scan(&total).Error if err != nil { return 0, err } - return int64(math.Round(population.InitialQuantity)), nil + + if total < 0 { + total = 0 + } + + return int64(math.Round(total)), nil } func (r *RecordingRepositoryImpl) GetAverageBodyWeight(tx *gorm.DB, recordingID uint) (float64, error) { diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index e8836590..b31a90c0 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -261,6 +261,10 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin return nil, err } + if req.BodyWeights == nil && req.Stocks == nil && req.Depletions == nil && req.Eggs == nil { + return s.GetOne(c, id) + } + ctx := c.Context() var recordingEntity *entity.Recording @@ -277,12 +281,21 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } recordingEntity = recording + hasBodyChanges := req.BodyWeights != nil + hasStockChanges := req.Stocks != nil + hasDepletionChanges := req.Depletions != nil + hasEggChanges := req.Eggs != nil + + if !hasBodyChanges && !hasStockChanges && !hasDepletionChanges && !hasEggChanges { + return nil + } + 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 hasEggChanges { if !isLaying && len(req.Eggs) > 0 { return fiber.NewError(fiber.StatusBadRequest, "Egg details permitted only for laying project flocks") } @@ -291,7 +304,29 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } } - if req.BodyWeights != nil { + if hasStockChanges { + if err := s.ensureProductWarehousesExist(c, req.Stocks, nil, nil); err != nil { + return err + } + } + + if hasDepletionChanges || hasEggChanges { + if err := s.ensureProductWarehousesExist(c, nil, req.Depletions, req.Eggs); err != nil { + return err + } + } + + hasExistingGradings := false + for _, egg := range recordingEntity.Eggs { + if len(egg.GradingEggs) > 0 { + hasExistingGradings = true + break + } + } + + hasEggsAfterUpdate := len(recordingEntity.Eggs) > 0 + + if hasBodyChanges { if err := s.Repository.DeleteBodyWeights(tx, recordingEntity.Id); err != nil { s.Log.Errorf("Failed to clear body weights: %+v", err) return err @@ -302,11 +337,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } } - if req.Stocks != nil { - if err := s.ensureProductWarehousesExist(c, req.Stocks, nil, nil); err != nil { - return err - } - + if hasStockChanges { existingStocks, err := s.Repository.ListStocks(tx, recordingEntity.Id) if err != nil { s.Log.Errorf("Failed to list existing stocks: %+v", err) @@ -330,17 +361,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } } - 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 - } - + if hasDepletionChanges { existingDepletions, err := s.Repository.ListDepletions(tx, recordingEntity.Id) if err != nil { s.Log.Errorf("Failed to list existing depletions: %+v", err) @@ -364,7 +385,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } } - if req.Eggs != nil { + if hasEggChanges { existingEggs, err := s.Repository.ListEggs(tx, recordingEntity.Id) if err != nil { s.Log.Errorf("Failed to list existing eggs: %+v", err) @@ -386,17 +407,71 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin s.Log.Errorf("Failed to adjust product warehouses for eggs: %+v", err) return err } + + hasExistingGradings = false + hasEggsAfterUpdate = len(req.Eggs) > 0 } - if err := s.computeAndUpdateMetrics(ctx, tx, recordingEntity); err != nil { - s.Log.Errorf("Failed to recompute recording metrics: %+v", err) - return err + if hasBodyChanges || hasStockChanges || hasDepletionChanges { + 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 + actorID := recordingEntity.CreatedBy + if actorID == 0 { + actorID = 1 + } + + var step approvalutils.ApprovalStep + if isLaying { + if !hasEggsAfterUpdate { + step = utils.RecordingStepGradingTelur + } else if hasEggChanges { + step = utils.RecordingStepGradingTelur + } else if hasExistingGradings { + step = utils.RecordingStepPengajuan + } else { + step = utils.RecordingStepGradingTelur + } + } else { + step = utils.RecordingStepPengajuan + } + + latestApproval := recordingEntity.LatestApproval + if latestApproval == nil { + if s.ApprovalSvc != nil { + if fetched, fetchErr := s.ApprovalSvc.LatestByTarget(ctx, utils.ApprovalWorkflowRecording, recordingEntity.Id, nil); fetchErr != nil { + s.Log.Errorf("Failed to load latest approval for recording %d: %+v", recordingEntity.Id, fetchErr) + return fetchErr + } else { + latestApproval = fetched + } + } else if s.ApprovalRepo != nil { + if fetched, fetchErr := s.ApprovalRepo.LatestByTarget(ctx, utils.ApprovalWorkflowRecording.String(), recordingEntity.Id, nil); fetchErr != nil { + s.Log.Errorf("Failed to load latest approval for recording %d: %+v", recordingEntity.Id, fetchErr) + return fetchErr + } else { + latestApproval = fetched + } + } + } + + shouldCreateApproval := true + if latestApproval != nil && + latestApproval.StepNumber == uint16(step) && + latestApproval.Action != nil && + *latestApproval.Action == action { + shouldCreateApproval = false + } + + if shouldCreateApproval { + if err := s.createRecordingApproval(ctx, tx, recordingEntity.Id, step, action, actorID, nil); err != nil { + s.Log.Errorf("Failed to create approval after recording update %d: %+v", recordingEntity.Id, err) + return err + } } return nil @@ -1015,13 +1090,21 @@ func (s *recordingService) ensureChickInExists(ctx context.Context, projectFlock return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid") } - _, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(ctx, projectFlockKandangID) - if err == nil { - return nil + populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(ctx, projectFlockKandangID) + if err != nil { + 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") } - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusBadRequest, "Project flock belum melakukan chick in sehingga belum dapat membuat recording") + + if len(populations) == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Project flock belum memiliki chick in yang disetujui 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") + + for _, population := range populations { + if population.TotalQty > 0 { + return nil + } + } + + return fiber.NewError(fiber.StatusBadRequest, "Chick in project flock belum disetujui sehingga belum dapat membuat recording") } diff --git a/internal/modules/production/recordings/validations/recording.validation.go b/internal/modules/production/recordings/validations/recording.validation.go index f058248c..28ea8a9f 100644 --- a/internal/modules/production/recordings/validations/recording.validation.go +++ b/internal/modules/production/recordings/validations/recording.validation.go @@ -4,12 +4,12 @@ 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"` + TotalWeight *float64 `json:"total_weight,omitempty" validate:"omitempty,gte=0"` } Stock struct { ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"` - Qty *float64 `json:"qty,omitempty" validate:"required_without=UsageAmount,gte=0"` + Qty float64 `json:"qty" validate:"required,gte=0"` PendingQty *float64 `json:"pending_qty,omitempty" validate:"omitempty,gte=0"` } @@ -26,10 +26,10 @@ type ( type Create struct { 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"` + BodyWeights []BodyWeight `json:"body_weights" validate:"dive"` + Stocks []Stock `json:"stocks" validate:"dive"` + Depletions []Depletion `json:"depletions" validate:"dive"` + Eggs []Egg `json:"eggs" validate:"omitempty,dive"` } type Update struct { diff --git a/internal/modules/production/route.go b/internal/modules/production/route.go index b41ef1e7..d1425b7c 100644 --- a/internal/modules/production/route.go +++ b/internal/modules/production/route.go @@ -10,6 +10,8 @@ import ( chickins "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins" projectflocks "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks" recordings "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings" + transferLayings "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings" + projectFlockKandangs "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project-flock-kandangs" // MODULE IMPORTS ) @@ -20,8 +22,10 @@ func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Valida projectflocks.ProjectflockModule{}, recordings.RecordingModule{}, chickins.ChickinModule{}, + transferLayings.TransferLayingModule{}, + projectFlockKandangs.ProjectFlockKandangModule{}, // MODULE REGISTRY - } +} for _, m := range allModules { m.RegisterRoutes(group, db, validate) diff --git a/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go b/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go new file mode 100644 index 00000000..c69f4ff5 --- /dev/null +++ b/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go @@ -0,0 +1,217 @@ +package controller + +import ( + "fmt" + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type TransferLayingController struct { + TransferLayingService service.TransferLayingService +} + +func NewTransferLayingController(transferLayingService service.TransferLayingService) *TransferLayingController { + return &TransferLayingController{ + TransferLayingService: transferLayingService, + } +} + +func (u *TransferLayingController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + } + + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + result, totalResults, err := u.TransferLayingService.GetAll(c, query) + if err != nil { + return err + } + + data := make([]dto.TransferLayingDetailDTO, len(result)) + for i, transfer := range result { + data[i] = dto.ToTransferLayingDetailDTOWithSingleApproval(transfer, transfer.LatestApproval) + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.TransferLayingDetailDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all transferLayings successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: data, + }) +} + +func (u *TransferLayingController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, approval, err := u.TransferLayingService.GetOneWithApproval(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get transferLaying successfully", + Data: dto.ToTransferLayingDetailDTOWithSingleApproval(*result, approval), + }) +} + +func (u *TransferLayingController) CreateOne(c *fiber.Ctx) error { + req := new(validation.Create) + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.TransferLayingService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create transferLaying successfully", + Data: dto.ToTransferLayingListDTO(*result), + }) +} + +func (u *TransferLayingController) UpdateOne(c *fiber.Ctx) error { + req := new(validation.Update) + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid request body: %s", err.Error())) + } + + result, err := u.TransferLayingService.UpdateOne(c, req, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Update transferLaying successfully", + Data: dto.ToTransferLayingDetailDTOWithSingleApproval(*result, result.LatestApproval), + }) +} + +func (u *TransferLayingController) DeleteOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := u.TransferLayingService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete transferLaying successfully", + }) +} + +func (u *TransferLayingController) Approval(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.TransferLayingService.Approval(c, req) + if err != nil { + return err + } + + var ( + data interface{} + message = "Submit transfer laying approval successfully" + ) + if len(results) == 1 { + data = dto.ToTransferLayingListDTO(results[0]) + } else { + message = "Submit transfer laying approvals successfully" + data = dto.ToTransferLayingListDTOs(results) + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: message, + Data: data, + }) +} + + +func (u *TransferLayingController) GetAvailableQtyPerKandang(c *fiber.Ctx) error { + projectFlockID, err := strconv.ParseUint(c.Params("project_flock_id"), 10, 32) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id") + } + + pf, kandangQtyMap, err := u.TransferLayingService.GetAvailableQtyPerKandang(c, uint(projectFlockID)) + if err != nil { + return err + } + + // Build kandang list response + kandangs := make([]dto.KandangAvailableQtyDTO, 0, len(kandangQtyMap)) + for kandangPFID, qty := range kandangQtyMap { + kandangs = append(kandangs, dto.KandangAvailableQtyDTO{ + ProjectFlockKandangId: kandangPFID, + AvailableQty: qty, + }) + } + + resp := dto.AvailableQtyForTransferDTO{ + ProjectFlockId: pf.Id, + ProjectFlockCode: pf.FlockName, + Category: pf.Category, + Kandangs: kandangs, + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get available quantity successfully", + Data: resp, + }) +} diff --git a/internal/modules/production/transfer_layings/dto/transfer_laying.dto.go b/internal/modules/production/transfer_layings/dto/transfer_laying.dto.go new file mode 100644 index 00000000..ef537e19 --- /dev/null +++ b/internal/modules/production/transfer_layings/dto/transfer_laying.dto.go @@ -0,0 +1,281 @@ +package dto + +import ( + "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" +) + +// === DTO Structs === + +type TransferLayingBaseDTO struct { + Id uint `json:"id"` + TransferNumber string `json:"transfer_number"` + TransferDate time.Time `json:"transfer_date"` + Notes string `json:"notes"` +} + +type ProjectFlockSummaryDTO struct { + Id uint `json:"id"` + FlockName string `json:"flock_name"` + Category string `json:"category"` +} + +type ProductSummaryDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type WarehouseSummaryDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + Type string `json:"type"` +} + +type ProductWarehouseSummaryDTO struct { + Product *ProductSummaryDTO `json:"product,omitempty"` + Warehouse *WarehouseSummaryDTO `json:"warehouse,omitempty"` +} + +type ProjectFlockKandangSummaryDTO struct { + Id uint `json:"id"` + Kandang *KandangSummaryDTO `json:"kandang,omitempty"` +} + +type KandangSummaryDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type LayingTransferSourceDTO struct { + SourceProjectFlockKandang *ProjectFlockKandangSummaryDTO `json:"source_project_flock_kandang,omitempty"` + Qty float64 `json:"qty"` + ProductWarehouse *ProductWarehouseSummaryDTO `json:"product_warehouse,omitempty"` + Note string `json:"note,omitempty"` +} + +type LayingTransferTargetDTO struct { + TargetProjectFlockKandang *ProjectFlockKandangSummaryDTO `json:"target_project_flock_kandang,omitempty"` + Qty float64 `json:"qty"` + ProductWarehouse *ProductWarehouseSummaryDTO `json:"product_warehouse,omitempty"` + Note string `json:"note,omitempty"` +} + +type TransferLayingListDTO struct { + TransferLayingBaseDTO + FromProjectFlock *ProjectFlockSummaryDTO `json:"from_project_flock,omitempty"` + ToProjectFlock *ProjectFlockSummaryDTO `json:"to_project_flock,omitempty"` + PendingUsageQty *float64 `json:"pending_usage_qty"` + UsageQty *float64 `json:"usage_qty"` + CreatedBy uint `json:"created_by"` + CreatedUser *userDTO.UserBaseDTO `json:"created_user,omitempty"` + CreatedAt time.Time `json:"created_at"` + Approval *approvalDTO.ApprovalBaseDTO `json:"approval,omitempty"` +} + +type TransferLayingDetailDTO struct { + TransferLayingListDTO + Sources []LayingTransferSourceDTO `json:"sources,omitempty"` + Targets []LayingTransferTargetDTO `json:"targets,omitempty"` + Approval *approvalDTO.ApprovalBaseDTO `json:"approval,omitempty"` +} + +// === Available Quantity DTOs === + +type KandangAvailableQtyDTO struct { + ProjectFlockKandangId uint `json:"project_flock_kandang_id"` + AvailableQty float64 `json:"available_qty"` +} + +type AvailableQtyForTransferDTO struct { + ProjectFlockId uint `json:"project_flock_id"` + ProjectFlockCode string `json:"flock_name"` + Category string `json:"category"` + Kandangs []KandangAvailableQtyDTO `json:"kandangs"` +} + +// === Mapper Functions === + +func ToProjectFlockSummaryDTO(pf *entity.ProjectFlock) *ProjectFlockSummaryDTO { + if pf == nil || pf.Id == 0 { + return nil + } + + return &ProjectFlockSummaryDTO{ + Id: pf.Id, + FlockName: pf.FlockName, + Category: pf.Category, + } +} + +func ToProjectFlockKandangSummaryDTO(pfk *entity.ProjectFlockKandang) *ProjectFlockKandangSummaryDTO { + if pfk == nil || pfk.Id == 0 { + return nil + } + + var kandang *KandangSummaryDTO + if pfk.Kandang.Id != 0 { + kandang = &KandangSummaryDTO{ + Id: pfk.Kandang.Id, + Name: pfk.Kandang.Name, + } + } + + return &ProjectFlockKandangSummaryDTO{ + Id: pfk.Id, + Kandang: kandang, + } +} + +func ToProductSummaryDTO(product *entity.Product) *ProductSummaryDTO { + if product == nil || product.Id == 0 { + return nil + } + + return &ProductSummaryDTO{ + Id: product.Id, + Name: product.Name, + } +} + +func ToWarehouseSummaryDTO(warehouse *entity.Warehouse) *WarehouseSummaryDTO { + if warehouse == nil || warehouse.Id == 0 { + return nil + } + + return &WarehouseSummaryDTO{ + Id: warehouse.Id, + Name: warehouse.Name, + Type: warehouse.Type, + } +} + +func ToProductWarehouseSummaryDTO(pw *entity.ProductWarehouse) *ProductWarehouseSummaryDTO { + if pw == nil || pw.Id == 0 { + return nil + } + + return &ProductWarehouseSummaryDTO{ + Product: ToProductSummaryDTO(&pw.Product), + Warehouse: ToWarehouseSummaryDTO(&pw.Warehouse), + } +} + +func ToLayingTransferSourceDTO(source entity.LayingTransferSource) LayingTransferSourceDTO { + return LayingTransferSourceDTO{ + SourceProjectFlockKandang: ToProjectFlockKandangSummaryDTO(source.SourceProjectFlockKandang), + Qty: source.Qty, + ProductWarehouse: ToProductWarehouseSummaryDTO(source.ProductWarehouse), + Note: source.Note, + } +} + +func ToLayingTransferSourceDTOs(sources []entity.LayingTransferSource) []LayingTransferSourceDTO { + if len(sources) == 0 { + return []LayingTransferSourceDTO{} + } + result := make([]LayingTransferSourceDTO, len(sources)) + for i, s := range sources { + result[i] = ToLayingTransferSourceDTO(s) + } + return result +} + +func ToLayingTransferTargetDTO(target entity.LayingTransferTarget) LayingTransferTargetDTO { + return LayingTransferTargetDTO{ + TargetProjectFlockKandang: ToProjectFlockKandangSummaryDTO(target.TargetProjectFlockKandang), + Qty: target.Qty, + ProductWarehouse: ToProductWarehouseSummaryDTO(target.ProductWarehouse), + Note: target.Note, + } +} + +func ToLayingTransferTargetDTOs(targets []entity.LayingTransferTarget) []LayingTransferTargetDTO { + if len(targets) == 0 { + return []LayingTransferTargetDTO{} + } + result := make([]LayingTransferTargetDTO, len(targets)) + for i, t := range targets { + result[i] = ToLayingTransferTargetDTO(t) + } + return result +} + +func ToTransferLayingBaseDTO(e entity.LayingTransfer) TransferLayingBaseDTO { + return TransferLayingBaseDTO{ + Id: e.Id, + TransferNumber: e.TransferNumber, + TransferDate: e.TransferDate, + Notes: e.Notes, + } +} + +func ToTransferLayingListDTO(e entity.LayingTransfer) TransferLayingListDTO { + var createdUser *userDTO.UserBaseDTO + if e.CreatedUser != nil && e.CreatedUser.Id != 0 { + mapped := userDTO.ToUserBaseDTO(*e.CreatedUser) + createdUser = &mapped + } + + return TransferLayingListDTO{ + TransferLayingBaseDTO: ToTransferLayingBaseDTO(e), + FromProjectFlock: ToProjectFlockSummaryDTO(e.FromProjectFlock), + ToProjectFlock: ToProjectFlockSummaryDTO(e.ToProjectFlock), + PendingUsageQty: e.PendingUsageQty, + UsageQty: e.UsageQty, + CreatedBy: e.CreatedBy, + CreatedUser: createdUser, + CreatedAt: e.CreatedAt, + } +} + +func ToTransferLayingDetailDTO(e entity.LayingTransfer, approvals []entity.Approval) TransferLayingDetailDTO { + var latestApproval *approvalDTO.ApprovalBaseDTO + + if e.LatestApproval != nil { + mapped := approvalDTO.ToApprovalDTO(*e.LatestApproval) + latestApproval = &mapped + } else if len(approvals) > 0 { + // Fallback to approvals slice + latest := approvalDTO.ToApprovalDTO(approvals[len(approvals)-1]) + latestApproval = &latest + } + + return TransferLayingDetailDTO{ + TransferLayingListDTO: ToTransferLayingListDTO(e), + Sources: ToLayingTransferSourceDTOs(e.Sources), + Targets: ToLayingTransferTargetDTOs(e.Targets), + Approval: latestApproval, + } +} + +func ToTransferLayingDetailDTOWithSingleApproval(e entity.LayingTransfer, approval *entity.Approval) TransferLayingDetailDTO { + var mappedApproval *approvalDTO.ApprovalBaseDTO + + // Prefer LatestApproval from entity + if e.LatestApproval != nil && e.LatestApproval.Id != 0 { + mapped := approvalDTO.ToApprovalDTO(*e.LatestApproval) + mappedApproval = &mapped + } else if approval != nil && approval.Id != 0 { + // Fallback to passed approval parameter + mapped := approvalDTO.ToApprovalDTO(*approval) + mappedApproval = &mapped + } + + return TransferLayingDetailDTO{ + TransferLayingListDTO: ToTransferLayingListDTO(e), + Sources: ToLayingTransferSourceDTOs(e.Sources), + Targets: ToLayingTransferTargetDTOs(e.Targets), + Approval: mappedApproval, + } +} + +func ToTransferLayingListDTOs(items []entity.LayingTransfer) []TransferLayingListDTO { + result := make([]TransferLayingListDTO, len(items)) + for i, item := range items { + result[i] = ToTransferLayingListDTO(item) + } + return result +} diff --git a/internal/modules/production/transfer_layings/module.go b/internal/modules/production/transfer_layings/module.go new file mode 100644 index 00000000..27851b71 --- /dev/null +++ b/internal/modules/production/transfer_layings/module.go @@ -0,0 +1,53 @@ +package transfer_layings + +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" + rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + rTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/repositories" + sTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/services" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + rInventory "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type TransferLayingModule struct{} + +func (TransferLayingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + transferLayingRepo := rTransferLaying.NewTransferLayingRepository(db) + userRepo := rUser.NewUserRepository(db) + projectFlockRepo := rProjectFlock.NewProjectflockRepository(db) + projectFlockKandangRepo := rProjectFlock.NewProjectFlockKandangRepository(db) + projectFlockPopulationRepo := rProjectFlock.NewProjectFlockPopulationRepository(db) + productWarehouseRepo := rInventory.NewProductWarehouseRepository(db) + warehouseRepo := rWarehouse.NewWarehouseRepository(db) + + approvalRepo := commonRepo.NewApprovalRepository(db) + approvalService := commonSvc.NewApprovalService(approvalRepo) + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowTransferToLaying, utils.TransferToLayingApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register transfer to laying approval workflow: %v", err)) + } + + transferLayingService := sTransferLaying.NewTransferLayingService( + transferLayingRepo, + projectFlockRepo, + projectFlockKandangRepo, + projectFlockPopulationRepo, + productWarehouseRepo, + warehouseRepo, + approvalService, + validate, + ) + userService := sUser.NewUserService(userRepo, validate) + + TransferLayingRoutes(router, userService, transferLayingService) +} diff --git a/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go b/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go new file mode 100644 index 00000000..3dab5120 --- /dev/null +++ b/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go @@ -0,0 +1,42 @@ +package repository + +import ( + "context" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type TransferLayingRepository interface { + repository.BaseRepository[entity.LayingTransfer] + GetByTransferNumber(ctx context.Context, transferNumber string) (*entity.LayingTransfer, error) + IdExists(ctx context.Context, id uint) (bool, error) +} + +type TransferLayingRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.LayingTransfer] + db *gorm.DB +} + +func NewTransferLayingRepository(db *gorm.DB) TransferLayingRepository { + return &TransferLayingRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.LayingTransfer](db), + db: db, + } +} +func (r *TransferLayingRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.LayingTransfer](ctx, r.db, id) +} + +func (r *TransferLayingRepositoryImpl) GetByTransferNumber(ctx context.Context, transferNumber string) (*entity.LayingTransfer, error) { + var transfer entity.LayingTransfer + err := r.db.WithContext(ctx). + Where("transfer_number = ?", transferNumber). + Where("deleted_at IS NULL"). + First(&transfer).Error + if err != nil { + return nil, err + } + return &transfer, nil +} diff --git a/internal/modules/production/transfer_layings/repositories/laying_transfer_source.repository.go b/internal/modules/production/transfer_layings/repositories/laying_transfer_source.repository.go new file mode 100644 index 00000000..6c52683e --- /dev/null +++ b/internal/modules/production/transfer_layings/repositories/laying_transfer_source.repository.go @@ -0,0 +1,38 @@ +package repository + +import ( + "context" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type LayingTransferSourceRepository interface { + repository.BaseRepository[entity.LayingTransferSource] + GetByLayingTransferId(ctx context.Context, layingTransferId uint) ([]entity.LayingTransferSource, error) +} + +type LayingTransferSourceRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.LayingTransferSource] + db *gorm.DB +} + +func NewLayingTransferSourceRepository(db *gorm.DB) LayingTransferSourceRepository { + return &LayingTransferSourceRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.LayingTransferSource](db), + db: db, + } +} + +func (r *LayingTransferSourceRepositoryImpl) GetByLayingTransferId(ctx context.Context, layingTransferId uint) ([]entity.LayingTransferSource, error) { + var sources []entity.LayingTransferSource + err := r.db.WithContext(ctx). + Where("laying_transfer_id = ?", layingTransferId). + Order("created_at DESC"). + Find(&sources).Error + if err != nil { + return nil, err + } + return sources, nil +} diff --git a/internal/modules/production/transfer_layings/repositories/laying_transfer_target.repository.go b/internal/modules/production/transfer_layings/repositories/laying_transfer_target.repository.go new file mode 100644 index 00000000..486008cc --- /dev/null +++ b/internal/modules/production/transfer_layings/repositories/laying_transfer_target.repository.go @@ -0,0 +1,38 @@ +package repository + +import ( + "context" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type LayingTransferTargetRepository interface { + repository.BaseRepository[entity.LayingTransferTarget] + GetByLayingTransferId(ctx context.Context, layingTransferId uint) ([]entity.LayingTransferTarget, error) +} + +type LayingTransferTargetRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.LayingTransferTarget] + db *gorm.DB +} + +func NewLayingTransferTargetRepository(db *gorm.DB) LayingTransferTargetRepository { + return &LayingTransferTargetRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.LayingTransferTarget](db), + db: db, + } +} + +func (r *LayingTransferTargetRepositoryImpl) GetByLayingTransferId(ctx context.Context, layingTransferId uint) ([]entity.LayingTransferTarget, error) { + var targets []entity.LayingTransferTarget + err := r.db.WithContext(ctx). + Where("laying_transfer_id = ?", layingTransferId). + Order("created_at DESC"). + Find(&targets).Error + if err != nil { + return nil, err + } + return targets, nil +} diff --git a/internal/modules/production/transfer_layings/route.go b/internal/modules/production/transfer_layings/route.go new file mode 100644 index 00000000..ad0cb9e1 --- /dev/null +++ b/internal/modules/production/transfer_layings/route.go @@ -0,0 +1,31 @@ +package transfer_layings + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/controllers" + transferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func TransferLayingRoutes(v1 fiber.Router, u user.UserService, s transferLaying.TransferLayingService) { + ctrl := controller.NewTransferLayingController(s) + + route := v1.Group("/transfer_layings") + + // 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.Post("/approval", m.Auth(u), ctrl.Approval) + + route.Get("/", ctrl.GetAll) + route.Post("/", ctrl.CreateOne) + route.Get("/:id", ctrl.GetOne) + route.Patch("/:id", ctrl.UpdateOne) + route.Delete("/:id", ctrl.DeleteOne) + route.Post("/approvals", ctrl.Approval) + route.Get("/project-flocks/:project_flock_id/available-qty", ctrl.GetAvailableQtyPerKandang) +} diff --git a/internal/modules/production/transfer_layings/services/transfer_laying.service.go b/internal/modules/production/transfer_layings/services/transfer_laying.service.go new file mode 100644 index 00000000..2aa7129c --- /dev/null +++ b/internal/modules/production/transfer_layings/services/transfer_laying.service.go @@ -0,0 +1,873 @@ +package service + +import ( + "context" + "errors" + "fmt" + "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" + rInventory "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + ProjectFlockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type TransferLayingService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.LayingTransfer, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.LayingTransfer, error) + GetOneWithApproval(ctx *fiber.Ctx, id uint) (*entity.LayingTransfer, *entity.Approval, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.LayingTransfer, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.LayingTransfer, error) + DeleteOne(ctx *fiber.Ctx, id uint) error + Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.LayingTransfer, error) + GetAvailableQtyPerKandang(ctx *fiber.Ctx, projectFlockID uint) (*entity.ProjectFlock, map[uint]float64, error) +} + +type transferLayingService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.TransferLayingRepository + ProjectFlockRepo ProjectFlockRepository.ProjectflockRepository + ProjectFlockKandangRepo ProjectFlockRepository.ProjectFlockKandangRepository + ProjectFlockPopulationRepo ProjectFlockRepository.ProjectFlockPopulationRepository + ProductWarehouseRepo rInventory.ProductWarehouseRepository + WarehouseRepo rWarehouse.WarehouseRepository + ApprovalService commonSvc.ApprovalService +} + +func NewTransferLayingService( + repo repository.TransferLayingRepository, + projectFlockRepo ProjectFlockRepository.ProjectflockRepository, + projectFlockKandangRepo ProjectFlockRepository.ProjectFlockKandangRepository, + projectFlockPopulationRepo ProjectFlockRepository.ProjectFlockPopulationRepository, + productWarehouseRepo rInventory.ProductWarehouseRepository, + warehouseRepo rWarehouse.WarehouseRepository, + approvalService commonSvc.ApprovalService, + validate *validator.Validate, +) TransferLayingService { + return &transferLayingService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + ProjectFlockRepo: projectFlockRepo, + ProjectFlockKandangRepo: projectFlockKandangRepo, + ProjectFlockPopulationRepo: projectFlockPopulationRepo, + ProductWarehouseRepo: productWarehouseRepo, + WarehouseRepo: warehouseRepo, + ApprovalService: approvalService, + } +} + +func (s transferLayingService) withRelations(db *gorm.DB) *gorm.DB { + return db. + Preload("CreatedUser"). + Preload("FromProjectFlock"). + Preload("ToProjectFlock"). + Preload("Sources"). + Preload("Sources.SourceProjectFlockKandang"). + Preload("Sources.SourceProjectFlockKandang.Kandang"). + Preload("Sources.ProductWarehouse"). + Preload("Sources.ProductWarehouse.Product"). + Preload("Sources.ProductWarehouse.Warehouse"). + Preload("Targets"). + Preload("Targets.TargetProjectFlockKandang"). + Preload("Targets.TargetProjectFlockKandang.Kandang"). + Preload("Targets.ProductWarehouse"). + Preload("Targets.ProductWarehouse.Product"). + Preload("Targets.ProductWarehouse.Warehouse") +} + +func (s transferLayingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.LayingTransfer, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + transferLayings, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + db = db.Order("created_at DESC") + return db + }) + + if err != nil { + s.Log.Errorf("Failed to get transferLayings: %+v", err) + return nil, 0, err + } + + approvalRepo := commonRepo.NewApprovalRepository(s.Repository.DB()) + for i, transfer := range transferLayings { + latestApproval, err := approvalRepo.LatestByTarget(c.Context(), string(utils.ApprovalWorkflowTransferToLaying), transfer.Id, func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + }) + if err == nil && latestApproval != nil { + transferLayings[i].LatestApproval = latestApproval + } + } + + return transferLayings, total, nil +} + +func (s transferLayingService) GetOne(c *fiber.Ctx, id uint) (*entity.LayingTransfer, error) { + transferLaying, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "TransferLaying not found") + } + if err != nil { + s.Log.Errorf("Failed get transferLaying by id: %+v", err) + return nil, err + } + + approvalRepo := commonRepo.NewApprovalRepository(s.Repository.DB()) + latestApproval, err := approvalRepo.LatestByTarget(c.Context(), string(utils.ApprovalWorkflowTransferToLaying), transferLaying.Id, func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + }) + if err == nil && latestApproval != nil { + transferLaying.LatestApproval = latestApproval + } + + return transferLaying, nil +} + +func (s transferLayingService) GetOneWithApproval(c *fiber.Ctx, id uint) (*entity.LayingTransfer, *entity.Approval, error) { + transferLaying, err := s.GetOne(c, id) + if err != nil { + return nil, nil, err + } + + return transferLaying, transferLaying.LatestApproval, nil +} + +func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.LayingTransfer, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + if _, err := s.ProjectFlockRepo.GetByID(c.Context(), req.SourceProjectFlockId, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Source Project Flock not found") + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate source project flock") + } + + if _, err := s.ProjectFlockRepo.GetByID(c.Context(), req.TargetProjectFlockId, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Target Project Flock not found") + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate target project flock") + } + + for _, detail := range req.SourceKandangs { + if err := commonSvc.EnsureRelations(c.Context(), + commonSvc.RelationCheck{Name: "Source Project Flock Kandang", ID: &detail.ProjectFlockKandangId, Exists: s.ProjectFlockKandangRepo.IdExists}, + ); err != nil { + return nil, err + } + + pfk, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), detail.ProjectFlockKandangId) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get source project flock kandang") + } + if pfk.ProjectFlockId != req.SourceProjectFlockId { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Source kandang %d does not belong to source project flock %d", detail.ProjectFlockKandangId, req.SourceProjectFlockId)) + } + } + + for _, detail := range req.TargetKandangs { + if err := commonSvc.EnsureRelations(c.Context(), + commonSvc.RelationCheck{Name: "Target Project Flock Kandang", ID: &detail.ProjectFlockKandangId, Exists: s.ProjectFlockKandangRepo.IdExists}, + ); err != nil { + return nil, err + } + + pfk, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), detail.ProjectFlockKandangId) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get target project flock kandang") + } + if pfk.ProjectFlockId != req.TargetProjectFlockId { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Target kandang %d does not belong to target project flock %d", detail.ProjectFlockKandangId, req.TargetProjectFlockId)) + } + } + + transferDate, err := utils.ParseDateString(req.TransferDate) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transfer date format") + } + + var totalSourceQty, totalTargetQty float64 + sourceWarehouseMap := make(map[uint]uint) + + for _, sourceDetail := range req.SourceKandangs { + if sourceDetail.Quantity <= 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Source kandang quantity must be greater than 0") + } + totalSourceQty += sourceDetail.Quantity + + populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(c.Context(), sourceDetail.ProjectFlockKandangId) + if err != nil { + return nil, err + } + + var totalPopulation float64 + var productWarehouseId uint + for _, pop := range populations { + totalPopulation += pop.TotalQty + if productWarehouseId == 0 { + productWarehouseId = pop.ProductWarehouseId + } + } + + if totalPopulation == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Source kandang %d has no population available for transfer", sourceDetail.ProjectFlockKandangId)) + } + + if totalPopulation < sourceDetail.Quantity { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Source kandang %d has insufficient quantity. Available: %.0f, Requested: %.0f", sourceDetail.ProjectFlockKandangId, totalPopulation, sourceDetail.Quantity)) + } + + sourceWarehouseMap[sourceDetail.ProjectFlockKandangId] = productWarehouseId + } + + for _, targetDetail := range req.TargetKandangs { + if targetDetail.Quantity <= 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Target kandang quantity must be greater than 0") + } + totalTargetQty += targetDetail.Quantity + } + + if totalSourceQty != totalTargetQty { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Total source quantity (%f) must equal total target quantity (%f)", totalSourceQty, totalTargetQty)) + } + + transferNumber := fmt.Sprintf("TL-%d", time.Now().UnixNano()) + + createBody := &entity.LayingTransfer{ + TransferNumber: transferNumber, + Notes: req.Reason, + FromProjectFlockId: req.SourceProjectFlockId, + ToProjectFlockId: req.TargetProjectFlockId, + TransferDate: transferDate, + PendingUsageQty: &totalSourceQty, + CreatedBy: 1, //todo : harus diambil dari auth + } + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + + if err := s.Repository.WithTx(dbTransaction).CreateOne(c.Context(), createBody, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create transfer laying record") + } + + productWarehouseRepoTx := s.ProductWarehouseRepo.WithTx(dbTransaction) + projectFlockPopulationRepoTx := s.ProjectFlockPopulationRepo.WithTx(dbTransaction) + + for _, sourceDetail := range req.SourceKandangs { + productWarehouseId := sourceWarehouseMap[sourceDetail.ProjectFlockKandangId] + + source := entity.LayingTransferSource{ + LayingTransferId: createBody.Id, + SourceProjectFlockKandangId: sourceDetail.ProjectFlockKandangId, + Qty: sourceDetail.Quantity, + ProductWarehouseId: &productWarehouseId, + } + if err := dbTransaction.Create(&source).Error; err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create transfer source") + } + + if err := s.reduceProjectFlockPopulation(c.Context(), projectFlockPopulationRepoTx, sourceDetail.ProjectFlockKandangId, sourceDetail.Quantity); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to reduce project flock population") + } + + if err := productWarehouseRepoTx.PatchOne(c.Context(), productWarehouseId, map[string]any{"quantity": gorm.Expr("quantity - ?", sourceDetail.Quantity)}, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update source warehouse quantity") + } + } + + for _, targetDetail := range req.TargetKandangs { + + targetPFK, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), targetDetail.ProjectFlockKandangId) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get target project flock kandang") + } + + targetWarehouse, err := s.WarehouseRepo.GetLatestByKandangID(c.Context(), targetPFK.KandangId) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("No warehouse found for target kandang %d", targetDetail.ProjectFlockKandangId)) + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get target warehouse") + } + + target := entity.LayingTransferTarget{ + LayingTransferId: createBody.Id, + TargetProjectFlockKandangId: targetDetail.ProjectFlockKandangId, + Qty: targetDetail.Quantity, + ProductWarehouseId: &targetWarehouse.Id, + } + if err := dbTransaction.Create(&target).Error; err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create transfer target") + } + } + + if err := createApprovalTransferLaying(c.Context(), dbTransaction, createBody.Id, createBody.CreatedBy); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create transfer approval") + } + + return nil + }) + + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create transfer laying") + } + + return s.GetOne(c, createBody.Id) +} + +func (s transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.LayingTransfer, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + existingTransfer, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + return db.Preload("Sources.ProductWarehouse").Preload("Targets") + }) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "TransferLaying not found") + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get transfer laying") + } + + approvalRepo := commonRepo.NewApprovalRepository(s.Repository.DB()) + latestApproval, err := approvalRepo.LatestByTarget(c.Context(), string(utils.ApprovalWorkflowTransferToLaying), id, nil) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check approval status") + } + + if latestApproval != nil && latestApproval.Action != nil { + action := string(*latestApproval.Action) + if action == string(entity.ApprovalActionApproved) || action == string(entity.ApprovalActionRejected) { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot update transfer laying with status %s", action)) + } + } + + if _, err := s.ProjectFlockRepo.GetByID(c.Context(), req.SourceProjectFlockId, nil); err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Source project flock not found") + } + + if _, err := s.ProjectFlockRepo.GetByID(c.Context(), req.TargetProjectFlockId, nil); err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Target project flock not found") + } + + transferDate, err := time.Parse("2006-01-02", req.TransferDate) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transfer date format") + } + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + projectFlockPopulationRepoTx := s.ProjectFlockPopulationRepo.WithTx(dbTransaction) + productWarehouseRepoTx := s.ProductWarehouseRepo.WithTx(dbTransaction) + + for _, oldSource := range existingTransfer.Sources { + if oldSource.ProductWarehouseId != nil && oldSource.Qty > 0 { + + if err := productWarehouseRepoTx.PatchOne(c.Context(), *oldSource.ProductWarehouseId, map[string]any{ + "quantity": gorm.Expr("quantity + ?", oldSource.Qty), + }, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to restore warehouse quantity") + } + + if err := s.restoreProjectFlockPopulation(c.Context(), projectFlockPopulationRepoTx, oldSource.SourceProjectFlockKandangId, oldSource.Qty); err != nil { + return err + } + } + } + + for _, oldSource := range existingTransfer.Sources { + if err := dbTransaction.Delete(&oldSource).Error; err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete old source") + } + } + + for _, oldTarget := range existingTransfer.Targets { + if err := dbTransaction.Delete(&oldTarget).Error; err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete old target") + } + } + + totalSourceQty := 0.0 + for _, source := range req.SourceKandangs { + totalSourceQty += source.Quantity + } + + if err := s.Repository.WithTx(dbTransaction).PatchOne(c.Context(), id, map[string]any{ + "transfer_date": transferDate, + "notes": req.Reason, + "pending_usage_qty": &totalSourceQty, + }, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update transfer header") + } + + sourceWarehouseMap := make(map[uint]uint) + for _, sourceDetail := range req.SourceKandangs { + + populations, err := projectFlockPopulationRepoTx.GetByProjectFlockKandangID(c.Context(), sourceDetail.ProjectFlockKandangId) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get populations") + } + + if len(populations) == 0 { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Source kandang %d has no population available", sourceDetail.ProjectFlockKandangId)) + } + + var totalPopulation float64 + var productWarehouseId uint + + for _, pop := range populations { + totalPopulation += pop.TotalQty + if pop.ProductWarehouseId > 0 { + productWarehouseId = pop.ProductWarehouseId + } + } + + if totalPopulation < sourceDetail.Quantity { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Source kandang %d has insufficient quantity. Available: %.0f, Requested: %.0f", sourceDetail.ProjectFlockKandangId, totalPopulation, sourceDetail.Quantity)) + } + + sourceWarehouseMap[sourceDetail.ProjectFlockKandangId] = productWarehouseId + + source := entity.LayingTransferSource{ + LayingTransferId: id, + SourceProjectFlockKandangId: sourceDetail.ProjectFlockKandangId, + Qty: sourceDetail.Quantity, + ProductWarehouseId: &productWarehouseId, + } + if err := dbTransaction.Create(&source).Error; err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create transfer source") + } + + if err := s.reduceProjectFlockPopulation(c.Context(), projectFlockPopulationRepoTx, sourceDetail.ProjectFlockKandangId, sourceDetail.Quantity); err != nil { + return err + } + + if err := productWarehouseRepoTx.PatchOne(c.Context(), productWarehouseId, map[string]any{"quantity": gorm.Expr("quantity - ?", sourceDetail.Quantity)}, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update source warehouse quantity") + } + } + + for _, targetDetail := range req.TargetKandangs { + targetPFK, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), targetDetail.ProjectFlockKandangId) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get target project flock kandang") + } + + targetWarehouse, err := s.WarehouseRepo.GetLatestByKandangID(c.Context(), targetPFK.KandangId) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("No warehouse found for target kandang %d", targetDetail.ProjectFlockKandangId)) + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get target warehouse") + } + + target := entity.LayingTransferTarget{ + LayingTransferId: id, + TargetProjectFlockKandangId: targetDetail.ProjectFlockKandangId, + Qty: targetDetail.Quantity, + ProductWarehouseId: &targetWarehouse.Id, + } + if err := dbTransaction.Create(&target).Error; err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create transfer target") + } + } + + return nil + }) + + if err != nil { + return nil, err + } + + return s.GetOne(c, id) +} + +func (s transferLayingService) DeleteOne(c *fiber.Ctx, id uint) error { + + _, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + return db.Preload("Sources.ProductWarehouse").Preload("Targets") + }) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "TransferLaying not found") + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get transfer laying") + } + + approvalRepo := commonRepo.NewApprovalRepository(s.Repository.DB()) + latestApproval, err := approvalRepo.LatestByTarget(c.Context(), string(utils.ApprovalWorkflowTransferToLaying), id, nil) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to check approval status") + } + + if latestApproval != nil && latestApproval.Action != nil { + action := string(*latestApproval.Action) + if action == string(entity.ApprovalActionApproved) || action == string(entity.ApprovalActionRejected) { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot delete transfer laying with status %s", action)) + } + } + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + + productWarehouseRepoTx := s.ProductWarehouseRepo.WithTx(dbTransaction) + + sourceRepoTx := repository.NewLayingTransferSourceRepository(dbTransaction) + sources, err := sourceRepoTx.GetByLayingTransferId(c.Context(), id) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get transfer sources") + } + + for _, source := range sources { + if source.ProductWarehouseId != nil && source.Qty > 0 { + + if err := productWarehouseRepoTx.PatchOne(c.Context(), *source.ProductWarehouseId, map[string]any{ + "quantity": gorm.Expr("quantity + ?", source.Qty), + }, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to restore source warehouse quantity") + } + } + } + + projectFlockPopulationRepoTx := s.ProjectFlockPopulationRepo.WithTx(dbTransaction) + for _, source := range sources { + populations, err := projectFlockPopulationRepoTx.GetByProjectFlockKandangID(c.Context(), source.SourceProjectFlockKandangId) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get populations for restoration") + } + + remainingToRestore := source.Qty + for i := len(populations) - 1; i >= 0 && remainingToRestore > 0; i-- { + pop := populations[i] + restoreAmount := remainingToRestore + if pop.TotalQty < remainingToRestore { + restoreAmount = pop.TotalQty + } + + newQty := pop.TotalQty + restoreAmount + if err := projectFlockPopulationRepoTx.PatchOne(c.Context(), pop.Id, map[string]any{"total_qty": newQty}, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to restore population quantity") + } + + remainingToRestore -= restoreAmount + } + } + + if err := s.Repository.WithTx(dbTransaction).DeleteOne(c.Context(), id); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete transfer laying") + } + + return nil + }) + + if err != nil { + if fiberErr, ok := err.(*fiber.Error); ok { + return fiberErr + } + s.Log.Errorf("Failed to delete transferLaying: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete transfer laying") + } + + return nil +} + +func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entity.LayingTransfer, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + actorID := uint(1) // TODO: change from auth context + var action entity.ApprovalAction + switch strings.ToUpper(strings.TrimSpace(req.Action)) { + case string(entity.ApprovalActionRejected): + action = entity.ApprovalActionRejected + case string(entity.ApprovalActionApproved): + action = entity.ApprovalActionApproved + default: + return nil, fiber.NewError(fiber.StatusBadRequest, "action must be APPROVED or REJECTED") + } + + approvableIDs := utils.UniqueUintSlice(req.ApprovableIds) + if len(approvableIDs) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "approvable_ids must contain at least one id") + } + + step := utils.TransferToLayingStepPengajuan + if action == entity.ApprovalActionApproved { + step = utils.TransferToLayingStepDisetujui + } + + err := s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + + approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) + sourceRepoTx := repository.NewLayingTransferSourceRepository(dbTransaction) + targetRepoTx := repository.NewLayingTransferTargetRepository(dbTransaction) + productWarehouseRepoTx := s.ProductWarehouseRepo.WithTx(dbTransaction) + + for _, approvableID := range approvableIDs { + transfer, err := s.Repository.GetByID(c.Context(), approvableID, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("TransferLaying %d not found", approvableID)) + } + return err + } + + if _, err := approvalSvcTx.CreateApproval( + c.Context(), + utils.ApprovalWorkflowTransferToLaying, + approvableID, + step, + &action, + actorID, + req.Notes, + ); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to record approval") + } + + if action == entity.ApprovalActionApproved && transfer.PendingUsageQty != nil && *transfer.PendingUsageQty > 0 { + + sources, err := sourceRepoTx.GetByLayingTransferId(c.Context(), approvableID) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get transfer sources") + } + + targets, err := targetRepoTx.GetByLayingTransferId(c.Context(), approvableID) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get transfer targets") + } + + if len(sources) > 0 && len(targets) > 0 { + firstSource := sources[0] + if firstSource.ProductWarehouseId == nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Source product warehouse not found for transfer %d", approvableID)) + } + + sourceWarehouse, err := productWarehouseRepoTx.GetByID(c.Context(), *firstSource.ProductWarehouseId, nil) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get source warehouse") + } + + for _, target := range targets { + + targetPFK, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), target.TargetProjectFlockKandangId) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + continue + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get target project flock kandang") + } + + targetWarehouse, err := s.WarehouseRepo.GetLatestByKandangID(c.Context(), targetPFK.KandangId) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + continue + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get target warehouse") + } + + if _, err := s.getOrCreateProductWarehouse( + c.Context(), + dbTransaction, + sourceWarehouse.ProductId, + targetWarehouse.Id, + target.Qty, + actorID, + ); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create or update product warehouse") + } + } + } + + usageQty := *transfer.PendingUsageQty + updateData := map[string]any{ + "usage_qty": usageQty, + "pending_usage_qty": nil, + } + if err := s.Repository.WithTx(dbTransaction).PatchOne(c.Context(), approvableID, updateData, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update transfer laying status") + } + } + } + + return nil + }) + + if err != nil { + if fiberErr, ok := err.(*fiber.Error); ok { + return nil, fiberErr + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to record approval") + } + + updated := make([]entity.LayingTransfer, 0, len(approvableIDs)) + for _, approvableID := range approvableIDs { + transfer, err := s.GetOne(c, approvableID) + if err != nil { + return nil, err + } + updated = append(updated, *transfer) + } + + return updated, nil +} + +func createApprovalTransferLaying(ctx context.Context, tx *gorm.DB, transferLayingID uint, actorID uint) error { + if transferLayingID == 0 || actorID == 0 { + return nil + } + + approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) + action := entity.ApprovalActionCreated + + _, err := approvalSvc.CreateApproval( + ctx, + utils.ApprovalWorkflowTransferToLaying, + transferLayingID, + utils.TransferToLayingStepPengajuan, + &action, + actorID, + nil, + ) + return err +} + +func (s *transferLayingService) getOrCreateProductWarehouse(ctx context.Context, tx *gorm.DB, productID uint, warehouseID uint, quantity float64, actorID uint) (*entity.ProductWarehouse, error) { + + productWarehouseRepoTx := rInventory.NewProductWarehouseRepository(tx) + + existing, err := productWarehouseRepoTx.GetProductWarehouseByProductAndWarehouseID(ctx, productID, warehouseID) + if err == nil && existing != nil { + + if err := productWarehouseRepoTx.PatchOne(ctx, existing.Id, map[string]any{"quantity": gorm.Expr("quantity + ?", quantity)}, nil); err != nil { + return nil, err + } + return existing, nil + } + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + + newWarehouse := &entity.ProductWarehouse{ + ProductId: productID, + WarehouseId: warehouseID, + Quantity: quantity, + CreatedBy: actorID, + } + + if err := productWarehouseRepoTx.CreateOne(ctx, newWarehouse, nil); err != nil { + return nil, err + } + + return newWarehouse, nil +} + +func (s *transferLayingService) reduceProjectFlockPopulation(ctx context.Context, populationRepo ProjectFlockRepository.ProjectFlockPopulationRepository, projectFlockKandangID uint, quantityToReduce float64) error { + + populations, err := populationRepo.GetByProjectFlockKandangID(ctx, projectFlockKandangID) + if err != nil { + return err + } + + if len(populations) == 0 { + return fiber.NewError(fiber.StatusBadRequest, "No populations found for reduction") + } + + remainingToReduce := quantityToReduce + + for i := len(populations) - 1; i >= 0; i-- { + if remainingToReduce <= 0 { + break + } + + pop := populations[i] + reductionAmount := remainingToReduce + if pop.TotalQty < remainingToReduce { + reductionAmount = pop.TotalQty + } + + newQty := pop.TotalQty - reductionAmount + if err := populationRepo.PatchOne(ctx, pop.Id, map[string]any{"total_qty": newQty}, nil); err != nil { + return err + } + + remainingToReduce -= reductionAmount + } + + if remainingToReduce > 0 { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient population to reduce. Still need to reduce: %.0f", remainingToReduce)) + } + + return nil +} + +func (s *transferLayingService) restoreProjectFlockPopulation(ctx context.Context, populationRepo ProjectFlockRepository.ProjectFlockPopulationRepository, projectFlockKandangID uint, quantityToRestore float64) error { + populations, err := populationRepo.GetByProjectFlockKandangID(ctx, projectFlockKandangID) + if err != nil { + return err + } + + if len(populations) == 0 { + return fiber.NewError(fiber.StatusBadRequest, "No populations found for restoration") + } + + if len(populations) > 0 { + lastPop := populations[len(populations)-1] + newQty := lastPop.TotalQty + quantityToRestore + if err := populationRepo.PatchOne(ctx, lastPop.Id, map[string]any{"total_qty": newQty}, nil); err != nil { + return err + } + } + + return nil +} + +func (s transferLayingService) GetAvailableQtyPerKandang(ctx *fiber.Ctx, projectFlockID uint) (*entity.ProjectFlock, map[uint]float64, error) { + + pf, err := s.ProjectFlockRepo.GetByID(ctx.Context(), projectFlockID, func(db *gorm.DB) *gorm.DB { + return db + }) + if err != nil { + s.Log.Errorf("Failed to get project flock %d: %+v", projectFlockID, err) + return nil, nil, fiber.NewError(fiber.StatusNotFound, "Project flock not found") + } + + kandangs, _, err := s.ProjectFlockKandangRepo.GetAll(ctx.Context(), 0, 1000, func(db *gorm.DB) *gorm.DB { + return db.Where("project_flock_id = ?", projectFlockID).Order("kandang_id ASC") + }) + if err != nil { + s.Log.Errorf("Failed to get kandangs for project flock %d: %+v", projectFlockID, err) + return nil, nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch kandangs") + } + + kandangAvailableQty := make(map[uint]float64) + for _, kandang := range kandangs { + + totalQty, err := s.ProjectFlockPopulationRepo.GetTotalQtyByProjectFlockKandangID(ctx.Context(), kandang.Id) + if err != nil { + s.Log.Warnf("Failed to get total qty for kandang %d: %+v", kandang.Id, err) + kandangAvailableQty[kandang.Id] = 0 + continue + } + + kandangAvailableQty[kandang.Id] = totalQty + } + + return pf, kandangAvailableQty, nil +} diff --git a/internal/modules/production/transfer_layings/validations/transfer_laying.validation.go b/internal/modules/production/transfer_layings/validations/transfer_laying.validation.go new file mode 100644 index 00000000..45a73e48 --- /dev/null +++ b/internal/modules/production/transfer_layings/validations/transfer_laying.validation.go @@ -0,0 +1,40 @@ +package validation + +type SourceKandangDetail struct { + ProjectFlockKandangId uint `json:"project_flock_kandang_id" validate:"required"` + Quantity float64 `json:"quantity" validate:"required,gt=0"` +} + +type TargetKandangDetail struct { + ProjectFlockKandangId uint `json:"project_flock_kandang_id" validate:"required"` + Quantity float64 `json:"quantity" validate:"required,gt=0"` +} + +type Create struct { + TransferDate string `json:"transfer_date" validate:"required,datetime=2006-01-02"` + SourceProjectFlockId uint `json:"source_project_flock_id" validate:"required"` + TargetProjectFlockId uint `json:"target_project_flock_id" validate:"required"` + SourceKandangs []SourceKandangDetail `json:"source_kandangs" validate:"required,min=1,dive,required"` + TargetKandangs []TargetKandangDetail `json:"target_kandangs" validate:"required,min=1,dive,required"` + Reason string `json:"reason" validate:"omitempty,max=1000"` +} + +type Update struct { + TransferDate string `json:"transfer_date" validate:"required,datetime=2006-01-02"` + SourceProjectFlockId uint `json:"source_project_flock_id" validate:"required"` + TargetProjectFlockId uint `json:"target_project_flock_id" validate:"required"` + SourceKandangs []SourceKandangDetail `json:"source_kandangs" validate:"required,min=1,dive,required"` + TargetKandangs []TargetKandangDetail `json:"target_kandangs" validate:"required,min=1,dive,required"` + Reason string `json:"reason" validate:"omitempty,max=1000"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` +} + +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/purchases/controllers/purchase.controller.go b/internal/modules/purchases/controllers/purchase.controller.go new file mode 100644 index 00000000..ffef2f5d --- /dev/null +++ b/internal/modules/purchases/controllers/purchase.controller.go @@ -0,0 +1,67 @@ +package controller + +import ( + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type PurchaseController struct { + service service.PurchaseService +} + +func NewPurchaseController(s service.PurchaseService) *PurchaseController { + return &PurchaseController{service: s} +} + +func (ctrl *PurchaseController) CreateOne(c *fiber.Ctx) error { + req := new(validation.CreatePurchaseRequest) + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := ctrl.service.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Purchase requisition created successfully", + Data: dto.ToPurchaseDetailDTO(*result), + }) +} + +func (ctrl *PurchaseController) ApproveStaffPurchase(c *fiber.Ctx) error { + param := c.Params("id") + id, err := strconv.ParseUint(param, 10, 64) + if err != nil || id == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid purchase requisition id") + } + + req := new(validation.ApproveStaffPurchaseRequest) + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := ctrl.service.ApproveStaffPurchase(c, id, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Staff purchase approval recorded successfully", + Data: dto.ToPurchaseDetailDTO(*result), + }) +} diff --git a/internal/modules/purchases/dto/purchase.dto.go b/internal/modules/purchases/dto/purchase.dto.go new file mode 100644 index 00000000..381115a6 --- /dev/null +++ b/internal/modules/purchases/dto/purchase.dto.go @@ -0,0 +1,155 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" +) + +type SupplierBaseDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + Alias string `json:"alias"` + Type string `json:"type"` + Category string `json:"category"` +} + +type AreaBaseDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type LocationBaseDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type WarehouseBaseDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + Area *AreaBaseDTO `json:"area,omitempty"` + Location *LocationBaseDTO `json:"location,omitempty"` +} + +type ProductBaseDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + SKU *string `json:"sku,omitempty"` +} + +type PurchaseItemDTO struct { + Id uint64 `json:"id"` + Product *ProductBaseDTO `json:"product,omitempty"` + Warehouse *WarehouseBaseDTO `json:"warehouse,omitempty"` + ProductWarehouseID *uint64 `json:"product_warehouse_id,omitempty"` + SubQty float64 `json:"sub_qty"` + TotalQty float64 `json:"total_qty"` + TotalUsed float64 `json:"total_used"` + Price float64 `json:"price"` + TotalPrice float64 `json:"total_price"` +} + +type PurchaseDetailDTO struct { + Id uint64 `json:"id"` + PrNumber string `json:"pr_number"` + Supplier *SupplierBaseDTO `json:"supplier,omitempty"` + CreditTerm *int `json:"credit_term,omitempty"` + DueDate *time.Time `json:"due_date,omitempty"` + GrandTotal float64 `json:"grand_total"` + Notes *string `json:"notes,omitempty"` + Items []PurchaseItemDTO `json:"items"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func toSupplierBaseDTO(s entity.Supplier) *SupplierBaseDTO { + if s.Id == 0 { + return nil + } + return &SupplierBaseDTO{ + Id: s.Id, + Name: s.Name, + Alias: s.Alias, + Type: s.Type, + Category: s.Category, + } +} + +func toWarehouseBaseDTO(w *entity.Warehouse) *WarehouseBaseDTO { + if w == nil || w.Id == 0 { + return nil + } + dto := &WarehouseBaseDTO{ + Id: w.Id, + Name: w.Name, + } + if w.Area.Id != 0 { + dto.Area = &AreaBaseDTO{ + Id: w.Area.Id, + Name: w.Area.Name, + } + } + if w.Location != nil && w.Location.Id != 0 { + dto.Location = &LocationBaseDTO{ + Id: w.Location.Id, + Name: w.Location.Name, + } + } + return dto +} + +func toProductBaseDTO(p *entity.Product) *ProductBaseDTO { + if p == nil || p.Id == 0 { + return nil + } + dto := &ProductBaseDTO{ + Id: p.Id, + Name: p.Name, + } + if p.Sku != nil { + dto.SKU = p.Sku + } + return dto +} + +func ToPurchaseItemDTO(item entity.PurchaseItem) PurchaseItemDTO { + dto := PurchaseItemDTO{ + Id: item.Id, + ProductWarehouseID: item.ProductWarehouseId, + SubQty: item.SubQty, + TotalQty: item.TotalQty, + TotalUsed: item.TotalUsed, + Price: item.Price, + TotalPrice: item.TotalPrice, + } + if item.Product != nil { + dto.Product = toProductBaseDTO(item.Product) + } + if item.Warehouse != nil { + dto.Warehouse = toWarehouseBaseDTO(item.Warehouse) + } + return dto +} + +func ToPurchaseItemDTOs(items []entity.PurchaseItem) []PurchaseItemDTO { + result := make([]PurchaseItemDTO, len(items)) + for i, item := range items { + result[i] = ToPurchaseItemDTO(item) + } + return result +} + +func ToPurchaseDetailDTO(p entity.Purchase) PurchaseDetailDTO { + return PurchaseDetailDTO{ + Id: p.Id, + PrNumber: p.PrNumber, + Supplier: toSupplierBaseDTO(p.Supplier), + CreditTerm: p.CreditTerm, + DueDate: p.DueDate, + GrandTotal: p.GrandTotal, + Notes: p.Notes, + Items: ToPurchaseItemDTOs(p.Items), + CreatedAt: p.CreatedAt, + UpdatedAt: p.UpdatedAt, + } +} diff --git a/internal/modules/purchases/module.go b/internal/modules/purchases/module.go new file mode 100644 index 00000000..1397f27e --- /dev/null +++ b/internal/modules/purchases/module.go @@ -0,0 +1,13 @@ +package purchases + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" +) + +type PurchaseModule struct{} + +func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + RegisterRoutes(router, db, validate) +} diff --git a/internal/modules/purchases/repositories/purchase.repository.go b/internal/modules/purchases/repositories/purchase.repository.go new file mode 100644 index 00000000..398fcea1 --- /dev/null +++ b/internal/modules/purchases/repositories/purchase.repository.go @@ -0,0 +1,113 @@ +package repositories + +import ( + "context" + "errors" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type PurchaseRepository interface { + repository.BaseRepository[entity.Purchase] + CreateWithItems(ctx context.Context, purchase *entity.Purchase, items []*entity.PurchaseItem) error + GetByIDWithRelations(ctx context.Context, id uint64) (*entity.Purchase, error) + UpdatePricing(ctx context.Context, purchaseID uint64, updates []PurchasePricingUpdate, grandTotal float64) error +} + +type PurchaseRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Purchase] +} + +func NewPurchaseRepository(db *gorm.DB) PurchaseRepository { + return &PurchaseRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Purchase](db), + } +} + +func (r *PurchaseRepositoryImpl) CreateWithItems(ctx context.Context, purchase *entity.Purchase, items []*entity.PurchaseItem) error { + db := r.DB().WithContext(ctx) + + if err := db.Create(purchase).Error; err != nil { + return err + } + + if len(items) == 0 { + return nil + } + + for _, item := range items { + item.PurchaseId = purchase.Id + } + + if err := db.Create(&items).Error; err != nil { + return err + } + return nil +} + +func (r *PurchaseRepositoryImpl) GetByIDWithRelations(ctx context.Context, id uint64) (*entity.Purchase, error) { + var purchase entity.Purchase + err := r.DB().WithContext(ctx). + Preload("Supplier"). + Preload("Items", func(db *gorm.DB) *gorm.DB { + return db.Order("id ASC") + }). + Preload("Items.Product"). + Preload("Items.Warehouse"). + Preload("Items.Warehouse.Area"). + Preload("Items.Warehouse.Location"). + Preload("Items.ProductWarehouse"). + First(&purchase, id).Error + if err != nil { + return nil, err + } + return &purchase, nil +} + +type PurchasePricingUpdate struct { + ItemID uint64 + Price float64 + TotalPrice float64 +} + +func (r *PurchaseRepositoryImpl) UpdatePricing( + ctx context.Context, + purchaseID uint64, + updates []PurchasePricingUpdate, + grandTotal float64, +) error { + if len(updates) == 0 { + return errors.New("pricing updates cannot be empty") + } + + db := r.DB().WithContext(ctx) + + for _, upd := range updates { + result := db.Model(&entity.PurchaseItem{}). + Where("purchase_id = ? AND id = ?", purchaseID, upd.ItemID). + Updates(map[string]interface{}{ + "price": upd.Price, + "total_price": upd.TotalPrice, + "updated_at": gorm.Expr("NOW()"), + }) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + } + + if err := db.Model(&entity.Purchase{}). + Where("id = ?", purchaseID). + Updates(map[string]interface{}{ + "grand_total": grandTotal, + "updated_at": gorm.Expr("NOW()"), + }).Error; err != nil { + return err + } + + return nil +} diff --git a/internal/modules/purchases/route.go b/internal/modules/purchases/route.go new file mode 100644 index 00000000..df3ea1a1 --- /dev/null +++ b/internal/modules/purchases/route.go @@ -0,0 +1,59 @@ +package purchases + +import ( + "fmt" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + 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" + rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" + rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/controllers" + rPurchase "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/services" + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + utils "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gorm.io/gorm" +) + +func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + group := router.Group("/purchases") + + purchaseRepo := rPurchase.NewPurchaseRepository(db) + productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + warehouseRepo := rWarehouse.NewWarehouseRepository(db) + supplierRepo := rSupplier.NewSupplierRepository(db) + userRepo := rUser.NewUserRepository(db) + + approvalRepo := commonRepo.NewApprovalRepository(db) + approvalService := commonSvc.NewApprovalService(approvalRepo) + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowPurchase, utils.PurchaseApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register purchase approval workflow: %v", err)) + } + + purchaseService := service.NewPurchaseService( + validate, + purchaseRepo, + productWarehouseRepo, + warehouseRepo, + supplierRepo, + approvalRepo, + ) + userService := sUser.NewUserService(userRepo, validate) + + PurchaseRoutes(group, userService, purchaseService) +} + +func PurchaseRoutes(v1 fiber.Router, u sUser.UserService, s service.PurchaseService) { + ctrl := controller.NewPurchaseController(s) + + route := v1.Group("/requisitions") + + // route.Post("/", m.Auth(u), ctrl.CreateOne) + + route.Post("/", ctrl.CreateOne) + route.Post("/:id/approvals/staff", ctrl.ApproveStaffPurchase) +} diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go new file mode 100644 index 00000000..a7419fc5 --- /dev/null +++ b/internal/modules/purchases/services/purchase.service.go @@ -0,0 +1,337 @@ +package service + +import ( + "errors" + "fmt" + "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" + rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" + rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + rPurchase "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type PurchaseService interface { + CreateOne(ctx *fiber.Ctx, req *validation.CreatePurchaseRequest) (*entity.Purchase, error) + ApproveStaffPurchase(ctx *fiber.Ctx, id uint64, req *validation.ApproveStaffPurchaseRequest) (*entity.Purchase, error) +} + +type purchaseService struct { + Log *logrus.Logger + Validate *validator.Validate + PurchaseRepo rPurchase.PurchaseRepository + ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository + WarehouseRepo rWarehouse.WarehouseRepository + SupplierRepo rSupplier.SupplierRepository + ApprovalRepo commonRepo.ApprovalRepository +} + +func NewPurchaseService( + validate *validator.Validate, + purchaseRepo rPurchase.PurchaseRepository, + productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, + warehouseRepo rWarehouse.WarehouseRepository, + supplierRepo rSupplier.SupplierRepository, + approvalRepo commonRepo.ApprovalRepository, +) PurchaseService { + return &purchaseService{ + Log: utils.Log, + Validate: validate, + PurchaseRepo: purchaseRepo, + ProductWarehouseRepo: productWarehouseRepo, + WarehouseRepo: warehouseRepo, + SupplierRepo: supplierRepo, + ApprovalRepo: approvalRepo, + } +} + +func uint64Ptr(v uint64) *uint64 { + return &v +} + +func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchaseRequest) (*entity.Purchase, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + supplier, err := s.SupplierRepo.GetByID(c.Context(), req.SupplierID, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Supplier not found") + } + s.Log.Errorf("Failed to get supplier: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get supplier") + } + + warehouse, err := s.WarehouseRepo.GetDetailByID(c.Context(), req.WarehouseID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Warehouse not found") + } + s.Log.Errorf("Failed to get warehouse: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get warehouse") + } + + if warehouse.AreaId != req.AreaID { + return nil, fiber.NewError(fiber.StatusBadRequest, "Warehouse does not belong to the provided area") + } + if warehouse.LocationId == nil || *warehouse.LocationId != req.LocationID { + return nil, fiber.NewError(fiber.StatusBadRequest, "Warehouse does not belong to the provided location") + } + + type aggregatedItem struct { + productId uint64 + warehouseId uint64 + productWarehouseId *uint64 + subQty float64 + } + + if len(req.Items) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Items must not be empty") + } + + aggregated := make([]*aggregatedItem, 0, len(req.Items)) + indexMap := make(map[string]int) + + for _, item := range req.Items { + var ( + productId = uint64(item.ProductID) + warehouseId = uint64(req.WarehouseID) + productWarehouseId *uint64 + ) + + if item.ProductWarehouseID != nil { + productWarehouse, err := s.ProductWarehouseRepo.GetDetailByID(c.Context(), *item.ProductWarehouseID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Product warehouse %d not found", *item.ProductWarehouseID)) + } + s.Log.Errorf("Failed to get product warehouse %d: %+v", *item.ProductWarehouseID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse") + } + + if productWarehouse.WarehouseId != req.WarehouseID { + return nil, fiber.NewError(fiber.StatusBadRequest, "Product warehouse does not match selected warehouse") + } + + productId = uint64(productWarehouse.ProductId) + warehouseId = uint64(productWarehouse.WarehouseId) + idCopy := uint64(productWarehouse.Id) + productWarehouseId = &idCopy + } else { + productWarehouse, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(c.Context(), item.ProductID, req.WarehouseID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to get product warehouse for product %d and warehouse %d: %+v", item.ProductID, req.WarehouseID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse") + } + if err == nil { + idCopy := uint64(productWarehouse.Id) + productWarehouseId = &idCopy + } + } + + key := fmt.Sprintf("%d:%d", productId, warehouseId) + if idx, ok := indexMap[key]; ok { + aggregated[idx].subQty += item.Quantity + continue + } + + entry := &aggregatedItem{ + productId: productId, + warehouseId: warehouseId, + productWarehouseId: productWarehouseId, + subQty: item.Quantity, + } + aggregated = append(aggregated, entry) + indexMap[key] = len(aggregated) - 1 + } + + prNumber := fmt.Sprintf("PR-%s-%s", time.Now().Format("20060102"), uuid.NewString()[:8]) + + var creditTerm *int + var dueDate *time.Time + + if supplier.DueDate > 0 { + ct := supplier.DueDate + creditTerm = &ct + d := time.Now().UTC().AddDate(0, 0, ct) + dueDate = &d + } + + purchase := &entity.Purchase{ + PrNumber: prNumber, + SupplierId: uint64(req.SupplierID), + CreditTerm: creditTerm, + DueDate: dueDate, + GrandTotal: 0, + Notes: req.Notes, + CreatedBy: 1, // TODO: replace with authenticated user id once available + } + + items := make([]*entity.PurchaseItem, 0, len(aggregated)) + for _, item := range aggregated { + items = append(items, &entity.PurchaseItem{ + ProductId: item.productId, + WarehouseId: item.warehouseId, + ProductWarehouseId: item.productWarehouseId, + SubQty: item.subQty, + TotalQty: item.subQty, + TotalUsed: 0, + Price: 0, + TotalPrice: 0, + }) + } + + ctx := c.Context() + transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + purchaseRepoTx := rPurchase.NewPurchaseRepository(tx) + if err := purchaseRepoTx.CreateWithItems(ctx, purchase, items); err != nil { + return err + } + + actorID := uint(purchase.CreatedBy) + if actorID == 0 { + actorID = 1 + } + action := entity.ApprovalActionCreated + + approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) + if _, err := approvalSvc.CreateApproval( + ctx, + utils.ApprovalWorkflowPurchase, + uint(purchase.Id), + utils.PurchaseStepPengajuan, + &action, + actorID, + nil, + ); err != nil { + return err + } + + return nil + }) + if transactionErr != nil { + s.Log.Errorf("Failed to create purchase requisition: %+v", transactionErr) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create purchase requisition") + } + + created, err := s.PurchaseRepo.GetByIDWithRelations(ctx, purchase.Id) + if err != nil { + s.Log.Errorf("Failed to load created purchase requisition: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase requisition") + } + + return created, nil +} + +func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint64, req *validation.ApproveStaffPurchaseRequest) (*entity.Purchase, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + ctx := c.Context() + + purchase, err := s.PurchaseRepo.GetByIDWithRelations(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Purchase requisition not found") + } + s.Log.Errorf("Failed to get purchase requisition: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase requisition") + } + + if len(purchase.Items) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase requisition has no items to approve") + } + + requestItems := make(map[uint64]validation.StaffPurchaseApprovalItem, len(req.Items)) + for _, item := range req.Items { + requestItems[item.PurchaseItemID] = item + } + + updates := make([]rPurchase.PurchasePricingUpdate, 0, len(purchase.Items)) + var grandTotal float64 + + for _, item := range purchase.Items { + data, ok := requestItems[item.Id] + if !ok { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Missing pricing data for item %d", item.Id)) + } + delete(requestItems, item.Id) + + if data.Price <= 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Price for item %d must be greater than 0", item.Id)) + } + + totalPrice := data.TotalPrice + if totalPrice == nil { + calculated := data.Price * item.TotalQty + totalPrice = &calculated + } + if *totalPrice <= 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Total price for item %d must be greater than 0", item.Id)) + } + + updates = append(updates, rPurchase.PurchasePricingUpdate{ + ItemID: item.Id, + Price: data.Price, + TotalPrice: *totalPrice, + }) + grandTotal += *totalPrice + } + + if len(requestItems) > 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Found pricing data for items that do not belong to this purchase requisition") + } + + action := entity.ApprovalActionApproved + actorID := uint(1) // TODO: replace with authenticated user id once available + + transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + purchaseRepoTx := rPurchase.NewPurchaseRepository(tx) + if err := purchaseRepoTx.UpdatePricing(ctx, purchase.Id, updates, grandTotal); err != nil { + return err + } + + approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) + if _, err := approvalSvc.CreateApproval( + ctx, + utils.ApprovalWorkflowPurchase, + uint(purchase.Id), + utils.PurchaseStepStaffPurchase, + &action, + actorID, + req.Notes, + ); err != nil { + return err + } + + return nil + }) + if transactionErr != nil { + if errors.Is(transactionErr, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Purchase item not found") + } + s.Log.Errorf("Failed to approve purchase requisition %d: %+v", purchase.Id, transactionErr) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to approve purchase requisition") + } + + updated, err := s.PurchaseRepo.GetByIDWithRelations(ctx, purchase.Id) + if err != nil { + s.Log.Errorf("Failed to load purchase requisition after approval: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase requisition") + } + + return updated, nil +} diff --git a/internal/modules/purchases/validations/purchase.validation.go b/internal/modules/purchases/validations/purchase.validation.go new file mode 100644 index 00000000..8791fc1c --- /dev/null +++ b/internal/modules/purchases/validations/purchase.validation.go @@ -0,0 +1,27 @@ +package validation + +type PurchaseItemPayload struct { + ProductID uint `json:"product_id" validate:"required"` + ProductWarehouseID *uint `json:"product_warehouse_id,omitempty" validate:"omitempty,gt=0"` + Quantity float64 `json:"quantity" validate:"required,gt=0"` +} + +type CreatePurchaseRequest struct { + SupplierID uint `json:"supplier_id" validate:"required"` + AreaID uint `json:"area_id" validate:"required"` + LocationID uint `json:"location_id" validate:"required"` + WarehouseID uint `json:"warehouse_id" validate:"required"` + Notes *string `json:"notes" validate:"omitempty,max=500"` + Items []PurchaseItemPayload `json:"items" validate:"required,min=1,dive"` +} + +type StaffPurchaseApprovalItem struct { + PurchaseItemID uint64 `json:"purchase_item_id" validate:"required,gt=0"` + Price float64 `json:"price" validate:"required,gt=0"` + TotalPrice *float64 `json:"total_price,omitempty" validate:"omitempty,gt=0"` +} + +type ApproveStaffPurchaseRequest struct { + Items []StaffPurchaseApprovalItem `json:"items" validate:"required,min=1,dive"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` +} diff --git a/internal/modules/users/repositories/user.repository.go b/internal/modules/users/repositories/user.repository.go index 04f67cbf..f9bee9ed 100644 --- a/internal/modules/users/repositories/user.repository.go +++ b/internal/modules/users/repositories/user.repository.go @@ -14,6 +14,7 @@ import ( type UserRepository interface { commonrepo.BaseRepository[entity.User] + IdExists(ctx context.Context, id uint) (bool, error) GetByIdUser(ctx context.Context, idUser int64, modifier func(*gorm.DB) *gorm.DB) (*entity.User, error) UpsertByIdUser(ctx context.Context, user *entity.User) error SoftDeleteByIdUser(ctx context.Context, idUser int64) error @@ -21,14 +22,20 @@ type UserRepository interface { type UserRepositoryImpl struct { *commonrepo.BaseRepositoryImpl[entity.User] + db *gorm.DB } func NewUserRepository(db *gorm.DB) UserRepository { return &UserRepositoryImpl{ BaseRepositoryImpl: commonrepo.NewBaseRepository[entity.User](db), + db: db, } } +func (r *UserRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { + return commonrepo.Exists[entity.User](ctx, r.db, id) +} + func (r *UserRepositoryImpl) GetByIdUser( ctx context.Context, idUser int64, diff --git a/internal/route/route.go b/internal/route/route.go index 4c0c80ec..7b7059b5 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -11,10 +11,13 @@ import ( approvals "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals" constants "gitlab.com/mbugroup/lti-api.git/internal/modules/constants" inventory "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory" + marketing "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing" master "gitlab.com/mbugroup/lti-api.git/internal/modules/master" production "gitlab.com/mbugroup/lti-api.git/internal/modules/production" + purchases "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases" ssoModule "gitlab.com/mbugroup/lti-api.git/internal/modules/sso" users "gitlab.com/mbugroup/lti-api.git/internal/modules/users" + expenses "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses" // MODULE IMPORTS ) @@ -31,7 +34,10 @@ func Routes(app *fiber.App, db *gorm.DB) { inventory.InventoryModule{}, production.ProductionModule{}, approvals.ApprovalModule{}, + purchases.PurchaseModule{}, + marketing.MarketingModule{}, ssoModule.Module{}, + expenses.ExpenseModule{}, // MODULE REGISTRY } diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 0a8862f9..fc01a231 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -18,6 +18,8 @@ const ( FlagIsActive FlagType = "IS_ACTIVE" FlagDOC FlagType = "DOC" + FlagPullet FlagType = "PULLET" + FlagLayer FlagType = "LAYER" FlagPakan FlagType = "PAKAN" FlagPreStarter FlagType = "PRE-STARTER" FlagStarter FlagType = "STARTER" @@ -37,6 +39,8 @@ const ( var flagGroupOptions = map[FlagGroup][]FlagType{ FlagGroupProduct: { FlagDOC, + FlagPullet, + FlagLayer, FlagPakan, FlagPreStarter, FlagStarter, @@ -79,6 +83,24 @@ const ( WarehouseTypeKandang WarehouseType = "KANDANG" ) +// ------------------------------------------------------------------- +// Stock log +// ------------------------------------------------------------------- + +type StockLogTransactionType string + +const ( + StockLogTransactionTypeIncrease StockLogTransactionType = "INCREASE" + StockLogTransactionTypeDecrease StockLogTransactionType = "DECREASE" +) + +type StockLogType string + +const ( + StockLogTypeAdjustment StockLogType = "ADJUSTMENT" + StockLogTypeTransfer StockLogType = "TRANSFER" +) + // ------------------------------------------------------------------- // WarehouseType // ------------------------------------------------------------------- @@ -140,6 +162,36 @@ var ProjectFlockApprovalSteps = map[approvalutils.ApprovalStep]string{ ProjectFlockStepAktif: "Aktif", } +// ------------------------------------------------------------------- +// Project Flock Kandang Approval +// ------------------------------------------------------------------- +const ( + ApprovalWorkflowProjectFlockKandang approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("PROJECT_FLOCK_KANDANGS") + ProjectFlockKandangStepPengajuan approvalutils.ApprovalStep = 1 + ProjectFlockKandangStepDisetujui approvalutils.ApprovalStep = 2 +) + +var ProjectFlockKandangApprovalSteps = map[approvalutils.ApprovalStep]string{ + ProjectFlockKandangStepPengajuan: "Pengajuan", + ProjectFlockKandangStepDisetujui: "Disetujui", +} + +// ------------------------------------------------------------------- +// Transfer To laying Approval +// ------------------------------------------------------------------- +const ( + ApprovalWorkflowTransferToLaying approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("TRANSFER_TO_LAYINGS") + TransferToLayingStepPengajuan approvalutils.ApprovalStep = 1 + TransferToLayingStepDisetujui approvalutils.ApprovalStep = 2 +) + +var TransferToLayingApprovalSteps = map[approvalutils.ApprovalStep]string{ + TransferToLayingStepPengajuan: "Pengajuan", + TransferToLayingStepDisetujui: "Disetujui", +} + +// ------------------------------------------------------------------- + // ------------------------------------------------------------------- // Recording Approval // ------------------------------------------------------------------- @@ -153,8 +205,40 @@ const ( var RecordingApprovalSteps = map[approvalutils.ApprovalStep]string{ RecordingStepGradingTelur: "Grading-Telur", - RecordingStepPengajuan: "Pengajuan", - RecordingStepDisetujui: "Disetujui", + RecordingStepPengajuan: "Pengajuan", + RecordingStepDisetujui: "Disetujui", +} + +// ------------------------------------------------------------------- +// Purchase Approval +// ------------------------------------------------------------------- + +const ( + ApprovalWorkflowPurchase approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("PURCHASES") + PurchaseStepPengajuan approvalutils.ApprovalStep = 1 + PurchaseStepStaffPurchase approvalutils.ApprovalStep = 2 +) + +var PurchaseApprovalSteps = map[approvalutils.ApprovalStep]string{ + PurchaseStepPengajuan: "Pengajuan", + PurchaseStepStaffPurchase: "Staff Purchase", +} + +// ------------------------------------------------------------------- +// Marketings Approval +// ------------------------------------------------------------------- + +const ( + ApprovalWorkflowMarketing approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("MARKETINGS") + MarketingStepPengajuan approvalutils.ApprovalStep = 1 + MarketingStepSalesOrder approvalutils.ApprovalStep = 2 + MarketingDeliveryOrder approvalutils.ApprovalStep = 3 +) + +var MarketingApprovalSteps = map[approvalutils.ApprovalStep]string{ + MarketingStepPengajuan: "Pengajuan", + MarketingStepSalesOrder: "Sales Order", + MarketingDeliveryOrder: "Delivery Order", } // ------------------------------------------------------------------- diff --git a/internal/utils/error.go b/internal/utils/error.go index e63e81a2..e409e50c 100644 --- a/internal/utils/error.go +++ b/internal/utils/error.go @@ -10,8 +10,8 @@ import ( ) func ErrorHandler(c *fiber.Ctx, err error) error { - if errorsMap := validation.CustomErrorMessages(err); len(errorsMap) > 0 { - return response.Error(c, fiber.StatusBadRequest, "Bad Request", errorsMap) + if message, errorsMap := validation.CustomErrorMessages(err); len(errorsMap) > 0 { + return response.Error(c, fiber.StatusBadRequest, message, nil) } var fiberErr *fiber.Error diff --git a/internal/utils/recording/util.recording.go b/internal/utils/recording/util.recording.go index fd463cf9..8f0fe81f 100644 --- a/internal/utils/recording/util.recording.go +++ b/internal/utils/recording/util.recording.go @@ -12,17 +12,19 @@ func MapBodyWeights(recordingID uint, items []validation.BodyWeight) []entity.Re result := make([]entity.RecordingBW, 0, len(items)) for _, item := range items { - totalWeight := item.TotalWeight - if totalWeight == nil { - calculated := item.AvgWeight * item.Qty - totalWeight = &calculated + var totalWeight float64 + if item.TotalWeight != nil { + totalWeight = *item.TotalWeight + } + if totalWeight <= 0 { + totalWeight = item.AvgWeight * item.Qty } result = append(result, entity.RecordingBW{ RecordingId: recordingID, AvgWeight: item.AvgWeight, Qty: item.Qty, - TotalWeight: *totalWeight, + TotalWeight: totalWeight, }) } return result @@ -35,12 +37,8 @@ func MapStocks(recordingID uint, items []validation.Stock) []entity.RecordingSto 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 + *usagePtr = item.Qty pending := item.PendingQty if pending == nil { pending = new(float64) diff --git a/tools/templates/dto.tmpl b/tools/templates/dto.tmpl index a03d7018..39b92884 100644 --- a/tools/templates/dto.tmpl +++ b/tools/templates/dto.tmpl @@ -10,15 +10,16 @@ import ( // === DTO Structs === type {{Pascal .Entity}}BaseDTO struct { - Id uint `json:"id"` - Name string `json:"name"` + Id uint `json:"id"` + Name string `json:"name"` } type {{Pascal .Entity}}ListDTO struct { - {{Pascal .Entity}}BaseDTO + Id uint `json:"id"` + Name string `json:"name"` 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 {{Pascal .Entity}}DetailDTO struct { @@ -42,7 +43,8 @@ func To{{Pascal .Entity}}ListDTO(e entity.{{Pascal .Entity}}) {{Pascal .Entity}} } return {{Pascal .Entity}}ListDTO{ - {{Pascal .Entity}}BaseDTO: To{{Pascal .Entity}}BaseDTO(e), + Id: e.Id, + Name: e.Name, CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt, CreatedUser: createdUser,