diff --git a/.DS_Store b/.DS_Store index 4c14efd8..e39247fd 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/internal/common/repository/common.approval.repository..go b/internal/common/repository/common.approval.repository..go index 7f1c27ae..dc517f21 100644 --- a/internal/common/repository/common.approval.repository..go +++ b/internal/common/repository/common.approval.repository..go @@ -13,6 +13,7 @@ type ApprovalRepository interface { FindByTarget(ctx context.Context, workflow string, approvableID uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.Approval, error) LatestByTarget(ctx context.Context, workflow string, approvableID uint, modifier func(*gorm.DB) *gorm.DB) (*entity.Approval, error) LatestByTargets(ctx context.Context, workflow string, approvableIDs []uint, modifier func(*gorm.DB) *gorm.DB) (map[uint]entity.Approval, error) + DeleteByTarget(ctx context.Context, workflow string, approvableID uint) error } type approvalRepositoryImpl struct { @@ -104,3 +105,13 @@ func (r *approvalRepositoryImpl) LatestByTargets( return result, nil } + +func (r *approvalRepositoryImpl) DeleteByTarget( + ctx context.Context, + workflow string, + approvableID uint, +) error { + return r.DB().WithContext(ctx). + Where("approvable_type = ? AND approvable_id = ?", workflow, approvableID). + Delete(&entity.Approval{}).Error +} 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..f1d239c2 --- /dev/null +++ b/internal/database/migrations/20251104084540_purchase-items.up.sql @@ -0,0 +1,54 @@ +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) NOT NULL DEFAULT 0, + total_used NUMERIC(15, 3) NOT NULL DEFAULT 0, + price NUMERIC(15, 3) NOT NULL DEFAULT 0, + total_price NUMERIC(15, 3) NOT NULL DEFAULT 0, + CONSTRAINT uq_purchase_items_purchase_product_warehouse + UNIQUE (purchase_id, product_id, warehouse_id) +); + +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 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); 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..5c0a2197 --- /dev/null +++ b/internal/database/migrations/20251104084555_purchases.up.sql @@ -0,0 +1,58 @@ +CREATE TABLE IF NOT EXISTS purchases ( + id BIGSERIAL PRIMARY KEY, + pr_number VARCHAR NOT NULL, + po_number VARCHAR NULL, + po_date TIMESTAMPTZ NULL, + supplier_id BIGINT NOT NULL, + credit_term INT NOT NULL, + due_date TIMESTAMPTZ, + grand_total NUMERIC(15, 3) NOT NULL, + notes TEXT, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + deleted_at TIMESTAMPTZ, + created_by BIGINT NOT NULL, + CONSTRAINT uq_purchases_pr_number UNIQUE (pr_number), + CONSTRAINT uq_purchases_po_number UNIQUE (po_number) +); + +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 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/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/20251117092514_adjustment_projectflock_period.down.sql b/internal/database/migrations/20251117092514_adjustment_projectflock_period.down.sql new file mode 100644 index 00000000..64183dd7 --- /dev/null +++ b/internal/database/migrations/20251117092514_adjustment_projectflock_period.down.sql @@ -0,0 +1,16 @@ +BEGIN; + +ALTER TABLE project_flock_kandangs + DROP COLUMN IF EXISTS period; + +ALTER TABLE project_flocks + ADD COLUMN IF NOT EXISTS period INT NOT NULL DEFAULT 0; + +CREATE UNIQUE INDEX IF NOT EXISTS project_flocks_base_period_unique + ON project_flocks ( + LOWER(TRIM(regexp_replace(flock_name, '\\s+\\d+(\\s+\\d+)*$', '', 'g'))), + period + ) + WHERE deleted_at IS NULL; + +COMMIT; diff --git a/internal/database/migrations/20251117092514_adjustment_projectflock_period.up.sql b/internal/database/migrations/20251117092514_adjustment_projectflock_period.up.sql new file mode 100644 index 00000000..a518c2d6 --- /dev/null +++ b/internal/database/migrations/20251117092514_adjustment_projectflock_period.up.sql @@ -0,0 +1,29 @@ +BEGIN; + +ALTER TABLE project_flock_kandangs + ADD COLUMN IF NOT EXISTS period INT; + +UPDATE project_flock_kandangs pfk +SET period = pf.period +FROM project_flocks pf +WHERE pfk.project_flock_id = pf.id + AND (pfk.period IS NULL OR pfk.period = 0) + AND pf.period IS NOT NULL; + +ALTER TABLE project_flock_kandangs + ALTER COLUMN period SET DEFAULT 0; + +UPDATE project_flock_kandangs +SET period = 0 +WHERE period IS NULL; + +ALTER TABLE project_flock_kandangs + ALTER COLUMN period SET NOT NULL; + +-- Drop period from project_flocks as the source of truth +DROP INDEX IF EXISTS project_flocks_base_period_unique; + +ALTER TABLE project_flocks + DROP COLUMN IF EXISTS period; + +COMMIT; diff --git a/internal/database/seed/seeder.go b/internal/database/seed/seeder.go index 24425917..24cbaa93 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,46 @@ 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 Culling", + Brand: "-", + Sku: "3", + Uom: "Ekor", + Category: "Day Old Chick", + Price: 1, }, { - Name: "Telur Konsumsi Baik", - Brand: "-", - Sku: "4", - Uom: "Unit", - Category: "Telur", - Price: 1, - + Name: "Telur Konsumsi Baik", + Brand: "-", + Sku: "4", + Uom: "Unit", + Category: "Telur", + Price: 1, }, { - Name: "Telur Pecah", - Brand: "-", - Sku: "5", - 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/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/location.go b/internal/entities/location.go index 9f87e97b..1dba8f82 100644 --- a/internal/entities/location.go +++ b/internal/entities/location.go @@ -16,6 +16,7 @@ type Location struct { UpdatedAt time.Time `gorm:"autoUpdateTime"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` - Area Area `gorm:"foreignKey:AreaId;references:Id"` + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + Area Area `gorm:"foreignKey:AreaId;references:Id"` + Kandangs []Kandang `gorm:"foreignKey:LocationId;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..e8745455 100644 --- a/internal/entities/projectflock.go +++ b/internal/entities/projectflock.go @@ -13,7 +13,6 @@ type ProjectFlock struct { Category string `gorm:"type:varchar(20);not null"` FcrId uint `gorm:"not null"` LocationId uint `gorm:"not null"` - Period int `gorm:"not null"` CreatedBy uint `gorm:"not null"` CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` diff --git a/internal/entities/projectflock_kandang.go b/internal/entities/projectflock_kandang.go index 26238980..d4bd7452 100644 --- a/internal/entities/projectflock_kandang.go +++ b/internal/entities/projectflock_kandang.go @@ -3,13 +3,14 @@ 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"` + Period int `gorm:"not null"` + 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..36b698b2 --- /dev/null +++ b/internal/entities/purchase.go @@ -0,0 +1,27 @@ +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"` + LatestApproval *Approval `gorm:"-" json:"-"` +} diff --git a/internal/entities/purchase_item.go b/internal/entities/purchase_item.go new file mode 100644 index 00000000..59f1a030 --- /dev/null +++ b/internal/entities/purchase_item.go @@ -0,0 +1,28 @@ +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"` + + // 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/inventory/product-warehouses/repositories/product_warehouse.repository.go b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go index 23cabb68..1eddd3b7 100644 --- a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go +++ b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go @@ -2,6 +2,7 @@ package repository import ( "context" + "errors" "fmt" "gitlab.com/mbugroup/lti-api.git/internal/common/repository" @@ -19,8 +20,13 @@ 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) + CleanupEmpty(ctx context.Context, affected map[uint]struct{}) error + EnsureProductWarehouse(ctx context.Context, productID, warehouseID uint, createdBy uint64) (uint, error) } type ProductWarehouseRepositoryImpl struct { @@ -78,14 +84,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 +106,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 +152,123 @@ func (r *ProductWarehouseRepositoryImpl) AdjustQuantities(ctx context.Context, d } return nil } + +func (r *ProductWarehouseRepositoryImpl) CleanupEmpty(ctx context.Context, affected map[uint]struct{}) error { + if len(affected) == 0 { + return nil + } + + ids := make([]uint, 0, len(affected)) + for id := range affected { + ids = append(ids, id) + } + + var emptyIDs []uint + if err := r.DB().WithContext(ctx). + Model(&entity.ProductWarehouse{}). + Where("id IN ? AND COALESCE(quantity,0) <= 0", ids). + Pluck("id", &emptyIDs).Error; err != nil { + return err + } + if len(emptyIDs) == 0 { + return nil + } + + if err := r.DB().WithContext(ctx). + Model(&entity.PurchaseItem{}). + Where("product_warehouse_id IN ?", emptyIDs). + Update("product_warehouse_id", nil).Error; err != nil { + return err + } + + if err := r.DB().WithContext(ctx). + Where("id IN ?", emptyIDs). + Delete(&entity.ProductWarehouse{}).Error; err != nil { + return err + } + + return nil +} + +func (r *ProductWarehouseRepositoryImpl) EnsureProductWarehouse( + ctx context.Context, + productID uint, + warehouseID uint, + createdBy uint64, +) (uint, error) { + record, err := r.GetProductWarehouseByProductAndWarehouseID(ctx, productID, warehouseID) + if err == nil { + return record.Id, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return 0, err + } + + entity := &entity.ProductWarehouse{ + ProductId: productID, + WarehouseId: warehouseID, + Quantity: 0, + CreatedBy: uint(createdBy), + } + if entity.CreatedBy == 0 { + entity.CreatedBy = 1 + } + + if err := r.CreateOne(ctx, entity, nil); err != nil { + return 0, err + } + return entity.Id, 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/master/customers/dto/customer.dto.go b/internal/modules/master/customers/dto/customer.dto.go index bd101be5..e1fb8d0d 100644 --- a/internal/modules/master/customers/dto/customer.dto.go +++ b/internal/modules/master/customers/dto/customer.dto.go @@ -20,7 +20,7 @@ type CustomerBaseDTO struct { AccountNumber string `json:"account_number"` Balance float64 `json:"balance"` - Pic *userDTO.UserBaseDTO `json:"pic"` + Pic *userDTO.UserBaseDTO `json:"pic,omitempty"` } type CustomerListDTO struct { diff --git a/internal/modules/master/kandangs/dto/kandang.dto.go b/internal/modules/master/kandangs/dto/kandang.dto.go index deed483c..5e775c68 100644 --- a/internal/modules/master/kandangs/dto/kandang.dto.go +++ b/internal/modules/master/kandangs/dto/kandang.dto.go @@ -14,15 +14,21 @@ type KandangBaseDTO struct { Id uint `json:"id"` Name string `json:"name"` Status string `json:"status"` - Location *locationDTO.LocationBaseDTO `json:"location"` - Pic *userDTO.UserBaseDTO `json:"pic"` + Capacity float64 `json:"capacity"` + Location locationDTO.LocationBaseDTO `json:"location,omitempty"` + Pic userDTO.UserBaseDTO `json:"pic,omitempty"` } type KandangListDTO struct { - KandangBaseDTO - CreatedUser *userDTO.UserBaseDTO `json:"created_user"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + 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"` + CreatedUser userDTO.UserBaseDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type KandangDetailDTO struct { @@ -32,39 +38,56 @@ type KandangDetailDTO struct { // === Mapper Functions === func ToKandangBaseDTO(e entity.Kandang) KandangBaseDTO { - var location *locationDTO.LocationBaseDTO + var location locationDTO.LocationBaseDTO if e.Location.Id != 0 { mapped := locationDTO.ToLocationBaseDTO(e.Location) - location = &mapped + location = mapped } - var pic *userDTO.UserBaseDTO + var pic userDTO.UserBaseDTO if e.Pic.Id != 0 { mapped := userDTO.ToUserBaseDTO(e.Pic) - pic = &mapped + pic = mapped } return KandangBaseDTO{ Id: e.Id, Name: e.Name, Status: e.Status, + Capacity: e.Capacity, Location: location, Pic: pic, } } func ToKandangListDTO(e entity.Kandang) KandangListDTO { - var createdUser *userDTO.UserBaseDTO + var location locationDTO.LocationBaseDTO + if e.Location.Id != 0 { + mapped := locationDTO.ToLocationBaseDTO(e.Location) + location = mapped + } + + var pic userDTO.UserBaseDTO + if e.Pic.Id != 0 { + mapped := userDTO.ToUserBaseDTO(e.Pic) + pic = mapped + } + + var createdUser userDTO.UserBaseDTO if e.CreatedUser.Id != 0 { mapped := userDTO.ToUserBaseDTO(e.CreatedUser) - createdUser = &mapped + createdUser = mapped } return KandangListDTO{ - KandangBaseDTO: ToKandangBaseDTO(e), - CreatedAt: e.CreatedAt, - UpdatedAt: e.UpdatedAt, - CreatedUser: createdUser, + Id: e.Id, + Name: e.Name, + Status: e.Status, + Location: location, + Pic: pic, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, } } diff --git a/internal/modules/master/kandangs/repositories/kandang.repository.go b/internal/modules/master/kandangs/repositories/kandang.repository.go index 8f32a7b2..a08db163 100644 --- a/internal/modules/master/kandangs/repositories/kandang.repository.go +++ b/internal/modules/master/kandangs/repositories/kandang.repository.go @@ -7,6 +7,7 @@ import ( "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gitlab.com/mbugroup/lti-api.git/internal/utils" + "github.com/gofiber/fiber/v2" "gorm.io/gorm" ) @@ -115,9 +116,41 @@ func (r *KandangRepositoryImpl) UpsertProjectFlockKandang(ctx context.Context, p Where("project_flock_id = ? AND kandang_id = ?", projectFlockID, kandangID). First(&link).Error if errors.Is(err, gorm.ErrRecordNotFound) { + var project entity.ProjectFlock + if err := r.db.WithContext(ctx). + Select("id, location_id"). + First(&project, projectFlockID).Error; err != nil { + return err + } + + var kandang entity.Kandang + if err := r.db.WithContext(ctx). + Select("id, location_id"). + First(&kandang, kandangID).Error; err != nil { + return err + } + if kandang.LocationId != project.LocationId { + return fiber.NewError(fiber.StatusBadRequest, "Kandang tidak berada pada lokasi yang sama dengan project flock") + } + + // Determine project's period from existing pivot rows so the new kandang + // shares the same period. + var period int + if err := r.db.WithContext(ctx). + Table("project_flock_kandangs"). + Where("project_flock_id = ?", projectFlockID). + Select("COALESCE(MAX(period), 0)"). + Scan(&period).Error; err != nil { + return err + } + if period <= 0 { + period = 1 + } + link = entity.ProjectFlockKandang{ ProjectFlockId: projectFlockID, KandangId: kandangID, + Period: period, } return r.db.WithContext(ctx).Create(&link).Error } 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/locations/dto/location.dto.go b/internal/modules/master/locations/dto/location.dto.go index 6e9ec68b..d5d5b26e 100644 --- a/internal/modules/master/locations/dto/location.dto.go +++ b/internal/modules/master/locations/dto/location.dto.go @@ -14,11 +14,14 @@ type LocationBaseDTO struct { Id uint `json:"id"` Name string `json:"name"` Address string `json:"address"` - Area *areaDTO.AreaBaseDTO `json:"area"` + Area *areaDTO.AreaBaseDTO `json:"area,omitempty"` } type LocationListDTO struct { - LocationBaseDTO + Id uint `json:"id"` + Name string `json:"name"` + Address string `json:"address"` + Area *areaDTO.AreaBaseDTO `json:"area"` CreatedUser *userDTO.UserBaseDTO `json:"created_user"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -52,11 +55,20 @@ func ToLocationListDTO(e entity.Location) LocationListDTO { createdUser = &mapped } + var area *areaDTO.AreaBaseDTO + if e.Area.Id != 0 { + mapped := areaDTO.ToAreaBaseDTO(e.Area) + area = &mapped + } + return LocationListDTO{ - LocationBaseDTO: ToLocationBaseDTO(e), - CreatedUser: createdUser, - CreatedAt: e.CreatedAt, - UpdatedAt: e.UpdatedAt, + Id: e.Id, + Name: e.Name, + Address: e.Address, + Area: area, + CreatedUser: createdUser, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, } } diff --git a/internal/modules/master/nonstocks/dto/nonstock.dto.go b/internal/modules/master/nonstocks/dto/nonstock.dto.go index 9ab2270b..fb30b6cf 100644 --- a/internal/modules/master/nonstocks/dto/nonstock.dto.go +++ b/internal/modules/master/nonstocks/dto/nonstock.dto.go @@ -12,16 +12,17 @@ import ( // === DTO Structs === type NonstockBaseDTO struct { - Id uint `json:"id"` - Name string `json:"name"` - UomID uint `json:"uom_id"` + Id uint `json:"id"` + Name string `json:"name"` + Uom *uomDTO.UomBaseDTO `json:"uom,omitempty"` + Flags []string `json:"flags"` } type NonstockListDTO struct { - NonstockBaseDTO - Uom *uomDTO.UomBaseDTO `json:"uom,omitempty"` + Id uint `json:"id"` + Name string `json:"name"` + Uom *uomDTO.UomBaseDTO `json:"uom"` Suppliers []supplierDTO.SupplierBaseDTO `json:"suppliers"` - Flags []string `json:"flags"` CreatedUser *userDTO.UserBaseDTO `json:"created_user"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -35,10 +36,22 @@ type NonstockDetailDTO struct { // === Mapper Functions === func ToNonstockBaseDTO(e entity.Nonstock) NonstockBaseDTO { + var uomRef *uomDTO.UomBaseDTO + if e.Uom.Id != 0 { + mapped := uomDTO.ToUomBaseDTO(e.Uom) + uomRef = &mapped + } + + flags := make([]string, len(e.Flags)) + for i, f := range e.Flags { + flags[i] = f.Name + } + return NonstockBaseDTO{ Id: e.Id, Name: e.Name, - UomID: e.UomId, + Uom: uomRef, + Flags: flags, } } @@ -66,13 +79,13 @@ func ToNonstockListDTO(e entity.Nonstock) NonstockListDTO { } return NonstockListDTO{ - NonstockBaseDTO: ToNonstockBaseDTO(e), - CreatedAt: e.CreatedAt, - UpdatedAt: e.UpdatedAt, - CreatedUser: createdUser, - Uom: uomRef, - Suppliers: suppliers, - Flags: flags, + Id: e.Id, + Name: e.Name, + Uom: uomRef, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, + Suppliers: suppliers, } } diff --git a/internal/modules/master/products/dto/product.dto.go b/internal/modules/master/products/dto/product.dto.go index b7f21008..60696fc7 100644 --- a/internal/modules/master/products/dto/product.dto.go +++ b/internal/modules/master/products/dto/product.dto.go @@ -13,8 +13,10 @@ import ( // === DTO Structs === type ProductBaseDTO struct { - Id uint `json:"id"` - Name string `json:"name"` + Id uint `json:"id"` + Name string `json:"name"` + Uom *uomDTO.UomBaseDTO `json:"uom,omitempty"` + Flags []string `json:"flags"` } type ProductListDTO struct { @@ -25,10 +27,8 @@ type ProductListDTO struct { SellingPrice *float64 `json:"selling_price,omitempty"` Tax *float64 `json:"tax,omitempty"` ExpiryPeriod *int `json:"expiry_period,omitempty"` - Uom *uomDTO.UomBaseDTO `json:"uom,omitempty"` ProductCategory *productCategoryDTO.ProductCategoryBaseDTO `json:"product_category,omitempty"` Suppliers []supplierDTO.SupplierBaseDTO `json:"suppliers"` - Flags []string `json:"flags"` CreatedUser *userDTO.UserBaseDTO `json:"created_user"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -42,9 +42,22 @@ type ProductDetailDTO struct { // === Mapper Functions === func ToProductBaseDTO(e entity.Product) ProductBaseDTO { + flags := make([]string, len(e.Flags)) + for i, f := range e.Flags { + flags[i] = f.Name + } + + var uomRef *uomDTO.UomBaseDTO + if e.Uom.Id != 0 { + mapped := uomDTO.ToUomBaseDTO(e.Uom) + uomRef = &mapped + } + return ProductBaseDTO{ - Id: e.Id, - Name: e.Name, + Id: e.Id, + Name: e.Name, + Flags: flags, + Uom: uomRef, } } @@ -55,12 +68,6 @@ func ToProductListDTO(e entity.Product) ProductListDTO { createdUser = &mapped } - var uomRef *uomDTO.UomBaseDTO - if e.Uom.Id != 0 { - mapped := uomDTO.ToUomBaseDTO(e.Uom) - uomRef = &mapped - } - var categoryRef *productCategoryDTO.ProductCategoryBaseDTO if e.ProductCategory.Id != 0 { mapped := productCategoryDTO.ToProductCategoryBaseDTO(e.ProductCategory) @@ -72,11 +79,6 @@ func ToProductListDTO(e entity.Product) ProductListDTO { suppliers[i] = supplierDTO.ToSupplierBaseDTO(s) } - flags := make([]string, len(e.Flags)) - for i, f := range e.Flags { - flags[i] = f.Name - } - return ProductListDTO{ Brand: e.Brand, Sku: e.Sku, @@ -88,10 +90,8 @@ func ToProductListDTO(e entity.Product) ProductListDTO { CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt, CreatedUser: createdUser, - Uom: uomRef, ProductCategory: categoryRef, Suppliers: suppliers, - Flags: flags, } } diff --git a/internal/modules/master/products/repositories/product.repository.go b/internal/modules/master/products/repositories/product.repository.go index 06672f5f..d1cc5cb2 100644 --- a/internal/modules/master/products/repositories/product.repository.go +++ b/internal/modules/master/products/repositories/product.repository.go @@ -16,6 +16,7 @@ type ProductRepository interface { IdExists(ctx context.Context, id uint) (bool, error) CategoryExists(ctx context.Context, categoryID uint) (bool, error) GetSuppliersByIDs(ctx context.Context, supplierIDs []uint) ([]entity.Supplier, error) + IsLinkedToSupplier(ctx context.Context, productID, supplierID uint) (bool, error) SyncSuppliersDiff(ctx context.Context, tx *gorm.DB, productID uint, supplierIDs []uint) error SyncFlags(ctx context.Context, tx *gorm.DB, productID uint, flags []string) error DeleteFlags(ctx context.Context, tx *gorm.DB, productID uint) error @@ -90,6 +91,17 @@ func (r *ProductRepositoryImpl) GetSuppliersByIDs(ctx context.Context, supplierI return suppliers, nil } +func (r *ProductRepositoryImpl) IsLinkedToSupplier(ctx context.Context, productID, supplierID uint) (bool, error) { + var count int64 + if err := r.DB().WithContext(ctx). + Model(&entity.ProductSupplier{}). + Where("product_id = ? AND supplier_id = ?", productID, supplierID). + Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} + func (r *ProductRepositoryImpl) SyncSuppliersDiff(ctx context.Context, tx *gorm.DB, productID uint, supplierIDs []uint) error { db := tx if db == nil { 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/dto/warehouse.dto.go b/internal/modules/master/warehouses/dto/warehouse.dto.go index b5432127..f3c3715b 100644 --- a/internal/modules/master/warehouses/dto/warehouse.dto.go +++ b/internal/modules/master/warehouses/dto/warehouse.dto.go @@ -16,16 +16,21 @@ type WarehouseBaseDTO struct { Id uint `json:"id"` Name string `json:"name"` Type string `json:"type"` - Area *areaDTO.AreaBaseDTO `json:"area"` - Location *locationDTO.LocationBaseDTO `json:"location"` - Kandang *kandangDTO.KandangBaseDTO `json:"kandang"` + Area *areaDTO.AreaBaseDTO `json:"area,omitempty"` + Location *locationDTO.LocationBaseDTO `json:"location,omitempty"` + Kandang *kandangDTO.KandangBaseDTO `json:"kandang,omitempty"` } type WarehouseListDTO struct { - WarehouseBaseDTO - CreatedUser *userDTO.UserBaseDTO `json:"created_user"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + Id uint `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Area *areaDTO.AreaBaseDTO `json:"area"` + Location *locationDTO.LocationBaseDTO `json:"location"` + Kandang *kandangDTO.KandangBaseDTO `json:"kandang"` + CreatedUser *userDTO.UserBaseDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type WarehouseDetailDTO struct { @@ -70,11 +75,34 @@ func ToWarehouseListDTO(e entity.Warehouse) WarehouseListDTO { createdUser = &mapped } + var area *areaDTO.AreaBaseDTO + if e.Area.Id != 0 { + mapped := areaDTO.ToAreaBaseDTO(e.Area) + area = &mapped + } + + var location *locationDTO.LocationBaseDTO + if e.Location != nil && e.Location.Id != 0 { + mapped := locationDTO.ToLocationBaseDTO(*e.Location) + location = &mapped + } + + var kandang *kandangDTO.KandangBaseDTO + if e.Kandang != nil && e.Kandang.Id != 0 { + mapped := kandangDTO.ToKandangBaseDTO(*e.Kandang) + kandang = &mapped + } + return WarehouseListDTO{ - WarehouseBaseDTO: ToWarehouseBaseDTO(e), - CreatedAt: e.CreatedAt, - UpdatedAt: e.UpdatedAt, - CreatedUser: createdUser, + Id: e.Id, + Name: e.Name, + Type: e.Type, + Area: area, + Location: location, + Kandang: kandang, + 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..b581da3c 100644 --- a/internal/modules/production/chickins/dto/chickin.dto.go +++ b/internal/modules/production/chickins/dto/chickin.dto.go @@ -16,11 +16,13 @@ import ( // === DTO Structs (ordered) === 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"` + UsageQty float64 `json:"usage_qty"` + PendingUsageQty float64 `json:"pending_usage_qty"` + Notes string `json:"notes"` } type ProjectFlockDTO struct { @@ -45,21 +47,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) === @@ -87,7 +100,8 @@ func ToUserBaseDTO(e entity.User) userBaseDTO.UserBaseDTO { return userBaseDTO.ToUserBaseDTO(e) } -func ToProjectFlockDTO(e entity.ProjectFlock) ProjectFlockDTO { +func ToProjectFlockDTO(pfk entity.ProjectFlockKandang) ProjectFlockDTO { + e := pfk.ProjectFlock var flock *flockBaseDTO.FlockBaseDTO if base := pfutils.DeriveBaseName(e.FlockName); base != "" { summary := flockBaseDTO.FlockBaseDTO{Id: 0, Name: base} @@ -110,7 +124,7 @@ func ToProjectFlockDTO(e entity.ProjectFlock) ProjectFlockDTO { } return ProjectFlockDTO{ Id: e.Id, - Period: e.Period, + Period: pfk.Period, Category: e.Category, Flock: flock, Area: area, @@ -122,7 +136,7 @@ func ToProjectFlockDTO(e entity.ProjectFlock) ProjectFlockDTO { func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangDTO { var pf *ProjectFlockDTO if e.ProjectFlock.Id != 0 { - mapped := ToProjectFlockDTO(e.ProjectFlock) + mapped := ToProjectFlockDTO(e) pf = &mapped } var kandang *kandangBaseDTO.KandangBaseDTO @@ -138,17 +152,22 @@ 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 } 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, + UsageQty: e.UsageQty, + PendingUsageQty: e.PendingUsageQty, + Notes: e.Notes, } } @@ -157,29 +176,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 +215,31 @@ 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 +} 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..d78a9a00 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,164 @@ 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 + var productWarehouses []entity.ProductWarehouse + category := strings.ToUpper(strings.TrimSpace(projectFlockKandang.ProjectFlock.Category)) + + var productCategoryCode string + switch category { + case string(utils.ProjectFlockCategoryGrowing): + productCategoryCode = "DOC" + case string(utils.ProjectFlockCategoryLaying): + productCategoryCode = "PULLET" + default: + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Unknown category: %s", category)) } - if totalQuantity < 1 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Insufficient quantity in Product Warehouses") + productWarehouses, err = s.ProductWarehouseRepo.GetByCategoryCodeAndWarehouseID(c.Context(), productCategoryCode, warehouse.Id) + if err != nil || len(productWarehouses) == 0 { + return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Product for %s category in the Kandang's warehouse not found", strings.ToLower(category))) } 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 - } - // 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) + actorID := uint(1) // todo nanti ambil dari auth context + newChikins := make([]*entity.ProjectChickin, 0) + for _, productWarehouse := range productWarehouses { + availableQty, err := s.calculateAvailableQuantity(c, req.ProjectFlockKandangId, &productWarehouse, category) if err != nil { - s.Log.Errorf("Failed to update product warehouse quantity: %+v", err) - return nil, err + return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to calculate available quantity for product warehouse %d", productWarehouse.Id)) } - newChickinDetail := &entity.ProjectChickinDetail{ - ProjectChickinId: newChickin.Id, - ProductWarehouseId: pw.Id, - Quantity: pw.Quantity, - CreatedBy: 1, // todo: ganti dengan user login + if availableQty <= 0 { + continue } - err = s.ProjectChickinDetailRepo.CreateOne(c.Context(), newChickinDetail, nil) - if err != nil { - s.Log.Errorf("Failed to create chickin detail: %+v", err) - return nil, err - } - } - 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) - if err != nil { - s.Log.Errorf("Failed to update project flock population: %+v", err) - return nil, err - } - } else { - newPopulation := &entity.ProjectFlockPopulation{ + 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: productWarehouse.Id, + Notes: req.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 +279,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 +297,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..c2676130 100644 --- a/internal/modules/production/chickins/validations/chickin.validation.go +++ b/internal/modules/production/chickins/validations/chickin.validation.go @@ -3,7 +3,7 @@ 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` + Note string `json:"note" validate:"omitempty"` } type Update struct { @@ -16,3 +16,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..92743035 --- /dev/null +++ b/internal/modules/production/project-flock-kandangs/controllers/project_flock_kandang.controller.go @@ -0,0 +1,76 @@ +package controller + +import ( + "math" + "strconv" + + "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", ""), + } + + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + result, totalResults, err := u.ProjectFlockKandangService.GetAll(c, query) + if err != nil { + return err + } + + 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: dto.ToProjectFlockKandangListDTOs(result), + }) +} + +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, 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.ToProjectFlockKandangListDTOWithAvailableQty(*result, availableQtys), + }) +} 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..92db5b23 --- /dev/null +++ b/internal/modules/production/project-flock-kandangs/dto/project_flock_kandang.dto.go @@ -0,0 +1,330 @@ +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"` +} + +type ProjectFlockDTO struct { + Id uint `json:"id"` + 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"` + Chickins []chickinDTO.ChickinBaseDTO `json:"chickins,omitempty"` + AvailableQtys []AvailableQtyDTO `json:"available_qtys,omitempty"` + CreatedUser *userDTO.UserBaseDTO `json:"created_user,omitempty"` + CreatedAt time.Time `json:"created_at"` + Approval *approvalDTO.ApprovalBaseDTO `json:"approval,omitempty"` +} + +type ProjectFlockKandangDetailDTO struct { + ProjectFlockKandangListDTO +} + +// === Mapper Functions (ordered) === + +func ToProjectFlockKandangBaseDTO(e entity.ProjectFlockKandang) ProjectFlockKandangBaseDTO { + return ProjectFlockKandangBaseDTO{ + Id: e.Id, + } +} + +func toProjectFlockDTO(pf *projectFlockDTO.ProjectFlockListDTO) *ProjectFlockDTO { + if pf == nil { + return nil + } + + return &ProjectFlockDTO{ + Id: pf.Id, + Period: pf.Period, + Area: pf.Area, + Category: pf.Category, + Fcr: pf.Fcr, + Location: pf.Location, + CreatedUser: pf.CreatedUser, + CreatedAt: pf.CreatedAt, + UpdatedAt: pf.UpdatedAt, + } +} + +func ToProjectFlockKandangListDTOWithAvailableQty(e entity.ProjectFlockKandang, availableQtysRaw []map[string]interface{}) ProjectFlockKandangListDTO { + var projectFlockSummary *projectFlockDTO.ProjectFlockListDTO + if e.ProjectFlock.Id != 0 { + mapped := projectFlockDTO.ToProjectFlockListDTO(e.ProjectFlock) + projectFlockSummary = &mapped + } + + return ProjectFlockKandangListDTO{ + ProjectFlockKandangBaseDTO: ToProjectFlockKandangBaseDTO(e), + ProjectFlock: toProjectFlockDTO(projectFlockSummary), + Kandang: toKandangDTO(e.Kandang), + Chickins: toChickinDTOs(e.Chickins), + AvailableQtys: toAvailableQtyDTOsFromRaw(availableQtysRaw), + CreatedAt: e.CreatedAt, + CreatedUser: toCreatedUserDTO(e.ProjectFlock), + Approval: toApprovalDTO(e), + } +} + +func toKandangDTO(kandang entity.Kandang) *KandangDTO { + if kandang.Id == 0 { + return nil + } + + return &KandangDTO{ + Id: kandang.Id, + Name: kandang.Name, + Status: kandang.Status, + } +} + +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 { + var projectFlockSummary *projectFlockDTO.ProjectFlockListDTO + if e.ProjectFlock.Id != 0 { + mapped := projectFlockDTO.ToProjectFlockListDTO(e.ProjectFlock) + projectFlockSummary = &mapped + } + + return ProjectFlockKandangListDTO{ + ProjectFlockKandangBaseDTO: ToProjectFlockKandangBaseDTO(e), + ProjectFlock: toProjectFlockDTO(projectFlockSummary), + Kandang: toKandangDTO(e.Kandang), + Chickins: toChickinDTOs(e.Chickins), + AvailableQtys: toAvailableQtyDTOs(e.Chickins), + 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 ToProjectFlockKandangDetailDTO(e entity.ProjectFlockKandang) ProjectFlockKandangDetailDTO { + return ProjectFlockKandangDetailDTO{ + ProjectFlockKandangListDTO: ToProjectFlockKandangListDTO(e), + } +} + +// === Helper Functions (ordered) === + +func toProductWarehouseDTO(pwData map[string]interface{}) *ProductWarehouseDTO { + if pwData == nil { + return nil + } + + dto := &ProductWarehouseDTO{} + + if id, ok := pwData["id"].(float64); ok { + dto.Id = uint(id) + } else if id, ok := pwData["id"].(uint); ok { + dto.Id = id + } + + if pData, ok := pwData["product"].(map[string]interface{}); ok { + dto.Product = toProductDTO(pData) + } + + if wData, ok := pwData["warehouse"].(map[string]interface{}); ok { + dto.Warehouse = toWarehouseDTO(wData) + } + + return dto +} + +func toProductDTO(pData map[string]interface{}) *productDTO.ProductBaseDTO { + if pData == nil { + return nil + } + + product := &productDTO.ProductBaseDTO{} + if id, ok := pData["id"].(float64); ok { + product.Id = uint(id) + } else if id, ok := pData["id"].(uint); ok { + product.Id = id + } + if name, ok := pData["name"].(string); ok { + product.Name = name + } + return product +} + +func toWarehouseDTO(wData map[string]interface{}) *warehouseDTO.WarehouseBaseDTO { + if wData == nil { + return nil + } + + warehouse := &warehouseDTO.WarehouseBaseDTO{} + if id, ok := wData["id"].(float64); ok { + warehouse.Id = uint(id) + } else if id, ok := wData["id"].(uint); ok { + warehouse.Id = id + } + if name, ok := wData["name"].(string); ok { + warehouse.Name = name + } + if wType, ok := wData["type"].(string); ok { + warehouse.Type = wType + } + return warehouse +} + +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 toAvailableQtyDTOs(chickins []entity.ProjectChickin) []AvailableQtyDTO { + if len(chickins) == 0 { + return nil + } + + availableQtyMap := make(map[uint]AvailableQtyDTO) + for _, ch := range chickins { + if ch.ProductWarehouse == nil || ch.ProductWarehouse.Quantity <= 0 { + continue + } + + if _, exists := availableQtyMap[ch.ProductWarehouseId]; exists { + continue + } + + pwDTO := &ProductWarehouseDTO{ + Id: ch.ProductWarehouse.Id, + } + + if ch.ProductWarehouse.Product.Id != 0 { + pwDTO.Product = &productDTO.ProductBaseDTO{ + Id: ch.ProductWarehouse.Product.Id, + Name: ch.ProductWarehouse.Product.Name, + } + } + + if ch.ProductWarehouse.Warehouse.Id != 0 { + pwDTO.Warehouse = &warehouseDTO.WarehouseBaseDTO{ + Id: ch.ProductWarehouse.Warehouse.Id, + Name: ch.ProductWarehouse.Warehouse.Name, + Type: ch.ProductWarehouse.Warehouse.Type, + } + } + + availableQtyMap[ch.ProductWarehouseId] = AvailableQtyDTO{ + ProductWarehouse: pwDTO, + } + } + + if len(availableQtyMap) == 0 { + return nil + } + + result := make([]AvailableQtyDTO, 0, len(availableQtyMap)) + for _, v := range availableQtyMap { + result = append(result, v) + } + return result +} + +func toAvailableQtyDTOsFromRaw(availableQtysRaw []map[string]interface{}) []AvailableQtyDTO { + if len(availableQtysRaw) == 0 { + return nil + } + + result := make([]AvailableQtyDTO, len(availableQtysRaw)) + for i, v := range availableQtysRaw { + pwData, ok := v["product_warehouse"].(map[string]interface{}) + if !ok { + continue + } + + pwDTO := toProductWarehouseDTO(pwData) + availableQty := 0.0 + if qty, ok := v["available_qty"].(float64); ok { + availableQty = qty + } + + result[i] = AvailableQtyDTO{ + AvailableQty: availableQty, + ProductWarehouse: pwDTO, + } + } + return result +} 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..160cec5e --- /dev/null +++ b/internal/modules/production/project-flock-kandangs/module.go @@ -0,0 +1,43 @@ +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" + 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) + + 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, 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..ec292d90 --- /dev/null +++ b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go @@ -0,0 +1,195 @@ +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" + 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" + "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, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectFlockKandang, []map[string]interface{}, error) +} + +type projectFlockKandangService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.ProjectFlockKandangRepository + ApprovalSvc commonSvc.ApprovalService + WarehouseRepo rWarehouse.WarehouseRepository + ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository + PopulationRepo repository.ProjectFlockPopulationRepository +} + +func NewProjectFlockKandangService(repo repository.ProjectFlockKandangRepository, approvalSvc commonSvc.ApprovalService, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, populationRepo repository.ProjectFlockPopulationRepository, validate *validator.Validate) ProjectFlockKandangService { + return &projectFlockKandangService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + ApprovalSvc: approvalSvc, + WarehouseRepo: warehouseRepo, + ProductWarehouseRepo: productWarehouseRepo, + PopulationRepo: populationRepo, + } +} + +func (s projectFlockKandangService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlockKandang, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + projectFlockKandangs, err := s.Repository.GetAll(c.Context()) + if err != nil { + s.Log.Errorf("Failed to get projectFlockKandangs: %+v", err) + return nil, 0, err + } + total := int64(len(projectFlockKandangs)) + + return projectFlockKandangs, total, nil +} + +func (s projectFlockKandangService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlockKandang, []map[string]interface{}, error) { + projectFlockKandang, err := s.Repository.GetByID(c.Context(), id) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found") + } + if err != nil { + s.Log.Errorf("Failed get projectFlockKandang by id: %+v", err) + return 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 + } + } + + availableQtys, err := s.getAvailableQuantities(c, projectFlockKandang) + if err != nil { + s.Log.Errorf("Failed to fetch available quantities for kandang %d: %+v", projectFlockKandang.Kandang.Id, err) + availableQtys = nil + } + + return projectFlockKandang, availableQtys, nil +} + +func (s projectFlockKandangService) getAvailableQuantities(c *fiber.Ctx, projectFlockKandang *entity.ProjectFlockKandang) ([]map[string]interface{}, 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.GetByCategoryCodeAndWarehouseID(c.Context(), productCategoryCode, warehouse.Id) + if err != nil || len(products) == 0 { + return nil, nil + } + + var result []map[string]interface{} + 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) + } + + // Only include product warehouse if available_qty > 0 + if availableQty <= 0 { + continue + } + + productData := map[string]interface{}{ + "id": pw.Product.Id, + "name": pw.Product.Name, + } + + warehouseData := map[string]interface{}{ + "id": pw.Warehouse.Id, + "name": pw.Warehouse.Name, + "type": pw.Warehouse.Type, + } + + productWarehouseData := map[string]interface{}{ + "id": pw.Id, + "product": productData, + "warehouse": warehouseData, + } + + result = append(result, map[string]interface{}{ + "available_qty": availableQty, + "product_warehouse": productWarehouseData, + }) + } + + 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..1e95fd2c --- /dev/null +++ b/internal/modules/production/project-flock-kandangs/validations/project_flock_kandang.validation.go @@ -0,0 +1,17 @@ +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"` +} diff --git a/internal/modules/production/project_flocks/controllers/projectflock.controller.go b/internal/modules/production/project_flocks/controllers/projectflock.controller.go index d3b0061c..9957e4d9 100644 --- a/internal/modules/production/project_flocks/controllers/projectflock.controller.go +++ b/internal/modules/production/project_flocks/controllers/projectflock.controller.go @@ -85,6 +85,17 @@ func (u *ProjectflockController) GetAll(c *fiber.Ctx) error { return err } + var periodMap map[uint]int + if len(result) > 0 { + ids := make([]uint, len(result)) + for i, item := range result { + ids[i] = item.Id + } + if periods, err := u.ProjectflockService.GetProjectPeriods(c, ids); err == nil { + periodMap = periods + } + } + return c.Status(fiber.StatusOK). JSON(response.SuccessWithPaginate[dto.ProjectFlockListDTO]{ Code: fiber.StatusOK, @@ -96,7 +107,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: dto.ToProjectFlockListDTOsWithPeriods(result, periodMap), }) } @@ -113,12 +124,19 @@ func (u *ProjectflockController) GetOne(c *fiber.Ctx) error { return err } + var period int + if periods, err := u.ProjectflockService.GetProjectPeriods(c, []uint{uint(id)}); err == nil { + if p, ok := periods[uint(id)]; ok { + period = p + } + } + return c.Status(fiber.StatusOK). JSON(response.Success{ Code: fiber.StatusOK, Status: "success", Message: "Get projectflock successfully", - Data: dto.ToProjectFlockListDTO(*result), + Data: dto.ToProjectFlockListDTOWithPeriod(*result, period), }) } @@ -205,11 +223,29 @@ func (u *ProjectflockController) Approval(c *fiber.Ctx) error { data interface{} message = "Submit projectflock approval successfully" ) + + var periodMap map[uint]int + if len(results) > 0 { + ids := make([]uint, len(results)) + for i, item := range results { + ids[i] = item.Id + } + if periods, err := u.ProjectflockService.GetProjectPeriods(c, ids); err == nil { + periodMap = periods + } + } + if len(results) == 1 { - data = dto.ToProjectFlockListDTO(results[0]) + period := 0 + if periodMap != nil { + if p, ok := periodMap[results[0].Id]; ok { + period = p + } + } + data = dto.ToProjectFlockListDTOWithPeriod(results[0], period) } else { message = "Submit projectflock approvals successfully" - data = dto.ToProjectFlockListDTOs(results) + data = dto.ToProjectFlockListDTOsWithPeriods(results, periodMap) } return c.Status(fiber.StatusOK). @@ -222,25 +258,32 @@ func (u *ProjectflockController) Approval(c *fiber.Ctx) error { } func (u *ProjectflockController) GetFlockPeriodSummary(c *fiber.Ctx) error { - param := c.Params("project_flock_kandang_id") + param := c.Params("location_id") id, err := strconv.Atoi(param) if err != nil { - return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_kandang_id") + return fiber.NewError(fiber.StatusBadRequest, "Invalid location_id") } - summary, err := u.ProjectflockService.GetFlockPeriodSummary(c, uint(id)) + summaries, err := u.ProjectflockService.GetFlockPeriodSummary(c, uint(id)) if err != nil { return err } - responseBody := dto.ToFlockPeriodSummaryDTO(summary.Flock, summary.NextPeriod) + responseBody := make([]dto.KandangPeriodSummaryDTO, 0, len(summaries)) + for _, item := range summaries { + responseBody = append(responseBody, dto.KandangPeriodSummaryDTO{ + Id: item.Id, + Name: item.Name, + Period: item.Period, + }) + } return c.Status(fiber.StatusOK). JSON(response.Success{ Code: fiber.StatusOK, Status: "success", - Message: "Get flock period summary successfully", + Message: "Get kandang period summary successfully", Data: responseBody, }) } diff --git a/internal/modules/production/project_flocks/dto/projectflock.dto.go b/internal/modules/production/project_flocks/dto/projectflock.dto.go index bfadf3e2..dac023df 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" @@ -22,6 +23,13 @@ type ProjectFlockBaseDTO struct { FlockName string `json:"flock_name"` } +type KandangWithProjectFlockIdDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + ProjectFlockKandangId uint `json:"project_flock_kandang_id"` +} + type ProjectFlockListDTO struct { ProjectFlockBaseDTO // Flock *flockDTO.FlockBaseDTO `json:"flock,omitempty"` @@ -45,7 +53,13 @@ type FlockPeriodDTO struct { NextPeriod int `json:"next_period"` } -func ToProjectFlockListDTO(e entity.ProjectFlock) ProjectFlockListDTO { +type KandangPeriodSummaryDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + Period int `json:"period"` +} + +func ToProjectFlockListDTOWithPeriod(e entity.ProjectFlock, period int) ProjectFlockListDTO { var createdUser *userDTO.UserBaseDTO if e.CreatedUser.Id != 0 { mapped := userDTO.ToUserBaseDTO(e.CreatedUser) @@ -91,31 +105,49 @@ func ToProjectFlockListDTO(e entity.ProjectFlock) ProjectFlockListDTO { } return ProjectFlockListDTO{ - ProjectFlockBaseDTO: createProjectFlockBaseDTO(e), + ProjectFlockBaseDTO: createProjectFlockBaseDTO(e, period), // Flock: flockSummary, - Area: areaSummary, - Kandangs: kandangSummaries, - Category: e.Category, - Fcr: fcrSummary, - Location: locationSummary, - CreatedAt: e.CreatedAt, - UpdatedAt: e.UpdatedAt, - CreatedUser: createdUser, - Approval: latestApproval, + Area: areaSummary, + Kandangs: kandangSummaries, + Category: e.Category, + Fcr: fcrSummary, + Location: locationSummary, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, + Approval: latestApproval, } } func ToProjectFlockListDTOs(items []entity.ProjectFlock) []ProjectFlockListDTO { result := make([]ProjectFlockListDTO, len(items)) for i, item := range items { - result[i] = ToProjectFlockListDTO(item) + result[i] = ToProjectFlockListDTOWithPeriod(item, 0) + } + return result +} + +func ToProjectFlockListDTO(e entity.ProjectFlock) ProjectFlockListDTO { + return ToProjectFlockListDTOWithPeriod(e, 0) +} + +func ToProjectFlockListDTOsWithPeriods(items []entity.ProjectFlock, periods map[uint]int) []ProjectFlockListDTO { + result := make([]ProjectFlockListDTO, len(items)) + for i, item := range items { + p := 0 + if periods != nil { + if v, ok := periods[item.Id]; ok { + p = v + } + } + result[i] = ToProjectFlockListDTOWithPeriod(item, p) } return result } func ToProjectFlockDetailDTO(e entity.ProjectFlock) ProjectFlockDetailDTO { return ProjectFlockDetailDTO{ - ProjectFlockListDTO: ToProjectFlockListDTO(e), + ProjectFlockListDTO: ToProjectFlockListDTOWithPeriod(e, 0), } } @@ -144,10 +176,10 @@ func defaultProjectFlockLatestApproval(e entity.ProjectFlock) approvalDTO.Approv return result } -func createProjectFlockBaseDTO(e entity.ProjectFlock) ProjectFlockBaseDTO { +func createProjectFlockBaseDTO(e entity.ProjectFlock, period int) ProjectFlockBaseDTO { return ProjectFlockBaseDTO{ Id: e.Id, - Period: e.Period, + Period: period, FlockName: e.FlockName, } } diff --git a/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go b/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go index 24e53d28..2e25bf09 100644 --- a/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go +++ b/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go @@ -50,7 +50,7 @@ func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangD pfLocal := ProjectFlockWithPivotDTO{ ProjectFlockBaseDTO: ProjectFlockBaseDTO{ Id: e.ProjectFlock.Id, - Period: e.ProjectFlock.Period, + Period: e.Period, FlockName: e.ProjectFlock.FlockName, }, Category: e.ProjectFlock.Category, 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..d914b128 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,19 @@ 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) + + // 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 +34,60 @@ 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 } diff --git a/internal/modules/production/project_flocks/repositories/projectflock.repository.go b/internal/modules/production/project_flocks/repositories/projectflock.repository.go index bb653fe9..afb05982 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock.repository.go @@ -2,7 +2,6 @@ package repository import ( "context" - "errors" "fmt" "strings" @@ -10,17 +9,12 @@ import ( entity "gitlab.com/mbugroup/lti-api.git/internal/entities" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations" "gorm.io/gorm" - "gorm.io/gorm/clause" ) const baseNameExpression = "LOWER(TRIM(regexp_replace(flock_name, '\\\\s+\\\\d+(\\\\s+\\\\d+)*$', '', 'g')))" type ProjectflockRepository interface { repository.BaseRepository[entity.ProjectFlock] - GetAllByBaseName(ctx context.Context, baseName string) ([]entity.ProjectFlock, error) - GetActiveByBaseName(ctx context.Context, baseName string) (*entity.ProjectFlock, error) - GetMaxPeriodByBaseName(ctx context.Context, baseName string) (int, error) - GetNextSequenceForBase(ctx context.Context, baseName string) (int, error) GetAllWithFilters(ctx context.Context, offset, limit int, params *validation.Query) ([]entity.ProjectFlock, int64, error) WithDefaultRelations() func(*gorm.DB) *gorm.DB ExistsByFlockName(ctx context.Context, flockName string, excludeID *uint) (bool, error) @@ -39,65 +33,6 @@ func NewProjectflockRepository(db *gorm.DB) ProjectflockRepository { } } -func (r *ProjectflockRepositoryImpl) GetAllByBaseName(ctx context.Context, baseName string) ([]entity.ProjectFlock, error) { - var records []entity.ProjectFlock - if err := r.DB().WithContext(ctx). - Unscoped(). - Where(baseNameExpression+" = LOWER(?)", baseName). - Order("period ASC"). - Find(&records).Error; err != nil { - return nil, err - } - return records, nil -} - -func (r *ProjectflockRepositoryImpl) GetActiveByBaseName(ctx context.Context, baseName string) (*entity.ProjectFlock, error) { - var record entity.ProjectFlock - err := r.DB().WithContext(ctx). - Where(baseNameExpression+" = LOWER(?)", baseName). - Order("period DESC"). - First(&record).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, err - } - if err != nil { - return nil, err - } - return &record, nil -} - -func (r *ProjectflockRepositoryImpl) GetMaxPeriodByBaseName(ctx context.Context, baseName string) (int, error) { - var max int - if err := r.DB().WithContext(ctx). - Model(&entity.ProjectFlock{}). - Where(baseNameExpression+" = LOWER(?)", baseName). - Select("COALESCE(MAX(period), 0)"). - Scan(&max).Error; err != nil { - return 0, err - } - return max, nil -} - -func (r *ProjectflockRepositoryImpl) GetNextSequenceForBase(ctx context.Context, baseName string) (int, error) { - var payload struct { - Period int - } - if err := r.DB().WithContext(ctx). - Model(&entity.ProjectFlock{}). - Where(baseNameExpression+" = LOWER(?)", baseName). - Clauses(clause.Locking{Strength: "UPDATE"}). - Order("period DESC"). - Limit(1). - Select("period"). - Scan(&payload).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return 1, nil - } - return 0, err - } - return payload.Period + 1, nil -} - func (r *ProjectflockRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, params *validation.Query) ([]entity.ProjectFlock, int64, error) { return r.GetAll(ctx, offset, limit, func(db *gorm.DB) *gorm.DB { db = r.withDefaultRelations(db) @@ -132,7 +67,13 @@ func (r *ProjectflockRepositoryImpl) applyQueryFilters(db *gorm.DB, params *vali db = db.Where("project_flocks.location_id = ?", params.LocationId) } if params.Period > 0 { - db = db.Where("project_flocks.period = ?", params.Period) + db = db.Where(` + EXISTS ( + SELECT 1 + FROM project_flock_kandangs pfk + WHERE pfk.project_flock_id = project_flocks.id + AND pfk.period = ? + )`, params.Period) } if len(params.KandangIds) > 0 { db = db.Where(` @@ -179,10 +120,15 @@ func (r *ProjectflockRepositoryImpl) applySearchFilters(db *gorm.DB, rawSearch s OR LOWER(created_users.email) LIKE ? OR LOWER(project_flocks.flock_name) LIKE ? OR LOWER(TRIM(regexp_replace(project_flocks.flock_name, '\\s+\\d+(\\s+\\d+)*$', '', 'g'))) LIKE ? - OR LOWER(CAST(project_flocks.period AS TEXT)) LIKE ? + OR EXISTS ( + SELECT 1 FROM project_flock_kandangs + WHERE project_flock_kandangs.project_flock_id = project_flocks.id + AND LOWER(CAST(project_flock_kandangs.period AS TEXT)) LIKE ? + ) OR EXISTS ( SELECT 1 FROM kandangs - WHERE kandangs.project_flock_id = project_flocks.id + JOIN project_flock_kandangs pfk ON pfk.kandang_id = kandangs.id + WHERE pfk.project_flock_id = project_flocks.id AND LOWER(kandangs.name) LIKE ? ) `, @@ -236,7 +182,7 @@ func (r *ProjectflockRepositoryImpl) buildOrderExpressions(sortBy, sortOrder str } case "period": return []string{ - fmt.Sprintf("project_flocks.period %s", direction), + fmt.Sprintf("(SELECT COALESCE(MAX(period), 0) FROM project_flock_kandangs pfk WHERE pfk.project_flock_id = project_flocks.id) %s", direction), fmt.Sprintf("project_flocks.id %s", direction), } default: 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..6923b1f3 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go @@ -4,6 +4,7 @@ import ( "context" "strings" + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gorm.io/gorm" ) @@ -18,7 +19,9 @@ type ProjectFlockKandangRepository interface { 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) + ProjectPeriodsByProjectIDs(ctx context.Context, projectIDs []uint) (map[uint]int, error) WithTx(tx *gorm.DB) ProjectFlockKandangRepository + IdExists(ctx context.Context, id uint) (bool, error) DB() *gorm.DB } @@ -59,6 +62,9 @@ func (r *projectFlockKandangRepositoryImpl) GetAll(ctx context.Context) ([]entit Preload("ProjectFlock.Kandangs"). Preload("ProjectFlock.KandangHistory"). Preload("Kandang"). + Preload("Chickins"). + Preload("Chickins.CreatedUser"). + Preload("Chickins.ProductWarehouse"). Order("project_flock_id ASC, created_at ASC"). Find(&records).Error; err != nil { return nil, err @@ -73,6 +79,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 +94,9 @@ 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"). First(record, id).Error; err != nil { return nil, err } @@ -103,6 +115,9 @@ func (r *projectFlockKandangRepositoryImpl) GetByProjectFlockAndKandang(ctx cont Preload("ProjectFlock.Kandangs"). Preload("ProjectFlock.KandangHistory"). Preload("Kandang"). + Preload("Chickins"). + Preload("Chickins.CreatedUser"). + Preload("Chickins.ProductWarehouse"). First(record).Error; err != nil { return nil, err } @@ -163,7 +178,33 @@ func (r *projectFlockKandangRepositoryImpl) MaxPeriodByBaseName(ctx context.Cont Table("project_flock_kandangs pfk"). Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id"). Where(flockBaseNameExpression+" = LOWER(?)", baseName). - Select("COALESCE(MAX(pf.period), 0)"). + Select("COALESCE(MAX(pfk.period), 0)"). Scan(&max).Error return max, err } + +func (r *projectFlockKandangRepositoryImpl) ProjectPeriodsByProjectIDs(ctx context.Context, projectIDs []uint) (map[uint]int, error) { + result := make(map[uint]int) + if len(projectIDs) == 0 { + return result, nil + } + + type row struct { + ProjectFlockID uint + Period int + } + var rows []row + if err := r.db.WithContext(ctx). + Table("project_flock_kandangs"). + Where("project_flock_id IN ?", projectIDs). + Select("project_flock_id, COALESCE(MAX(period), 0) AS period"). + Group("project_flock_id"). + Scan(&rows).Error; err != nil { + return nil, err + } + + for _, item := range rows { + result[item.ProjectFlockID] = item.Period + } + return result, nil +} diff --git a/internal/modules/production/project_flocks/route.go b/internal/modules/production/project_flocks/route.go index 8128f943..0e6f80c4 100644 --- a/internal/modules/production/project_flocks/route.go +++ b/internal/modules/production/project_flocks/route.go @@ -1,7 +1,7 @@ package project_flocks import ( - m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/controllers" projectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,7 +13,7 @@ func ProjectflockRoutes(v1 fiber.Router, u user.UserService, s projectflock.Proj ctrl := controller.NewProjectflockController(s) route := v1.Group("/project-flocks") - route.Use(m.Auth(u)) + // route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) route.Post("/", ctrl.CreateOne) @@ -22,6 +22,6 @@ func ProjectflockRoutes(v1 fiber.Router, u user.UserService, s projectflock.Proj route.Delete("/:id", ctrl.DeleteOne) route.Get("/kandangs/lookup", ctrl.LookupProjectFlockKandang) route.Post("/approvals", ctrl.Approval) - route.Get("/kandangs/:project_flock_kandang_id/periods", ctrl.GetFlockPeriodSummary) + route.Get("/kandangs/:location_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..f812f412 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -35,7 +35,8 @@ type ProjectflockService interface { GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID uint) (float64, error) DeleteOne(ctx *fiber.Ctx, id uint) error GetProjectFlockKandangByProjectAndKandang(ctx *fiber.Ctx, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, float64, error) - GetFlockPeriodSummary(ctx *fiber.Ctx, flockID uint) (*FlockPeriodSummary, error) + GetFlockPeriodSummary(ctx *fiber.Ctx, locationID uint) ([]KandangPeriodSummary, error) + GetProjectPeriods(ctx *fiber.Ctx, projectIDs []uint) (map[uint]int, error) Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlock, error) } @@ -52,9 +53,10 @@ type projectflockService struct { approvalWorkflow approvalutils.ApprovalWorkflowKey } -type FlockPeriodSummary struct { - Flock entity.Flock - NextPeriod int +type KandangPeriodSummary struct { + Id uint + Name string + Period int } func NewProjectflockService( @@ -81,6 +83,18 @@ func NewProjectflockService( } } +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, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err @@ -206,6 +220,11 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* if len(kandangs) != len(kandangIDs) { return nil, fiber.NewError(fiber.StatusNotFound, "Some kandangs not found") } + for _, kandang := range kandangs { + if kandang.LocationId != req.LocationId { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Kandang %d tidak berada pada lokasi yang sama dengan project flock", kandang.Id)) + } + } // larang kalau ada yg sudah terikat ke project lain if linked, err := s.pivotRepo().HasKandangsLinkedToOtherProject(c.Context(), kandangIDs, nil); err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate kandangs linkage") @@ -224,22 +243,24 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { projectRepo := repository.NewProjectflockRepository(dbTransaction) - nextSeq, err := projectRepo.GetNextSequenceForBase(c.Context(), canonicalBase) - if err != nil { - return err - } - generatedName, seq, err := s.generateSequentialFlockName(c.Context(), projectRepo, canonicalBase, nextSeq, nil) + // Generate unique flock name (sequential per base name, starting from 1) + generatedName, _, err := s.generateSequentialFlockName(c.Context(), projectRepo, canonicalBase, 1, nil) if err != nil { return err } createBody.FlockName = generatedName - createBody.Period = seq if err := projectRepo.CreateOne(c.Context(), createBody, nil); err != nil { return err } - if err := s.attachKandangs(c.Context(), dbTransaction, createBody.Id, kandangIDs); err != nil { + // Compute period based on location history (max period in that location + 1), + // and store it on project_flock_kandangs only. + nextPeriod, err := s.nextLocationPeriod(c.Context(), dbTransaction, req.LocationId) + if err != nil { + return err + } + if err := s.attachKandangs(c.Context(), dbTransaction, createBody.Id, kandangIDs, nextPeriod); err != nil { return err } @@ -375,6 +396,15 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id if len(kandangs) != len(newKandangIDs) { return nil, fiber.NewError(fiber.StatusNotFound, "Some kandangs not found") } + targetLocationID := existing.LocationId + if req.LocationId != nil && *req.LocationId > 0 { + targetLocationID = *req.LocationId + } + for _, kandang := range kandangs { + if kandang.LocationId != targetLocationID { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Kandang %d tidak berada pada lokasi yang sama dengan project flock", kandang.Id)) + } + } if linked, err := s.pivotRepo().HasKandangsLinkedToOtherProject(c.Context(), newKandangIDs, &id); err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate kandangs linkage") } else if linked { @@ -400,18 +430,11 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id } if needFlockNameRegenerate { - nextSeq, err := projectRepo.GetNextSequenceForBase(c.Context(), baseForGeneration) - if err != nil { - return err - } - newName, seq, err := s.generateSequentialFlockName(c.Context(), projectRepo, baseForGeneration, nextSeq, &id) + newName, _, err := s.generateSequentialFlockName(c.Context(), projectRepo, baseForGeneration, 1, &id) if err != nil { return err } updateBody["flock_name"] = newName - if seq != existing.Period { - updateBody["period"] = seq - } } if len(updateBody) > 0 { @@ -455,7 +478,19 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id } if len(toAttach) > 0 { - if err := s.attachKandangs(c.Context(), dbTransaction, id, toAttach); err != nil { + var currentPeriod int + if err := dbTransaction.WithContext(c.Context()). + Table("project_flock_kandangs"). + Where("project_flock_id = ?", id). + Select("COALESCE(MAX(period), 0)"). + Scan(¤tPeriod).Error; err != nil { + return err + } + if currentPeriod <= 0 { + currentPeriod = 1 + } + + if err := s.attachKandangs(c.Context(), dbTransaction, id, toAttach, currentPeriod); err != nil { return err } } @@ -731,57 +766,90 @@ func (s projectflockService) GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID u return total, nil } -func (s projectflockService) GetFlockPeriodSummary(c *fiber.Ctx, projectFlockKandangID uint) (*FlockPeriodSummary, error) { - if projectFlockKandangID == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required") +// nextLocationPeriod computes the next period number for a given location +// based on the maximum period that has ever been used by any kandang in that location. +func (s projectflockService) nextLocationPeriod(ctx context.Context, tx *gorm.DB, locationID uint) (int, error) { + if locationID == 0 { + return 0, fiber.NewError(fiber.StatusBadRequest, "location_id is required to compute period") } - pivot, err := s.pivotRepo().GetByID(c.Context(), projectFlockKandangID) - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Project flock kandang not found") + db := s.Repository.DB() + if tx != nil { + db = tx } + + var maxPeriod int + if err := db.WithContext(ctx). + Table("project_flock_kandangs pfk"). + Joins("JOIN kandangs k ON k.id = pfk.kandang_id"). + Where("k.location_id = ?", locationID). + Select("COALESCE(MAX(pfk.period), 0)"). + Scan(&maxPeriod).Error; err != nil { + s.Log.Errorf("Failed to compute max period for location %d: %+v", locationID, err) + return 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to compute period for location") + } + + return maxPeriod + 1, nil +} + +func (s projectflockService) GetProjectPeriods(c *fiber.Ctx, projectIDs []uint) (map[uint]int, error) { + if len(projectIDs) == 0 { + return map[uint]int{}, nil + } + return s.pivotRepo().ProjectPeriodsByProjectIDs(c.Context(), projectIDs) +} + +func (s projectflockService) GetFlockPeriodSummary(c *fiber.Ctx, locationID uint) ([]KandangPeriodSummary, error) { + if locationID == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "location_id is required") + } + + exists, err := s.Repository.LocationExists(c.Context(), locationID) if err != nil { - s.Log.Errorf("Failed to fetch project_flock_kandang %d: %+v", projectFlockKandangID, err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang") + s.Log.Errorf("Failed to validate location %d: %+v", locationID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate location") + } + if !exists { + return nil, fiber.NewError(fiber.StatusNotFound, "Location not found") } - var baseName string - var referenceFlock *entity.Flock - if pivot.ProjectFlock.Id != 0 { - baseName = pfutils.DeriveBaseName(pivot.ProjectFlock.FlockName) + type kandangPeriodRow struct { + Id uint + Name string + LatestPeriod int } - if strings.TrimSpace(baseName) != "" { - referenceFlock, err = s.FlockRepo.GetByName(c.Context(), baseName) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - s.Log.Errorf("Failed to fetch flock %q: %+v", baseName, err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch flock") - } + var rows []kandangPeriodRow + + db := s.Repository.DB().WithContext(c.Context()) + if err := db. + Table("kandangs AS k"). + Select("k.id, k.name, COALESCE(MAX(pfk.period), 0) AS latest_period"). + Joins("LEFT JOIN project_flock_kandangs AS pfk ON pfk.kandang_id = k.id"). + Where("k.location_id = ?", locationID). + Where("k.deleted_at IS NULL"). + Group("k.id, k.name"). + Order("k.id ASC"). + Scan(&rows).Error; err != nil { + s.Log.Errorf("Failed to fetch kandang period summary for location %d: %+v", locationID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch kandang period summary") } - if referenceFlock == nil { - referenceFlock = &entity.Flock{Name: pivot.ProjectFlock.FlockName} - } - - maxPeriod := pivot.ProjectFlock.Period - if strings.TrimSpace(baseName) != "" { - if headerMax, err := s.Repository.GetMaxPeriodByBaseName(c.Context(), baseName); err != nil { - s.Log.Warnf("Unable to compute header period for base %q: %+v", baseName, err) - } else if headerMax > maxPeriod { - maxPeriod = headerMax + summaries := make([]KandangPeriodSummary, 0, len(rows)) + for _, row := range rows { + nextPeriod := 0 + if row.LatestPeriod > 0 { + nextPeriod = row.LatestPeriod + 1 } - if pivotMax, err := s.pivotRepo().MaxPeriodByBaseName(c.Context(), baseName); err != nil { - s.Log.Warnf("Unable to compute pivot period for base %q: %+v", baseName, err) - } else if pivotMax > maxPeriod { - maxPeriod = pivotMax - } + summaries = append(summaries, KandangPeriodSummary{ + Id: row.Id, + Name: row.Name, + Period: nextPeriod, + }) } - return &FlockPeriodSummary{ - Flock: *referenceFlock, - NextPeriod: maxPeriod + 1, - }, nil + return summaries, nil } func uniqueUintSlice(values []uint) []uint { @@ -857,7 +925,7 @@ func (s projectflockService) ensureFlockByName(ctx context.Context, actorID uint return newFlock, nil } -func (s projectflockService) attachKandangs(ctx context.Context, dbTransaction *gorm.DB, projectFlockID uint, kandangIDs []uint) error { +func (s projectflockService) attachKandangs(ctx context.Context, dbTransaction *gorm.DB, projectFlockID uint, kandangIDs []uint, period int) error { if len(kandangIDs) == 0 { return nil } @@ -895,6 +963,7 @@ func (s projectflockService) attachKandangs(ctx context.Context, dbTransaction * records = append(records, &entity.ProjectFlockKandang{ ProjectFlockId: projectFlockID, KandangId: id, + Period: period, }) } if err := s.pivotRepoWithTx(dbTransaction).CreateMany(ctx, records); err != nil { diff --git a/internal/modules/production/recordings/dto/recording.dto.go b/internal/modules/production/recordings/dto/recording.dto.go index e8d04758..cf4bdccd 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,31 +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"` -} - -type RecordingProductWarehouseDTO struct { - Id uint `json:"id"` - ProductId uint `json:"product_id"` - ProductName string `json:"product_name"` - WarehouseId uint `json:"warehouse_id"` - WarehouseName string `json:"warehouse_name"` + 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 RecordingEggGradingDTO struct { @@ -89,12 +84,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 +138,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 +178,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 +214,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 +223,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 +242,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 +268,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..62d64127 --- /dev/null +++ b/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go @@ -0,0 +1,182 @@ +package controller + +import ( + "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), + SourceProjectFlockId: uint(c.QueryInt("source_project_flock_id", 0)), + TargetProjectFlockId: uint(c.QueryInt("target_project_flock_id", 0)), + TransferDateFrom: c.Query("transfer_date_from", ""), + TransferDateTo: c.Query("transfer_date_to", ""), + ApprovalStatus: c.Query("approval_status", ""), + TransferNumber: c.Query("transfer_number", ""), + Sort: c.Query("sort", "created_at"), + Order: c.Query("order", "desc"), + } + + 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 + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.TransferLayingListDTO]{ + 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: dto.ToTransferLayingListDTOs(result), + }) +} + +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, "Invalid request body") + } + + 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.ToTransferLayingListDTO(*result), + }) +} + +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, + }) +} 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..5bc54762 --- /dev/null +++ b/internal/modules/production/transfer_layings/dto/transfer_laying.dto.go @@ -0,0 +1,253 @@ +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"` + 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"` + KandangId uint `json:"kandang_id"` +} + +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"` +} + +// === Mapper Functions === + +func ToProjectFlockSummaryDTO(pf *entity.ProjectFlock) *ProjectFlockSummaryDTO { + if pf == nil || pf.Id == 0 { + return nil + } + + return &ProjectFlockSummaryDTO{ + Id: pf.Id, + Category: pf.Category, + } +} + +func ToProjectFlockKandangSummaryDTO(pfk *entity.ProjectFlockKandang) *ProjectFlockKandangSummaryDTO { + if pfk == nil || pfk.Id == 0 { + return nil + } + + return &ProjectFlockKandangSummaryDTO{ + Id: pfk.Id, + KandangId: pfk.KandangId, + } +} + +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 + + // Use LatestApproval from entity if available + 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..13a0bc8f --- /dev/null +++ b/internal/modules/production/transfer_layings/route.go @@ -0,0 +1,30 @@ +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) +} 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..92965de0 --- /dev/null +++ b/internal/modules/production/transfer_layings/services/transfer_laying.service.go @@ -0,0 +1,727 @@ +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) +} + +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("Sources"). + Preload("Sources.SourceProjectFlockKandang"). + Preload("Sources.ProductWarehouse"). + Preload("Sources.ProductWarehouse.Product"). + Preload("Sources.ProductWarehouse.Warehouse"). + Preload("Targets"). + Preload("Targets.TargetProjectFlockKandang"). + 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) + if params.SourceProjectFlockId != 0 { + db = db.Where("from_project_flock_id = ?", params.SourceProjectFlockId) + } + if params.TargetProjectFlockId != 0 { + db = db.Where("to_project_flock_id = ?", params.TargetProjectFlockId) + } + if params.TransferDateFrom != "" { + db = db.Where("transfer_date >= ?", params.TransferDateFrom) + } + if params.TransferDateTo != "" { + db = db.Where("transfer_date <= ?", params.TransferDateTo) + } + if params.TransferNumber != "" { + db = db.Where("transfer_number ILIKE ?", "%"+params.TransferNumber+"%") + } + + sortField := "created_at" + if params.Sort != "" { + sortField = params.Sort + } + sortOrder := "DESC" + if params.Order == "asc" { + sortOrder = "ASC" + } + db = db.Order(fmt.Sprintf("%s %s", sortField, sortOrder)) + + return db + }) + + if err != nil { + s.Log.Errorf("Failed to get transferLayings: %+v", err) + return nil, 0, err + } + + if params.ApprovalStatus != "" { + var filtered []entity.LayingTransfer + approvalRepo := commonRepo.NewApprovalRepository(s.Repository.DB()) + + for _, transfer := range transferLayings { + latestApproval, err := approvalRepo.LatestByTarget(c.Context(), string(utils.ApprovalWorkflowTransferToLaying), transfer.Id, nil) + if err == nil && latestApproval != nil && latestApproval.Action != nil { + if string(*latestApproval.Action) == params.ApprovalStatus { + filtered = append(filtered, transfer) + } + } + } + transferLayings = filtered + } + + 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, nil) + 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 + } + + _, err := s.Repository.GetByID(c.Context(), id, nil) + 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)) + } + } + + updateBody := make(map[string]any) + + if req.TransferDate != nil { + updateBody["transfer_date"] = *req.TransferDate + } + + if req.Reason != nil { + updateBody["notes"] = *req.Reason + } + + 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, "TransferLaying not found") + } + s.Log.Errorf("Failed to update transferLaying: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update transfer laying") + } + + 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 remainingToRestore < pop.TotalQty { + + restoreAmount = remainingToRestore + } + + 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 +} 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..e35ba4f3 --- /dev/null +++ b/internal/modules/production/transfer_layings/validations/transfer_laying.validation.go @@ -0,0 +1,44 @@ +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,omitempty" validate:"omitempty,datetime=2006-01-02"` + Reason *string `json:"reason,omitempty" 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"` + SourceProjectFlockId uint `query:"source_project_flock_id" validate:"omitempty"` + TargetProjectFlockId uint `query:"target_project_flock_id" validate:"omitempty"` + TransferDateFrom string `query:"transfer_date_from" validate:"omitempty,datetime=2006-01-02"` + TransferDateTo string `query:"transfer_date_to" validate:"omitempty,datetime=2006-01-02"` + ApprovalStatus string `query:"approval_status" validate:"omitempty,oneof=PENDING APPROVED REJECTED"` // Filter by latest approval status + TransferNumber string `query:"transfer_number" validate:"omitempty"` // Search by transfer number + Sort string `query:"sort" validate:"omitempty,oneof=created_at transfer_date"` // Sort by field + Order string `query:"order" validate:"omitempty,oneof=asc desc"` // Sort order +} + +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..d10f42af --- /dev/null +++ b/internal/modules/purchases/controllers/purchase.controller.go @@ -0,0 +1,237 @@ +package controller + +import ( + "fmt" + "math" + "strconv" + "strings" + + "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) GetAll(c *fiber.Ctx) error { + query := &validation.PurchaseQuery{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: strings.TrimSpace(c.Query("search")), + PrNumber: strings.TrimSpace(c.Query("pr_number")), + CreatedFrom: strings.TrimSpace(c.Query("created_from")), + CreatedTo: strings.TrimSpace(c.Query("created_to")), + } + + if supplierID := c.QueryInt("supplier_id", 0); supplierID > 0 { + query.SupplierID = uint(supplierID) + } + + if status := strings.TrimSpace(c.Query("status")); status != "" { + query.Status = strings.ToUpper(status) + } + + results, total, err := ctrl.service.GetAll(c, query) + if err != nil { + return err + } + + limit := query.Limit + if limit <= 0 { + limit = 10 + } + page := query.Page + if page <= 0 { + page = 1 + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.PurchaseListItemDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Purchase fetched successfully", + Meta: response.Meta{ + Page: page, + Limit: limit, + TotalPages: int64(math.Ceil(float64(total) / float64(limit))), + TotalResults: total, + }, + Data: dto.ToPurchaseListDTOs(results), + }) +} + +func (ctrl *PurchaseController) GetOne(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 id") + } + + result, err := ctrl.service.GetOne(c, id) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Purchase fetched successfully", + Data: dto.ToPurchaseDetailDTO(*result), + }) +} + +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 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 id") + } + + req := new(validation.ApproveStaffPurchaseRequest) + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid request body: %v", err)) + } + + 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), + }) +} + + +func (ctrl *PurchaseController) ApproveManagerPurchase(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 id") + } + + req := new(validation.ApproveManagerPurchaseRequest) + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := ctrl.service.ApproveManagerPurchase(c, id, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Manager purchase approval recorded successfully", + Data: dto.ToPurchaseDetailDTO(*result), + }) +} + +func (ctrl *PurchaseController) ReceiveProducts(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 id") + } + + req := new(validation.ReceivePurchaseRequest) + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := ctrl.service.ReceiveProducts(c, id, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Purchase receiving recorded successfully", + Data: dto.ToPurchaseDetailDTO(*result), + }) +} + +func (ctrl *PurchaseController) DeleteItems(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 id") + } + + req := new(validation.DeletePurchaseItemsRequest) + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := ctrl.service.DeleteItems(c, id, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Purchase items deleted successfully", + Data: dto.ToPurchaseDetailDTO(*result), + }) +} + +func (ctrl *PurchaseController) DeletePurchase(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 id") + } + + if err := ctrl.service.DeletePurchase(c, id); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Purchase deleted successfully", + Data: nil, + }) +} diff --git a/internal/modules/purchases/dto/purchase.dto.go b/internal/modules/purchases/dto/purchase.dto.go new file mode 100644 index 00000000..5a2926cf --- /dev/null +++ b/internal/modules/purchases/dto/purchase.dto.go @@ -0,0 +1,173 @@ +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" + locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto" + productDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/dto" + supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto" + warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" +) + +type PurchaseListItemDTO struct { + Id uint64 `json:"id"` + PrNumber string `json:"pr_number"` + PoNumber *string `json:"po_number"` + Supplier *supplierDTO.SupplierBaseDTO `json:"supplier"` + CreditTerm *int `json:"credit_term"` + DueDate *time.Time `json:"due_date"` + PoDate *time.Time `json:"po_date"` + GrandTotal float64 `json:"grand_total"` + Notes *string `json:"notes"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Approval *approvalDTO.ApprovalBaseDTO `json:"approval"` +} + +type PurchaseDetailDTO struct { + Id uint64 `json:"id"` + PrNumber string `json:"pr_number"` + PoNumber *string `json:"po_number"` + Supplier *supplierDTO.SupplierBaseDTO `json:"supplier"` + CreditTerm *int `json:"credit_term"` + DueDate *time.Time `json:"due_date"` + PoDate *time.Time `json:"po_date"` + GrandTotal float64 `json:"grand_total"` + Notes *string `json:"notes"` + Items []PurchaseItemDTO `json:"items"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Approval *approvalDTO.ApprovalBaseDTO `json:"approval"` +} + +type PurchaseItemDTO struct { + Id uint64 `json:"id"` + ProductID uint64 `json:"product_id"` + Product *productDTO.ProductBaseDTO `json:"product"` + WarehouseID uint64 `json:"warehouse_id"` + Warehouse *warehouseDTO.WarehouseBaseDTO `json:"warehouse"` + ProductWarehouseID *uint64 `json:"product_warehouse_id"` + SubQty float64 `json:"sub_qty"` + TotalQty float64 `json:"total_qty"` + TotalUsed float64 `json:"total_used"` + Price float64 `json:"price"` + TotalPrice float64 `json:"total_price"` + ReceivedDate *time.Time `json:"received_date"` + TravelNumber *string `json:"travel_number"` + TravelDocumentPath *string `json:"travel_document_path"` + VehicleNumber *string `json:"vehicle_number"` +} + +func ToPurchaseItemDTO(item entity.PurchaseItem) PurchaseItemDTO { + dto := PurchaseItemDTO{ + Id: item.Id, + ProductID: item.ProductId, + ProductWarehouseID: item.ProductWarehouseId, + WarehouseID: item.WarehouseId, + SubQty: item.SubQty, + TotalQty: item.TotalQty, + TotalUsed: item.TotalUsed, + Price: item.Price, + TotalPrice: item.TotalPrice, + ReceivedDate: item.ReceivedDate, + TravelNumber: item.TravelNumber, + TravelDocumentPath: item.TravelNumberDocs, + VehicleNumber: item.VehicleNumber, + } + if item.Product != nil && item.Product.Id != 0 { + summary := productDTO.ToProductBaseDTO(*item.Product) + dto.Product = &summary + } + if item.Warehouse != nil && item.Warehouse.Id != 0 { + summary := warehouseDTO.ToWarehouseBaseDTO(*item.Warehouse) + if item.Warehouse.Area.Id != 0 { + areaSummary := areaDTO.ToAreaBaseDTO(item.Warehouse.Area) + summary.Area = &areaSummary + } + if item.Warehouse.Location != nil && item.Warehouse.Location.Id != 0 { + locationSummary := locationDTO.ToLocationBaseDTO(*item.Warehouse.Location) + summary.Location = &locationSummary + } + dto.Warehouse = &summary + } + 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 { + dto := PurchaseDetailDTO{ + Id: p.Id, + PrNumber: p.PrNumber, + PoNumber: p.PoNumber, + Supplier: mapSupplier(p.Supplier), + CreditTerm: p.CreditTerm, + DueDate: p.DueDate, + PoDate: p.PoDate, + GrandTotal: p.GrandTotal, + Notes: p.Notes, + Items: ToPurchaseItemDTOs(p.Items), + CreatedAt: p.CreatedAt, + UpdatedAt: p.UpdatedAt, + } + if approval := toPurchaseApprovalDTO(p); approval != nil { + dto.Approval = approval + } + return dto +} + +func ToPurchaseListDTO(p entity.Purchase) PurchaseListItemDTO { + dto := PurchaseListItemDTO{ + Id: p.Id, + PrNumber: p.PrNumber, + PoNumber: p.PoNumber, + Supplier: mapSupplier(p.Supplier), + CreditTerm: p.CreditTerm, + DueDate: p.DueDate, + PoDate: p.PoDate, + GrandTotal: p.GrandTotal, + Notes: p.Notes, + CreatedAt: p.CreatedAt, + UpdatedAt: p.UpdatedAt, + } + if approval := toPurchaseApprovalDTO(p); approval != nil { + dto.Approval = approval + } + return dto +} + +func mapSupplier(s entity.Supplier) *supplierDTO.SupplierBaseDTO { + if s.Id == 0 { + return nil + } + summary := supplierDTO.ToSupplierBaseDTO(s) + return &summary +} + +func ToPurchaseListDTOs(items []entity.Purchase) []PurchaseListItemDTO { + if len(items) == 0 { + return nil + } + result := make([]PurchaseListItemDTO, len(items)) + for i, item := range items { + result[i] = ToPurchaseListDTO(item) + } + return result +} + +func toPurchaseApprovalDTO(p entity.Purchase) *approvalDTO.ApprovalBaseDTO { + if p.LatestApproval == nil || p.LatestApproval.Id == 0 { + return nil + } + mapped := approvalDTO.ToApprovalDTO(*p.LatestApproval) + return &mapped +} diff --git a/internal/modules/purchases/module.go b/internal/modules/purchases/module.go new file mode 100644 index 00000000..1911e364 --- /dev/null +++ b/internal/modules/purchases/module.go @@ -0,0 +1,55 @@ +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" + rProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/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" + 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" +) + +type PurchaseModule struct{} + +func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + purchaseRepo := rPurchase.NewPurchaseRepository(db) + productRepo := rProduct.NewProductRepository(db) + warehouseRepo := rWarehouse.NewWarehouseRepository(db) + supplierRepo := rSupplier.NewSupplierRepository(db) + productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(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)) + } + expenseBridge := service.NewNoopPurchaseExpenseBridge() + + purchaseService := service.NewPurchaseService( + validate, + purchaseRepo, + productRepo, + warehouseRepo, + supplierRepo, + productWarehouseRepo, + approvalRepo, + approvalService, + expenseBridge, + ) + + userRepo := rUser.NewUserRepository(db) + userService := sUser.NewUserService(userRepo, validate) + + Routes(router, purchaseService, userService) +} diff --git a/internal/modules/purchases/repositories/purchase.repository.go b/internal/modules/purchases/repositories/purchase.repository.go new file mode 100644 index 00000000..bc1c038a --- /dev/null +++ b/internal/modules/purchases/repositories/purchase.repository.go @@ -0,0 +1,423 @@ +package repositories + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + utils "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type PurchaseRepository interface { + repository.BaseRepository[entity.Purchase] + CreateWithItems(ctx context.Context, purchase *entity.Purchase, items []*entity.PurchaseItem) error + CreateItems(ctx context.Context, purchaseID uint64, items []*entity.PurchaseItem) error + GetByIDWithRelations(ctx context.Context, id uint64) (*entity.Purchase, error) + GetAllWithFilters(ctx context.Context, offset, limit int, filter *PurchaseListFilter) ([]entity.Purchase, int64, error) + UpdatePricing(ctx context.Context, purchaseID uint64, updates []PurchasePricingUpdate, grandTotal float64) error + UpdateReceivingDetails(ctx context.Context, purchaseID uint64, updates []PurchaseReceivingUpdate) error + DeleteItems(ctx context.Context, purchaseID uint64, itemIDs []uint64) error + WithListRelations() func(*gorm.DB) *gorm.DB + UpdateGrandTotal(ctx context.Context, purchaseID uint64, grandTotal float64) error + NextPrNumber(ctx context.Context, tx *gorm.DB) (string, error) + NextPoNumber(ctx context.Context, tx *gorm.DB) (string, error) +} + +type PurchaseRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Purchase] +} + +func NewPurchaseRepository(db *gorm.DB) PurchaseRepository { + return &PurchaseRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Purchase](db), + } +} + +type PurchaseListFilter struct { + SupplierID uint + Search string + PrNumber string + CreatedFrom *time.Time + CreatedTo *time.Time + Status *entity.ApprovalAction + CompletedOnly bool +} + +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) CreateItems(ctx context.Context, purchaseID uint64, items []*entity.PurchaseItem) error { + if len(items) == 0 { + return nil + } + + for _, item := range items { + if item == nil { + continue + } + item.PurchaseId = purchaseID + } + + return r.DB().WithContext(ctx).Create(&items).Error +} + +func (r *PurchaseRepositoryImpl) GetByIDWithRelations(ctx context.Context, id uint64) (*entity.Purchase, error) { + var purchase entity.Purchase + err := r.DB().WithContext(ctx). + Scopes(r.withDetailRelations). + First(&purchase, id).Error + if err != nil { + return nil, err + } + return &purchase, nil +} + +func (r *PurchaseRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, filter *PurchaseListFilter) ([]entity.Purchase, int64, error) { + return r.GetAll(ctx, offset, limit, func(db *gorm.DB) *gorm.DB { + db = r.withListRelations(db) + return r.applyListFilters(db, filter) + }) +} + +func (r *PurchaseRepositoryImpl) WithListRelations() func(*gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + return r.withListRelations(db) + } +} + +func (r *PurchaseRepositoryImpl) withDetailRelations(db *gorm.DB) *gorm.DB { + return db. + 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") +} + +func (r *PurchaseRepositoryImpl) WithDetailRelations() func(*gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + return r.withDetailRelations(db) + } +} + +type PurchasePricingUpdate struct { + ItemID uint64 + ProductID *uint64 + Price float64 + TotalPrice float64 + Quantity *float64 + TotalQty *float64 +} + +type PurchaseReceivingUpdate struct { + ItemID uint64 + ReceivedDate *time.Time + TravelNumber *string + TravelDocumentPath *string + VehicleNumber *string + ReceivedQty *float64 + WarehouseID *uint + ProductWarehouseID *uint + ClearProductWarehouse bool +} + +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 { + data := map[string]interface{}{ + "price": upd.Price, + "total_price": upd.TotalPrice, + } + if upd.ProductID != nil { + data["product_id"] = *upd.ProductID + } + if upd.Quantity != nil { + data["sub_qty"] = *upd.Quantity + } + if upd.TotalQty != nil { + data["total_qty"] = *upd.TotalQty + } + + result := db.Model(&entity.PurchaseItem{}). + Where("purchase_id = ? AND id = ?", purchaseID, upd.ItemID). + Updates(data) + 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 +} + +func (r *PurchaseRepositoryImpl) UpdateReceivingDetails( + ctx context.Context, + purchaseID uint64, + updates []PurchaseReceivingUpdate, +) error { + if len(updates) == 0 { + return errors.New("receiving updates cannot be empty") + } + + db := r.DB().WithContext(ctx) + + for _, upd := range updates { + data := map[string]interface{}{} + + if upd.ReceivedDate != nil { + data["received_date"] = upd.ReceivedDate + } + if upd.TravelNumber != nil { + data["travel_number"] = upd.TravelNumber + } + if upd.TravelDocumentPath != nil { + data["travel_number_docs"] = upd.TravelDocumentPath + } + if upd.VehicleNumber != nil { + data["vehicle_number"] = upd.VehicleNumber + } + if upd.ReceivedQty != nil { + data["total_qty"] = upd.ReceivedQty + } + if upd.WarehouseID != nil && *upd.WarehouseID != 0 { + data["warehouse_id"] = upd.WarehouseID + } + + if upd.ProductWarehouseID != nil { + data["product_warehouse_id"] = *upd.ProductWarehouseID + } else if upd.ClearProductWarehouse { + data["product_warehouse_id"] = gorm.Expr("NULL") + } + + if len(data) == 0 { + continue + } + + result := db.Model(&entity.PurchaseItem{}). + Where("purchase_id = ? AND id = ?", purchaseID, upd.ItemID). + Updates(data) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + } + + return nil +} + +func (r *PurchaseRepositoryImpl) UpdateGrandTotal( + ctx context.Context, + purchaseID uint64, + grandTotal float64, +) error { + return r.DB().WithContext(ctx). + Model(&entity.Purchase{}). + Where("id = ?", purchaseID). + Updates(map[string]interface{}{ + "grand_total": grandTotal, + "updated_at": gorm.Expr("NOW()"), + }).Error +} + +func (r *PurchaseRepositoryImpl) DeleteItems(ctx context.Context, purchaseID uint64, itemIDs []uint64) error { + if len(itemIDs) == 0 { + return errors.New("itemIDs cannot be empty") + } + + return r.DB().WithContext(ctx). + Where("purchase_id = ? AND id IN ?", purchaseID, itemIDs). + Delete(&entity.PurchaseItem{}).Error +} + +func (r *PurchaseRepositoryImpl) NextPrNumber(ctx context.Context, tx *gorm.DB) (string, error) { + return r.generateSequentialNumber(ctx, tx, "pr_number", utils.PurchasePRNumberPrefix, utils.PurchaseNumberPadding) +} + +func (r *PurchaseRepositoryImpl) NextPoNumber(ctx context.Context, tx *gorm.DB) (string, error) { + return r.generateSequentialNumber(ctx, tx, "po_number", utils.PurchasePONumberPrefix, utils.PurchaseNumberPadding) +} + +func (r *PurchaseRepositoryImpl) generateSequentialNumber(ctx context.Context, tx *gorm.DB, column, prefix string, padding int) (string, error) { + db := tx + if db == nil { + db = r.DB() + } + + var values []string + err := db.WithContext(ctx). + Model(&entity.Purchase{}). + Where(fmt.Sprintf("%s LIKE ?", column), prefix+"%"). + Select(column). + Order(fmt.Sprintf("%s DESC", column)). + Limit(20). + Clauses(clause.Locking{Strength: "UPDATE"}). + Pluck(column, &values).Error + if err != nil { + return "", err + } + + next := 1 + for _, value := range values { + if number, ok := parseNumericSuffix(value, prefix); ok { + next = number + 1 + break + } + } + + const maxAttempts = 20 + for attempt := 0; attempt < maxAttempts; attempt++ { + candidate := fmt.Sprintf("%s%0*d", prefix, padding, next) + exists, err := r.numberExists(ctx, db, column, candidate) + if err != nil { + return "", err + } + if !exists { + return candidate, nil + } + next++ + } + + return "", fmt.Errorf("unable to generate unique %s", column) +} + +func (r *PurchaseRepositoryImpl) numberExists(ctx context.Context, db *gorm.DB, column, value string) (bool, error) { + var count int64 + if err := db.WithContext(ctx). + Model(&entity.Purchase{}). + Where(fmt.Sprintf("%s = ?", column), value). + Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} + +func parseNumericSuffix(value, prefix string) (int, bool) { + if !strings.HasPrefix(value, prefix) { + return 0, false + } + suffix := strings.TrimPrefix(value, prefix) + if suffix == "" { + return 0, false + } + trimmed := strings.TrimLeft(suffix, "0") + if trimmed == "" { + trimmed = "0" + } + number, err := strconv.Atoi(trimmed) + if err != nil { + return 0, false + } + return number, true +} + +func (r *PurchaseRepositoryImpl) withListRelations(db *gorm.DB) *gorm.DB { + return db.Preload("Supplier") +} + +func (r *PurchaseRepositoryImpl) applyListFilters(db *gorm.DB, filter *PurchaseListFilter) *gorm.DB { + if filter == nil { + return db + } + + if filter.SupplierID > 0 { + db = db.Where("purchases.supplier_id = ?", filter.SupplierID) + } + + if search := strings.ToLower(strings.TrimSpace(filter.Search)); search != "" { + like := "%" + search + "%" + db = db.Where("(LOWER(purchases.pr_number) LIKE ? OR LOWER(COALESCE(purchases.notes, '')) LIKE ?)", like, like) + } + + if pr := strings.TrimSpace(filter.PrNumber); pr != "" { + db = db.Where("purchases.pr_number ILIKE ?", "%"+pr+"%") + } + + if filter.CreatedFrom != nil { + db = db.Where("purchases.created_at >= ?", *filter.CreatedFrom) + } + + if filter.CreatedTo != nil { + db = db.Where("purchases.created_at < ?", *filter.CreatedTo) + } + + if filter.CompletedOnly { + step := uint16(utils.PurchaseStepCompleted) + db = r.applyLatestApprovalFilter(db, entity.ApprovalActionApproved, &step) + } else if filter.Status != nil { + db = r.applyLatestApprovalFilter(db, *filter.Status, nil) + } + + return db.Order("purchases.created_at DESC").Order("purchases.id DESC") +} + +func (r *PurchaseRepositoryImpl) applyLatestApprovalFilter(db *gorm.DB, action entity.ApprovalAction, minStep *uint16) *gorm.DB { + latestSub := r.DB(). + Model(&entity.Approval{}). + Select("approvable_id, MAX(action_at) AS latest_action_at"). + Where("approvable_type = ?", utils.ApprovalWorkflowPurchase.String()). + Group("approvable_id") + + db = db. + Joins("LEFT JOIN (?) AS latest_purchase_approvals ON latest_purchase_approvals.approvable_id = purchases.id", latestSub). + Joins( + "LEFT JOIN approvals ON approvals.approvable_id = purchases.id AND approvals.approvable_type = ? AND approvals.action_at = latest_purchase_approvals.latest_action_at", + utils.ApprovalWorkflowPurchase.String(), + ). + Where("approvals.action = ?", string(action)) + if minStep != nil { + db = db.Where("approvals.step_number >= ?", *minStep) + } + return db +} diff --git a/internal/modules/purchases/route.go b/internal/modules/purchases/route.go new file mode 100644 index 00000000..aedc3ee8 --- /dev/null +++ b/internal/modules/purchases/route.go @@ -0,0 +1,26 @@ +package purchases + +import ( + "github.com/gofiber/fiber/v2" + + middleware "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/controllers" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +func Routes(router fiber.Router, purchaseService service.PurchaseService, userService user.UserService) { + ctrl := controller.NewPurchaseController(purchaseService) + + route := router.Group("/purchases") + route.Use(middleware.Auth(userService)) + + route.Get("/", ctrl.GetAll) + route.Get("/:id", ctrl.GetOne) + route.Post("/", ctrl.CreateOne) + route.Post("/:id/approvals/staff", ctrl.ApproveStaffPurchase) + route.Post("/:id/approvals/manager", ctrl.ApproveManagerPurchase) + route.Post("/:id/receipts", ctrl.ReceiveProducts) + route.Delete("/:id", ctrl.DeletePurchase) + route.Delete("/:id/items", ctrl.DeleteItems) +} diff --git a/internal/modules/purchases/services/expense_bridge.go b/internal/modules/purchases/services/expense_bridge.go new file mode 100644 index 00000000..b7c96d03 --- /dev/null +++ b/internal/modules/purchases/services/expense_bridge.go @@ -0,0 +1,43 @@ +package service + +import ( + "context" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" +) + +// PurchaseExpenseBridge defines hooks that allow purchase flows to stay in sync with expense data once it exists. +type PurchaseExpenseBridge interface { + OnItemsCreated(ctx context.Context, purchaseID uint64, items []entity.PurchaseItem) error + OnItemsDeleted(ctx context.Context, purchaseID uint64, itemIDs []uint64) error + OnItemsReceived(ctx context.Context, purchaseID uint64, updates []ExpenseReceivingPayload) error +} + +// ExpenseReceivingPayload captures the minimum data expense integration will need once available. +type ExpenseReceivingPayload struct { + PurchaseItemID uint64 + ProductID uint64 + WarehouseID uint64 + ReceivedQty float64 + ReceivedDate *time.Time +} + +// noopPurchaseExpenseBridge is the default implementation until the expense module is ready. +type noopPurchaseExpenseBridge struct{} + +func NewNoopPurchaseExpenseBridge() PurchaseExpenseBridge { + return &noopPurchaseExpenseBridge{} +} + +func (n *noopPurchaseExpenseBridge) OnItemsCreated(_ context.Context, _ uint64, _ []entity.PurchaseItem) error { + return nil +} + +func (n *noopPurchaseExpenseBridge) OnItemsDeleted(_ context.Context, _ uint64, _ []uint64) error { + return nil +} + +func (n *noopPurchaseExpenseBridge) OnItemsReceived(_ context.Context, _ uint64, _ []ExpenseReceivingPayload) error { + return nil +} diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go new file mode 100644 index 00000000..7f694a12 --- /dev/null +++ b/internal/modules/purchases/services/purchase.service.go @@ -0,0 +1,1315 @@ +package service + +import ( + "context" + "errors" + "fmt" + "math" + "strings" + "time" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + rProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/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" + 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 PurchaseService interface { + GetAll(ctx *fiber.Ctx, params *validation.PurchaseQuery) ([]entity.Purchase, int64, error) + GetOne(ctx *fiber.Ctx, id uint64) (*entity.Purchase, error) + CreateOne(ctx *fiber.Ctx, req *validation.CreatePurchaseRequest) (*entity.Purchase, error) + ApproveStaffPurchase(ctx *fiber.Ctx, id uint64, req *validation.ApproveStaffPurchaseRequest) (*entity.Purchase, error) + ApproveManagerPurchase(ctx *fiber.Ctx, id uint64, req *validation.ApproveManagerPurchaseRequest) (*entity.Purchase, error) + ReceiveProducts(ctx *fiber.Ctx, id uint64, req *validation.ReceivePurchaseRequest) (*entity.Purchase, error) + DeleteItems(ctx *fiber.Ctx, id uint64, req *validation.DeletePurchaseItemsRequest) (*entity.Purchase, error) + DeletePurchase(ctx *fiber.Ctx, id uint64) error +} + +const ( + priceTolerance = 0.0001 + queryDateLayout = "2006-01-02" +) + +type purchaseService struct { + Log *logrus.Logger + Validate *validator.Validate + PurchaseRepo rPurchase.PurchaseRepository + ProductRepo rProduct.ProductRepository + WarehouseRepo rWarehouse.WarehouseRepository + SupplierRepo rSupplier.SupplierRepository + ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository + ApprovalRepo commonRepo.ApprovalRepository + ApprovalSvc commonSvc.ApprovalService + ExpenseBridge PurchaseExpenseBridge +} + +type staffAdjustmentPayload struct { + PricingUpdates []rPurchase.PurchasePricingUpdate + NewItems []*entity.PurchaseItem + GrandTotal float64 +} + +func NewPurchaseService( + validate *validator.Validate, + purchaseRepo rPurchase.PurchaseRepository, + productRepo rProduct.ProductRepository, + warehouseRepo rWarehouse.WarehouseRepository, + supplierRepo rSupplier.SupplierRepository, + productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, + approvalRepo commonRepo.ApprovalRepository, + approvalSvc commonSvc.ApprovalService, + expenseBridge PurchaseExpenseBridge, +) PurchaseService { + if expenseBridge == nil { + expenseBridge = NewNoopPurchaseExpenseBridge() + } + return &purchaseService{ + Log: utils.Log, + Validate: validate, + PurchaseRepo: purchaseRepo, + ProductRepo: productRepo, + WarehouseRepo: warehouseRepo, + SupplierRepo: supplierRepo, + ProductWarehouseRepo: productWarehouseRepo, + ApprovalRepo: approvalRepo, + ApprovalSvc: approvalSvc, + ExpenseBridge: expenseBridge, + } +} + +func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.PurchaseQuery) ([]entity.Purchase, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + limit := params.Limit + if limit <= 0 { + limit = 10 + } + page := params.Page + if page <= 0 { + page = 1 + } + offset := (page - 1) * limit + + ctx := c.Context() + + createdFrom, createdTo, err := parseQueryDates(params.CreatedFrom, params.CreatedTo) + if err != nil { + return nil, 0, err + } + + statusAction, completedOnly, err := parseApprovalAction(params.Status) + if err != nil { + return nil, 0, err + } + + filter := &rPurchase.PurchaseListFilter{ + SupplierID: params.SupplierID, + Search: params.Search, + PrNumber: params.PrNumber, + CreatedFrom: createdFrom, + CreatedTo: createdTo, + Status: statusAction, + CompletedOnly: completedOnly, + } + + purchases, total, err := s.PurchaseRepo.GetAllWithFilters(ctx, offset, limit, filter) + if err != nil { + s.Log.Errorf("Failed to get purchases: %+v", err) + return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchases") + } + + if err := s.attachLatestApprovals(ctx, purchases); err != nil { + s.Log.Warnf("Unable to attach latest approvals to purchases: %+v", err) + } + + return purchases, total, nil +} + +func (s *purchaseService) GetOne(c *fiber.Ctx, id uint64) (*entity.Purchase, error) { + if id == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid purchase id") + } + + 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 not found") + } + s.Log.Errorf("Failed to get purchase: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase") + } + + if err := s.attachLatestApproval(ctx, purchase); err != nil { + s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", id, err) + } + + return purchase, nil +} + +func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchaseRequest) (*entity.Purchase, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + ctx := c.Context() + + if _, err := s.SupplierRepo.GetByID(ctx, req.SupplierID, nil); 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") + } + + type aggregatedItem struct { + productId uint64 + warehouseId uint64 + subQty float64 + } + + if len(req.Items) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Items must not be empty") + } + + warehouseCache := make(map[uint]*entity.Warehouse) + productSupplierCache := make(map[uint]bool) + + getWarehouse := func(id uint) (*entity.Warehouse, error) { + if warehouse, ok := warehouseCache[id]; ok { + return warehouse, nil + } + + warehouse, err := s.WarehouseRepo.GetDetailByID(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Warehouse %d not found", id)) + } + s.Log.Errorf("Failed to get warehouse %d: %+v", id, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get warehouse") + } + + warehouseCache[id] = warehouse + return warehouse, nil + } + + aggregated := make([]*aggregatedItem, 0, len(req.Items)) + indexMap := make(map[string]int) + + for _, item := range req.Items { + if _, err := getWarehouse(item.WarehouseID); err != nil { + return nil, err + } + + if _, checked := productSupplierCache[item.ProductID]; !checked { + linked, err := s.ProductRepo.IsLinkedToSupplier(ctx, item.ProductID, req.SupplierID) + if err != nil { + s.Log.Errorf("Failed to validate product %d for supplier %d: %+v", item.ProductID, req.SupplierID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product for supplier") + } + if !linked { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Product %d is not linked to supplier %d", item.ProductID, req.SupplierID)) + } + productSupplierCache[item.ProductID] = true + } + + productId := uint64(item.ProductID) + warehouseId := uint64(item.WarehouseID) + + 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, + subQty: item.Quantity, + } + aggregated = append(aggregated, entry) + indexMap[key] = len(aggregated) - 1 + } + + creditTermValue := req.CreditTerm + creditTerm := &creditTermValue + dueDateValue := time.Now().UTC().AddDate(0, 0, creditTermValue) + dueDate := &dueDateValue + + purchase := &entity.Purchase{ + 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, + SubQty: item.subQty, + TotalQty: 0, + TotalUsed: 0, + Price: 0, + TotalPrice: 0, + }) + } + + transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + purchaseRepoTx := rPurchase.NewPurchaseRepository(tx) + + code, err := purchaseRepoTx.NextPrNumber(ctx, tx) + if err != nil { + return err + } + purchase.PrNumber = code + + if err := purchaseRepoTx.CreateWithItems(ctx, purchase, items); err != nil { + return err + } + + actorID := uint(purchase.CreatedBy) + if actorID == 0 { + actorID = 1 + } + action := entity.ApprovalActionCreated + if err := s.createPurchaseApproval(ctx, tx, purchase.Id, utils.PurchaseStepPengajuan, action, actorID, nil, false); err != nil { + return err + } + + return nil + }) + if transactionErr != nil { + s.Log.Errorf("Failed to create purchase: %+v", transactionErr) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create purchase") + } + + created, err := s.PurchaseRepo.GetByIDWithRelations(ctx, purchase.Id) + if err != nil { + s.Log.Errorf("Failed to load created purchase: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase") + } + + if err := s.attachLatestApproval(ctx, created); err != nil { + s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", created.Id, err) + } + + s.notifyExpenseItemsCreated(ctx, created.Id, created.Items) + + return created, nil +} + +func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint64, req *validation.ApproveStaffPurchaseRequest) (*entity.Purchase, error) { + return s.processStaffPurchaseApproval(c, id, req, false) +} + +func (s *purchaseService) processStaffPurchaseApproval(c *fiber.Ctx, id uint64, req *validation.ApproveStaffPurchaseRequest, requireStaffApproval bool) (*entity.Purchase, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + actorID := uint(1) // TODO: replace with authenticated user id once available + + 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 not found") + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase") + } + + if err := s.attachLatestApproval(ctx, purchase); err != nil { + s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err) + } + + var latestStep uint16 + if purchase.LatestApproval != nil { + latestStep = purchase.LatestApproval.StepNumber + } + + if requireStaffApproval && latestStep < uint16(utils.PurchaseStepStaffPurchase) { + return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase cannot be edited before staff approval") + } + + isInitialApproval := latestStep < uint16(utils.PurchaseStepStaffPurchase) + if isInitialApproval && latestStep != uint16(utils.PurchaseStepPengajuan) { + return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase is not ready for staff approval") + } + + hasReceivingData := false + for _, item := range purchase.Items { + if item.TotalQty > 0 || item.TotalUsed > 0 { + hasReceivingData = true + break + } + } + + syncReceiving := !isInitialApproval && hasReceivingData + + payload, err := s.buildStaffAdjustmentPayload(ctx, purchase, req, syncReceiving) + if err != nil { + return nil, err + } + + transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + purchaseRepoTx := rPurchase.NewPurchaseRepository(tx) + grandTotalUpdated := false + if len(payload.PricingUpdates) > 0 { + if err := purchaseRepoTx.UpdatePricing(ctx, purchase.Id, payload.PricingUpdates, payload.GrandTotal); err != nil { + return err + } + grandTotalUpdated = true + } + + if len(payload.NewItems) > 0 { + if err := purchaseRepoTx.CreateItems(ctx, purchase.Id, payload.NewItems); err != nil { + return err + } + } + + if !grandTotalUpdated { + if err := purchaseRepoTx.UpdateGrandTotal(ctx, purchase.Id, payload.GrandTotal); err != nil { + return err + } + } + + if isInitialApproval { + action := entity.ApprovalActionApproved + if err := s.createPurchaseApproval(ctx, tx, purchase.Id, utils.PurchaseStepStaffPurchase, action, actorID, req.Notes, false); err != nil { + return err + } + return nil + } + + if len(payload.PricingUpdates) > 0 || len(payload.NewItems) > 0 { + approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) + if approvalSvc != nil { + latest, err := approvalSvc.LatestByTarget(ctx, utils.ApprovalWorkflowPurchase, uint(purchase.Id), nil) + if err != nil { + return err + } + + shouldRecordStaffUpdate := latest == nil || + latest.StepNumber != uint16(utils.PurchaseStepStaffPurchase) || + latest.Action == nil || + (latest.Action != nil && *latest.Action != entity.ApprovalActionUpdated) + + if shouldRecordStaffUpdate { + action := entity.ApprovalActionUpdated + 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") + } + if isInitialApproval { + s.Log.Errorf("Failed to approve purchase %d: %+v", purchase.Id, transactionErr) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to approve purchase") + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update purchase pricing") + } + + updated, err := s.PurchaseRepo.GetByIDWithRelations(ctx, purchase.Id) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase") + } + if err := s.attachLatestApproval(ctx, updated); err != nil { + s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err) + } + + if len(payload.NewItems) > 0 { + newItems := make([]entity.PurchaseItem, len(payload.NewItems)) + for i, item := range payload.NewItems { + if item == nil { + continue + } + newItems[i] = *item + } + s.notifyExpenseItemsCreated(ctx, purchase.Id, newItems) + } + + return updated, nil +} + +func (s *purchaseService) ApproveManagerPurchase(c *fiber.Ctx, id uint64, req *validation.ApproveManagerPurchaseRequest) (*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 not found") + } + s.Log.Errorf("Failed to get purchase: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase") + } + + if err := s.attachLatestApproval(ctx, purchase); err != nil { + s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err) + } + + if purchase.LatestApproval == nil || + purchase.LatestApproval.StepNumber < uint16(utils.PurchaseStepStaffPurchase) { + return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase must reach staff purchase step before manager approval") + } + + actorID := uint(1) + action := entity.ApprovalActionApproved + now := time.Now().UTC() + hasExistingPO := purchase.PoNumber != nil && strings.TrimSpace(*purchase.PoNumber) != "" + var generatedNumber string + + transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + updateData := map[string]any{} + if !hasExistingPO { + repoTx := rPurchase.NewPurchaseRepository(tx) + code, err := repoTx.NextPoNumber(ctx, tx) + if err != nil { + return err + } + updateData["po_number"] = code + updateData["po_date"] = now + generatedNumber = code + } + + if len(updateData) > 0 { + repoTx := rPurchase.NewPurchaseRepository(tx) + if err := repoTx.PatchOne(ctx, uint(purchase.Id), updateData, nil); err != nil { + return err + } + } + + forceManagerApproval := false + approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) + if approvalSvcTx != nil { + filterByStep := func(step approvalutils.ApprovalStep) func(*gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + return db.Where("step_number = ?", uint16(step)) + } + } + latestStaff, err := approvalSvcTx.LatestByTarget(ctx, utils.ApprovalWorkflowPurchase, uint(purchase.Id), filterByStep(utils.PurchaseStepStaffPurchase)) + if err != nil { + return err + } + latestManager, err := approvalSvcTx.LatestByTarget(ctx, utils.ApprovalWorkflowPurchase, uint(purchase.Id), filterByStep(utils.PurchaseStepManager)) + if err != nil { + return err + } + if latestStaff != nil && latestManager != nil && latestStaff.ActionAt.After(latestManager.ActionAt) { + forceManagerApproval = true + } + } + + if err := s.createPurchaseApproval(ctx, tx, purchase.Id, utils.PurchaseStepManager, action, actorID, req.Notes, forceManagerApproval); err != nil { + return err + } + + return nil + }) + if transactionErr != nil { + s.Log.Errorf("Failed to approve manager purchase %d: %+v", purchase.Id, transactionErr) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to generate purchase order") + } + + if generatedNumber != "" { + purchase.PoNumber = &generatedNumber + purchase.PoDate = &now + } + + updated, err := s.PurchaseRepo.GetByIDWithRelations(ctx, purchase.Id) + if err != nil { + s.Log.Errorf("Failed to load purchase after manager approval: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase") + } + + if err := s.attachLatestApproval(ctx, updated); err != nil { + s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err) + } + + return updated, nil +} + +func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint64, req *validation.ReceivePurchaseRequest) (*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 not found") + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase ") + } + + if purchase.PoNumber == nil || strings.TrimSpace(*purchase.PoNumber) == "" { + return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase order has not been generated") + } + + if err := s.attachLatestApproval(ctx, purchase); err != nil { + s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err) + } + + if purchase.LatestApproval == nil || + purchase.LatestApproval.StepNumber < uint16(utils.PurchaseStepManager) { + return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase must be approved by manager before receiving products") + } + + itemMap := make(map[uint64]*entity.PurchaseItem, len(purchase.Items)) + for i := range purchase.Items { + itemMap[purchase.Items[i].Id] = &purchase.Items[i] + } + + type preparedReceiving struct { + item *entity.PurchaseItem + payload validation.ReceivePurchaseItemRequest + receivedDate time.Time + warehouseID uint + overrideWarehouse bool + receivedQty float64 + } + + visitedItems := make(map[uint64]struct{}, len(req.Items)) + prepared := make([]preparedReceiving, 0, len(req.Items)) + for _, payload := range req.Items { + item, exists := itemMap[payload.PurchaseItemID] + if !exists { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Purchase item %d not found", payload.PurchaseItemID)) + } + + receivedDate, err := time.Parse("2006-01-02", payload.ReceivedDate) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid received_date for item %d", payload.PurchaseItemID)) + } + receivedDate = receivedDate.UTC() + + warehouseID := uint(item.WarehouseId) + overrideWarehouse := false + if payload.WarehouseID != nil && *payload.WarehouseID != 0 { + warehouseID = *payload.WarehouseID + overrideWarehouse = true + } + if warehouseID == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Warehouse must be specified for item %d", payload.PurchaseItemID)) + } + + var receivedQty float64 + if payload.ReceivedQty != nil { + receivedQty = *payload.ReceivedQty + } else { + receivedQty = item.SubQty + } + if receivedQty < 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Received quantity for item %d cannot be negative", payload.PurchaseItemID)) + } + if receivedQty > item.SubQty { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Received quantity for item %d cannot exceed ordered quantity (%.3f)", payload.PurchaseItemID, item.SubQty)) + } + + if _, dup := visitedItems[payload.PurchaseItemID]; dup { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Duplicate receiving data for item %d", payload.PurchaseItemID)) + } + visitedItems[payload.PurchaseItemID] = struct{}{} + + prepared = append(prepared, preparedReceiving{ + item: item, + payload: payload, + receivedDate: receivedDate, + warehouseID: warehouseID, + overrideWarehouse: overrideWarehouse, + receivedQty: receivedQty, + }) + } + + // Require receiving payload to cover all purchase items so that + // receiving cannot be submitted partially item-by-item. + if len(visitedItems) != len(itemMap) { + return nil, fiber.NewError(fiber.StatusBadRequest, "Receiving data must be provided for all purchase items") + } + + receivingAction := entity.ApprovalActionApproved + completedAction := entity.ApprovalActionApproved + actorID := uint(1) + + approvalSvc := s.approvalServiceForDB(nil) + if approvalSvc != nil { + filterStep := func(step approvalutils.ApprovalStep) func(*gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + return db.Where("step_number = ?", uint16(step)) + } + } + + latestReceiving, err := approvalSvc.LatestByTarget(ctx, utils.ApprovalWorkflowPurchase, uint(purchase.Id), filterStep(utils.PurchaseStepReceiving)) + if err != nil { + s.Log.Errorf("Failed to inspect receiving approval for purchase %d: %+v", purchase.Id, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to record purchase receiving") + } + + if latestReceiving != nil { + receivingAction = entity.ApprovalActionUpdated + } + } + + transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + repoTx := rPurchase.NewPurchaseRepository(tx) + pwRepoTx := rProductWarehouse.NewProductWarehouseRepository(tx) + + deltas := make(map[uint]float64) + affected := make(map[uint]struct{}) + updates := make([]rPurchase.PurchaseReceivingUpdate, 0, len(prepared)) + + for _, prep := range prepared { + item := prep.item + + var oldPWID *uint + if item.ProductWarehouseId != nil { + idCopy := uint(*item.ProductWarehouseId) + oldPWID = &idCopy + } + + var newPWID *uint + clearPW := false + + if prep.receivedQty > 0 { + pwID, err := pwRepoTx.EnsureProductWarehouse(ctx, uint(item.ProductId), prep.warehouseID, purchase.CreatedBy) + if err != nil { + return err + } + newPWID = &pwID + deltas[pwID] += prep.receivedQty + affected[pwID] = struct{}{} + } else { + clearPW = true + } + + if oldPWID != nil { + deltas[*oldPWID] -= item.TotalQty + affected[*oldPWID] = struct{}{} + } + + dateCopy := prep.receivedDate + qtyCopy := prep.receivedQty + update := rPurchase.PurchaseReceivingUpdate{ + ItemID: item.Id, + ReceivedDate: &dateCopy, + TravelNumber: prep.payload.TravelNumber, + TravelDocumentPath: prep.payload.TravelDocumentPath, + VehicleNumber: prep.payload.VehicleNumber, + ReceivedQty: &qtyCopy, + ProductWarehouseID: newPWID, + ClearProductWarehouse: clearPW, + } + + if prep.overrideWarehouse || uint(item.WarehouseId) != prep.warehouseID { + warehouseCopy := prep.warehouseID + update.WarehouseID = &warehouseCopy + } + + updates = append(updates, update) + } + + if err := repoTx.UpdateReceivingDetails(ctx, purchase.Id, updates); err != nil { + return err + } + + if err := pwRepoTx.AdjustQuantities(ctx, deltas, nil); err != nil { + return err + } + + if err := pwRepoTx.CleanupEmpty(ctx, affected); err != nil { + return err + } + + if err := s.createPurchaseApproval(ctx, tx, purchase.Id, utils.PurchaseStepReceiving, receivingAction, actorID, req.Notes, true); err != nil { + return err + } + + if err := s.createPurchaseApproval(ctx, tx, purchase.Id, utils.PurchaseStepCompleted, completedAction, actorID, req.Notes, true); 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 for receiving") + } + s.Log.Errorf("Failed to save purchase receiving %d: %+v", purchase.Id, transactionErr) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to record purchase receiving") + } + + updated, err := s.PurchaseRepo.GetByIDWithRelations(ctx, purchase.Id) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase ") + } + if err := s.attachLatestApproval(ctx, updated); err != nil { + s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err) + } + + receivingPayloads := make([]ExpenseReceivingPayload, 0, len(prepared)) + for _, prep := range prepared { + date := prep.receivedDate + payload := ExpenseReceivingPayload{ + PurchaseItemID: prep.item.Id, + ProductID: prep.item.ProductId, + WarehouseID: uint64(prep.warehouseID), + ReceivedQty: prep.receivedQty, + ReceivedDate: &date, + } + receivingPayloads = append(receivingPayloads, payload) + } + s.notifyExpenseItemsReceived(ctx, purchase.Id, receivingPayloads) + + return updated, nil +} + +func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint64, req *validation.DeletePurchaseItemsRequest) (*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 not found") + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase") + } + + if err := s.attachLatestApproval(ctx, purchase); err != nil { + s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err) + } + if purchase.LatestApproval == nil || purchase.LatestApproval.StepNumber == uint16(utils.PurchaseStepPengajuan) { + return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase cannot delete items before staff purchase approval") + } + + if len(purchase.Items) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase has no items to delete") + } + + requested := make(map[uint64]struct{}, len(req.ItemIDs)) + for _, id := range req.ItemIDs { + requested[id] = struct{}{} + } + + toDelete := make([]uint64, 0, len(req.ItemIDs)) + var remainingTotal float64 + + for _, item := range purchase.Items { + if _, ok := requested[item.Id]; ok { + if item.TotalQty > 0 || item.TotalUsed > 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot delete item %d because it already has receiving data", item.Id)) + } + toDelete = append(toDelete, item.Id) + } else { + remainingTotal += item.TotalPrice + } + } + + if len(toDelete) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Requested items were not found in this purchase") + } + + if len(purchase.Items)-len(toDelete) <= 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase must keep at least one item") + } + + transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + repoTx := rPurchase.NewPurchaseRepository(tx) + + if err := repoTx.DeleteItems(ctx, purchase.Id, toDelete); err != nil { + return err + } + + if err := repoTx.UpdateGrandTotal(ctx, purchase.Id, remainingTotal); 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") + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to delete purchase items") + } + + if len(toDelete) > 0 { + s.notifyExpenseItemsDeleted(ctx, purchase.Id, toDelete) + } + + updated, err := s.PurchaseRepo.GetByIDWithRelations(ctx, purchase.Id) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase") + } + if err := s.attachLatestApproval(ctx, updated); err != nil { + s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err) + } + + return updated, nil +} + +func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint64) error { + if id == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid purchase id") + } + + ctx := c.Context() + purchase, err := s.PurchaseRepo.GetByIDWithRelations(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Purchase not found") + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase") + } + + itemIDs := make([]uint64, 0, len(purchase.Items)) + for _, item := range purchase.Items { + itemIDs = append(itemIDs, item.Id) + } + + transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + approvalRepoTx := commonRepo.NewApprovalRepository(tx) + if err := approvalRepoTx.DeleteByTarget(ctx, utils.ApprovalWorkflowPurchase.String(), uint(id)); err != nil { + return err + } + + purchaseRepoTx := rPurchase.NewPurchaseRepository(tx) + if err := purchaseRepoTx.DeleteOne(ctx, uint(id)); err != nil { + return err + } + return nil + }) + if transactionErr != nil { + if errors.Is(transactionErr, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Purchase not found") + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete purchase") + } + + if len(itemIDs) > 0 { + s.notifyExpenseItemsDeleted(ctx, uint64(id), itemIDs) + } + + return nil +} + +func (s *purchaseService) createPurchaseApproval( + ctx context.Context, + db *gorm.DB, + purchaseID uint64, + step approvalutils.ApprovalStep, + action entity.ApprovalAction, + actorID uint, + notes *string, + allowDuplicate bool, +) error { + if purchaseID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Purchase is invalid for approval") + } + if actorID == 0 { + actorID = 1 + } + + svc := s.approvalServiceForDB(db) + + modifier := func(db *gorm.DB) *gorm.DB { + return db.Where("step_number = ?", uint16(step)) + } + + latest, err := svc.LatestByTarget(ctx, utils.ApprovalWorkflowPurchase, uint(purchaseID), modifier) + if err != nil { + return err + } + + if !allowDuplicate && latest != nil && + latest.Action != nil && + *latest.Action == action { + return nil + } + + actionCopy := action + _, err = svc.CreateApproval(ctx, utils.ApprovalWorkflowPurchase, uint(purchaseID), step, &actionCopy, actorID, notes) + return err +} + +func (s *purchaseService) approvalServiceForDB(db *gorm.DB) commonSvc.ApprovalService { + if db != nil { + return commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(db)) + } + if s.ApprovalSvc != nil { + return s.ApprovalSvc + } + return commonSvc.NewApprovalService(s.ApprovalRepo) +} + +func (s *purchaseService) attachLatestApprovals(ctx context.Context, items []entity.Purchase) error { + if len(items) == 0 || s.ApprovalSvc == nil { + return nil + } + + ids := make([]uint, 0, len(items)) + visited := make(map[uint64]struct{}, len(items)) + for _, item := range items { + if item.Id == 0 { + continue + } + if _, ok := visited[item.Id]; ok { + continue + } + visited[item.Id] = struct{}{} + ids = append(ids, uint(item.Id)) + } + + if len(ids) == 0 { + return nil + } + + latestMap, err := s.ApprovalSvc.LatestByTargets(ctx, utils.ApprovalWorkflowPurchase, ids, func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + }) + if err != nil { + return err + } + + for i := range items { + if items[i].Id == 0 { + continue + } + if approval, ok := latestMap[uint(items[i].Id)]; ok { + items[i].LatestApproval = approval + } else { + items[i].LatestApproval = nil + } + } + + return nil +} + +func (s *purchaseService) notifyExpenseItemsCreated(ctx context.Context, purchaseID uint64, items []entity.PurchaseItem) { + if s.ExpenseBridge == nil || purchaseID == 0 || len(items) == 0 { + return + } + if err := s.ExpenseBridge.OnItemsCreated(ctx, purchaseID, items); err != nil { + s.Log.Warnf("Failed to notify expense bridge for created purchase %d: %+v", purchaseID, err) + } +} + +func (s *purchaseService) notifyExpenseItemsReceived(ctx context.Context, purchaseID uint64, payloads []ExpenseReceivingPayload) { + if s.ExpenseBridge == nil || purchaseID == 0 || len(payloads) == 0 { + return + } + if err := s.ExpenseBridge.OnItemsReceived(ctx, purchaseID, payloads); err != nil { + s.Log.Warnf("Failed to notify expense bridge for received purchase %d: %+v", purchaseID, err) + } +} + +func (s *purchaseService) notifyExpenseItemsDeleted(ctx context.Context, purchaseID uint64, itemIDs []uint64) { + if s.ExpenseBridge == nil || purchaseID == 0 || len(itemIDs) == 0 { + return + } + if err := s.ExpenseBridge.OnItemsDeleted(ctx, purchaseID, itemIDs); err != nil { + s.Log.Warnf("Failed to notify expense bridge for deleted purchase %d: %+v", purchaseID, err) + } +} + +func (s *purchaseService) buildStaffAdjustmentPayload( + ctx context.Context, + purchase *entity.Purchase, + req *validation.ApproveStaffPurchaseRequest, + syncReceiving bool, +) (*staffAdjustmentPayload, error) { + if len(req.Items) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Items must not be empty") + } + + requestItems := make(map[uint64]validation.StaffPurchaseApprovalItem, len(req.Items)) + newPayloads := make([]validation.StaffPurchaseApprovalItem, 0) + + for _, item := range req.Items { + if item.PurchaseItemID == 0 { + newPayloads = append(newPayloads, item) + continue + } + if _, exists := requestItems[item.PurchaseItemID]; exists { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Duplicate pricing data for item %d", item.PurchaseItemID)) + } + requestItems[item.PurchaseItemID] = item + } + + updates := make([]rPurchase.PurchasePricingUpdate, 0, len(purchase.Items)) + var grandTotal float64 + + existingCombos := make(map[string]struct{}, len(purchase.Items)+len(newPayloads)) + for _, item := range purchase.Items { + key := fmt.Sprintf("%d:%d", item.ProductId, item.WarehouseId) + existingCombos[key] = struct{}{} + } + + allowedWarehouses := make(map[uint64]struct{}, len(purchase.Items)) + for _, item := range purchase.Items { + allowedWarehouses[item.WarehouseId] = struct{}{} + } + if len(allowedWarehouses) == 0 && len(newPayloads) > 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "No available warehouses for this purchase") + } + + 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)) + } + if data.ProductID != 0 && data.ProductID != item.ProductId { + return nil, fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Cannot change product for item %d. Delete the item and create a new one instead", item.Id), + ) + } + if data.WarehouseID != 0 && data.WarehouseID != item.WarehouseId { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Warehouse mismatch for item %d", item.Id)) + } + + effectiveQty := item.SubQty + if data.Qty != nil { + if *data.Qty <= 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Quantity for item %d must be greater than 0", item.Id)) + } + if item.TotalUsed > 0 && *data.Qty < item.TotalUsed { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Quantity for item %d cannot be lower than used amount (%.3f)", item.Id, item.TotalUsed)) + } + if (item.TotalQty > 0 || item.TotalUsed > 0) && !syncReceiving { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot change quantity for item %d because it already has receiving data", item.Id)) + } + effectiveQty = *data.Qty + } + + totalPriceInput := data.TotalPrice + totalPrice, err := calculateTotalPrice(effectiveQty, data.Price, &totalPriceInput, fmt.Sprintf("item %d", item.Id)) + if err != nil { + return nil, err + } + + update := rPurchase.PurchasePricingUpdate{ + ItemID: item.Id, + Price: data.Price, + TotalPrice: totalPrice, + } + if data.Qty != nil { + qtyCopy := effectiveQty + update.Quantity = &qtyCopy + } + if syncReceiving { + qtyCopy := effectiveQty + update.TotalQty = &qtyCopy + } + + updates = append(updates, update) + grandTotal += totalPrice + delete(requestItems, item.Id) + } + if len(requestItems) > 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Found pricing data for items that do not belong to this purchase") + } + + productSupplierCache := make(map[uint64]bool) + newItems := make([]*entity.PurchaseItem, 0, len(newPayloads)) + + for _, payload := range newPayloads { + if payload.ProductID == 0 || payload.WarehouseID == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Product and warehouse must be provided for new items") + } + if payload.Qty == nil || *payload.Qty <= 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Quantity must be greater than 0 for product %d", payload.ProductID)) + } + if _, ok := allowedWarehouses[payload.WarehouseID]; !ok { + return nil, fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Warehouse %d is not available for this purchase", payload.WarehouseID), + ) + } + + key := fmt.Sprintf("%d:%d", payload.ProductID, payload.WarehouseID) + if _, exists := existingCombos[key]; exists { + return nil, fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Product %d in warehouse %d already exists in this purchase", payload.ProductID, payload.WarehouseID), + ) + } + + if _, checked := productSupplierCache[payload.ProductID]; !checked { + linked, err := s.ProductRepo.IsLinkedToSupplier(ctx, uint(payload.ProductID), uint(purchase.SupplierId)) + if err != nil { + s.Log.Errorf("Failed to validate product %d for supplier %d: %+v", payload.ProductID, purchase.SupplierId, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product for supplier") + } + if !linked { + return nil, fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Product %d is not linked to supplier %d", payload.ProductID, purchase.SupplierId), + ) + } + productSupplierCache[payload.ProductID] = true + } + + qty := *payload.Qty + totalPriceInput := payload.TotalPrice + totalPrice, err := calculateTotalPrice(qty, payload.Price, &totalPriceInput, fmt.Sprintf("product %d in warehouse %d", payload.ProductID, payload.WarehouseID)) + if err != nil { + return nil, err + } + + newItem := &entity.PurchaseItem{ + PurchaseId: purchase.Id, + ProductId: payload.ProductID, + WarehouseId: payload.WarehouseID, + SubQty: qty, + TotalQty: 0, + TotalUsed: 0, + Price: payload.Price, + TotalPrice: totalPrice, + } + newItems = append(newItems, newItem) + existingCombos[key] = struct{}{} + grandTotal += totalPrice + } + + if len(updates) == 0 && len(newItems) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase has no items to process") + } + + return &staffAdjustmentPayload{ + PricingUpdates: updates, + NewItems: newItems, + GrandTotal: grandTotal, + }, nil +} + +func calculateTotalPrice(quantity float64, price float64, provided *float64, ref string) (float64, error) { + if quantity <= 0 { + return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Quantity for %s must be greater than 0", ref)) + } + if price <= 0 { + return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Price for %s must be greater than 0", ref)) + } + + fmt.Println(price, quantity) + expectedTotal := price * quantity + + if provided == nil { + return expectedTotal, nil + } + if *provided <= 0 { + return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Total price for %s must be greater than 0", ref)) + } + if math.Abs(*provided-expectedTotal) > priceTolerance { + return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Total price for %s must equal quantity x price", ref)) + } + return *provided, nil +} + +func (s *purchaseService) attachLatestApproval(ctx context.Context, item *entity.Purchase) error { + if item == nil || item.Id == 0 || s.ApprovalSvc == nil { + return nil + } + + latest, err := s.ApprovalSvc.LatestByTarget(ctx, utils.ApprovalWorkflowPurchase, uint(item.Id), func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + }) + if err != nil { + return err + } + + item.LatestApproval = latest + return nil +} + +func parseQueryDates(fromStr, toStr string) (*time.Time, *time.Time, error) { + var fromPtr *time.Time + var toPtr *time.Time + + if strings.TrimSpace(fromStr) != "" { + parsed, err := time.Parse(queryDateLayout, fromStr) + if err != nil { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "created_from must use format YYYY-MM-DD") + } + fromValue := parsed + fromPtr = &fromValue + } + + if strings.TrimSpace(toStr) != "" { + parsed, err := time.Parse(queryDateLayout, toStr) + if err != nil { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "created_to must use format YYYY-MM-DD") + } + toValue := parsed.AddDate(0, 0, 1) + toPtr = &toValue + } + + if fromPtr != nil && toPtr != nil && fromPtr.After(*toPtr) { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "created_from must be earlier than created_to") + } + + return fromPtr, toPtr, nil +} + +func parseApprovalAction(status string) (*entity.ApprovalAction, bool, error) { + value := strings.TrimSpace(strings.ToUpper(status)) + if value == "" { + return nil, false, nil + } + + if value == "COMPLETED" { + return nil, true, nil + } + + action := entity.ApprovalAction(value) + switch action { + case entity.ApprovalActionApproved, + entity.ApprovalActionRejected, + entity.ApprovalActionCreated, + entity.ApprovalActionUpdated: + return &action, false, nil + default: + return nil, false, fiber.NewError(fiber.StatusBadRequest, "Invalid status filter") + } +} diff --git a/internal/modules/purchases/validations/purchase.validation.go b/internal/modules/purchases/validations/purchase.validation.go new file mode 100644 index 00000000..4994a927 --- /dev/null +++ b/internal/modules/purchases/validations/purchase.validation.go @@ -0,0 +1,63 @@ +package validation + +type PurchaseItemPayload struct { + WarehouseID uint `json:"warehouse_id" validate:"required,gt=0"` + ProductID uint `json:"product_id" validate:"required,gt=0"` + Quantity float64 `json:"qty" validate:"required,gt=0"` +} + +type CreatePurchaseRequest struct { + SupplierID uint `json:"supplier_id" validate:"required,gt=0"` + CreditTerm int `json:"credit_term" validate:"required,gte=0"` + 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,omitempty" validate:"omitempty,gt=0"` + // For new items (no purchase_item_id), product_id is required. + ProductID uint64 `json:"product_id,omitempty" validate:"required_without=PurchaseItemID,omitempty,gt=0"` + WarehouseID uint64 `json:"warehouse_id,omitempty" validate:"required_without=PurchaseItemID,omitempty,gt=0"` + Qty *float64 `json:"qty,omitempty" validate:"required_without=PurchaseItemID,omitempty,gt=0"` + Price float64 `json:"price" validate:"required,gt=0"` + TotalPrice float64 `json:"total_price" validate:"required,gt=0"` +} + +type ApproveStaffPurchaseRequest struct { + Items []StaffPurchaseApprovalItem `json:"items" validate:"required,min=1,dive"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` +} + +type ApproveManagerPurchaseRequest struct { + Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` +} + +type ReceivePurchaseItemRequest struct { + PurchaseItemID uint64 `json:"purchase_item_id" validate:"required,gt=0"` + WarehouseID *uint `json:"warehouse_id" validate:"omitempty,gt=0"` + ReceivedDate string `json:"received_date" validate:"required,datetime=2006-01-02"` + TravelNumber *string `json:"travel_number" validate:"omitempty,max=100"` + TravelDocumentPath *string `json:"travel_document_path" validate:"omitempty,max=255"` + VehicleNumber *string `json:"vehicle_number" validate:"omitempty,max=100"` + ReceivedQty *float64 `json:"received_qty" validate:"omitempty,gte=0"` +} + +type ReceivePurchaseRequest struct { + Items []ReceivePurchaseItemRequest `json:"items" validate:"required,min=1,dive"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` +} + +type DeletePurchaseItemsRequest struct { + ItemIDs []uint64 `json:"item_ids" validate:"required,min=1,dive,gt=0"` +} + +type PurchaseQuery struct { + Page int `query:"page" validate:"omitempty,number,min=1"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + SupplierID uint `query:"supplier_id" validate:"omitempty,gt=0"` + Search string `query:"search" validate:"omitempty,max=100"` + PrNumber string `query:"pr_number" validate:"omitempty,max=50"` + CreatedFrom string `query:"created_from" validate:"omitempty,datetime=2006-01-02"` + CreatedTo string `query:"created_to" validate:"omitempty,datetime=2006-01-02"` + Status string `query:"status" validate:"omitempty,oneof=CREATED UPDATED APPROVED REJECTED COMPLETED"` +} diff --git a/internal/route/route.go b/internal/route/route.go index 4c0c80ec..f198fba5 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -12,8 +12,9 @@ import ( constants "gitlab.com/mbugroup/lti-api.git/internal/modules/constants" inventory "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory" master "gitlab.com/mbugroup/lti-api.git/internal/modules/master" - production "gitlab.com/mbugroup/lti-api.git/internal/modules/production" ssoModule "gitlab.com/mbugroup/lti-api.git/internal/modules/sso" + production "gitlab.com/mbugroup/lti-api.git/internal/modules/production" + purchases "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases" users "gitlab.com/mbugroup/lti-api.git/internal/modules/users" // MODULE IMPORTS ) @@ -29,6 +30,7 @@ func Routes(app *fiber.App, db *gorm.DB) { master.MasterModule{}, constants.ConstantModule{}, inventory.InventoryModule{}, + purchases.PurchaseModule{}, production.ProductionModule{}, approvals.ApprovalModule{}, ssoModule.Module{}, diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 0a8862f9..dad324b9 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,33 @@ 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 + PurchaseStepManager approvalutils.ApprovalStep = 3 + PurchaseStepReceiving approvalutils.ApprovalStep = 4 + PurchaseStepCompleted approvalutils.ApprovalStep = 5 + + PurchasePRNumberPrefix = "PR-LTI-" + PurchasePONumberPrefix = "PO-LTI-" + PurchaseNumberPadding = 4 +) + +var PurchaseApprovalSteps = map[approvalutils.ApprovalStep]string{ + PurchaseStepPengajuan: "Pengajuan", + PurchaseStepStaffPurchase: "Staff Purchase", + PurchaseStepManager: "Manager Purchase", + PurchaseStepReceiving: "Penerimaan Produk", + PurchaseStepCompleted: "Selesai", } // ------------------------------------------------------------------- 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,