diff --git a/go.mod b/go.mod index abb6d004..d0ffe677 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.2.1 github.com/google/uuid v1.6.0 github.com/jackc/pgconn v1.14.1 + github.com/jackc/pgx/v5 v5.5.5 github.com/redis/go-redis/v9 v9.14.0 github.com/sirupsen/logrus v1.9.3 github.com/spf13/viper v1.19.0 @@ -60,7 +61,6 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgproto3/v2 v2.3.2 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect - github.com/jackc/pgx/v5 v5.5.5 // indirect github.com/jackc/puddle/v2 v2.2.1 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect diff --git a/go.sum b/go.sum index 73b36464..ab7d76b4 100644 --- a/go.sum +++ b/go.sum @@ -262,14 +262,10 @@ github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVS github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d h1:llb0neMWDQe87IzJLS4Ci7psK/lVsjIS2otl+1WyRyY= github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= -github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= -github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= github.com/xuri/excelize/v2 v2.9.0 h1:1tgOaEq92IOEumR1/JfYS/eR0KHOCsRv/rYXXh6YJQE= github.com/xuri/excelize/v2 v2.9.0/go.mod h1:uqey4QBZ9gdMeWApPLdhm9x+9o2lq4iVmjiLfBS5hdE= github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7 h1:hPVCafDV85blFTabnqKgNhDCkJX25eik94Si9cTER4A= github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= -github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= -github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= diff --git a/internal/common/service/common.fifo.service.go b/internal/common/service/common.fifo.service.go index 2a65c1b4..b99e6c35 100644 --- a/internal/common/service/common.fifo.service.go +++ b/internal/common/service/common.fifo.service.go @@ -228,7 +228,13 @@ func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*St switch { case delta > 0: - allocationRes, err := s.allocateFromStock(ctx, tx, productWarehouseID, req.UsableKey, req.UsableID, delta) + + var excludedStockables []fifo.StockableKey + if cfg.ExcludedStockables != nil { + excludedStockables = cfg.ExcludedStockables + } + + allocationRes, err := s.allocateFromStock(ctx, tx, productWarehouseID, req.UsableKey, req.UsableID, delta, excludedStockables) if err != nil { return err } @@ -410,8 +416,9 @@ func (s *fifoService) allocateFromStock( usableKey fifo.UsableKey, usableID uint, requestQty float64, + excludedStockables []fifo.StockableKey, ) (*allocationOutcome, error) { - lots, err := s.fetchStockLots(ctx, tx, productWarehouseID) + lots, err := s.fetchStockLots(ctx, tx, productWarehouseID, excludedStockables) if err != nil { return nil, err } @@ -492,14 +499,24 @@ func (s *fifoService) allocateFromStock( }, nil } -func (s *fifoService) fetchStockLots(ctx context.Context, tx *gorm.DB, productWarehouseID uint) ([]stockLot, error) { +func (s *fifoService) fetchStockLots(ctx context.Context, tx *gorm.DB, productWarehouseID uint, excludedStockables []fifo.StockableKey) ([]stockLot, error) { configs := fifo.Stockables() if len(configs) == 0 { return nil, nil } + // Create exclusion set for faster lookup + excludedSet := make(map[fifo.StockableKey]bool) + for _, key := range excludedStockables { + excludedSet[key] = true + } + var lots []stockLot for key, cfg := range configs { + // Skip excluded stockables + if excludedSet[key] { + continue + } usesNumericTime := cfg.Columns.CreatedAt == cfg.Columns.ID @@ -616,7 +633,13 @@ func (s *fifoService) resolvePendingForWarehouse(ctx context.Context, tx *gorm.D continue } - outcome, err := s.allocateFromStock(ctx, tx, productWarehouseID, candidate.UsableKey, candidate.UsableID, candidate.Pending) + // Get excluded stockables from candidate usable config + var excludedStockables []fifo.StockableKey + if candidate.Config.ExcludedStockables != nil { + excludedStockables = candidate.Config.ExcludedStockables + } + + outcome, err := s.allocateFromStock(ctx, tx, productWarehouseID, candidate.UsableKey, candidate.UsableID, candidate.Pending, excludedStockables) if err != nil { return nil, err } diff --git a/internal/config/fiber.go b/internal/config/fiber.go index aea67b5f..40c2c818 100644 --- a/internal/config/fiber.go +++ b/internal/config/fiber.go @@ -13,6 +13,7 @@ func FiberConfig() fiber.Config { CaseSensitive: true, ServerHeader: "Fiber", AppName: "Fiber API", + BodyLimit: 8 * 1024 * 1024, ErrorHandler: utils.ErrorHandler, JSONEncoder: sonic.Marshal, JSONDecoder: sonic.Unmarshal, diff --git a/internal/database/migrations/20251117034511_create_expenses_table.down.sql b/internal/database/migrations/20251117034511_create_expenses_table.down.sql index bf0ea945..9b06613a 100644 --- a/internal/database/migrations/20251117034511_create_expenses_table.down.sql +++ b/internal/database/migrations/20251117034511_create_expenses_table.down.sql @@ -1 +1,2 @@ -DROP TABLE IF EXISTS expenses; \ No newline at end of file +DROP SEQUENCE IF EXISTS expenses_ref_seq; +DROP TABLE IF EXISTS expenses; diff --git a/internal/database/migrations/20251210044651_create_so_number_sequence.down.sql b/internal/database/migrations/20251210044651_create_so_number_sequence.down.sql index 4d80dd2c..53907ef1 100644 --- a/internal/database/migrations/20251210044651_create_so_number_sequence.down.sql +++ b/internal/database/migrations/20251210044651_create_so_number_sequence.down.sql @@ -1,3 +1,3 @@ -- Drop function and sequence for sales order numbers -DROP FUNCTION IF EXISTS generate_so_number(); DROP SEQUENCE IF EXISTS so_number_seq; +DROP FUNCTION IF EXISTS generate_so_number(); diff --git a/internal/database/migrations/20260105131644_create_daily_checklists_tables.down.sql b/internal/database/migrations/20260105131644_create_daily_checklists_tables.down.sql index 7be30be1..20182fe3 100644 --- a/internal/database/migrations/20260105131644_create_daily_checklists_tables.down.sql +++ b/internal/database/migrations/20260105131644_create_daily_checklists_tables.down.sql @@ -1,6 +1,8 @@ -DROP TABLE IF EXISTS daily_checklist_tasks; +-- Drop tables in correct order (child tables before parent tables) +DROP TABLE IF EXISTS daily_checklist_activity_task_assignments; -- Child table with FK to daily_checklist_activity_tasks DROP TABLE IF EXISTS daily_checklist_activity_task_assignees; DROP TABLE IF EXISTS daily_checklist_activity_tasks; +DROP TABLE IF EXISTS daily_checklist_tasks; DROP TABLE IF EXISTS daily_checklist_phases; DROP TABLE IF EXISTS daily_checklists; DROP TABLE IF EXISTS checklists; diff --git a/internal/database/migrations/20260106113501_alter_expense_fk_to_cascade.down.sql b/internal/database/migrations/20260106113501_alter_expense_fk_to_cascade.down.sql new file mode 100644 index 00000000..2a212b3b --- /dev/null +++ b/internal/database/migrations/20260106113501_alter_expense_fk_to_cascade.down.sql @@ -0,0 +1,15 @@ +-- Revert back to NO ACTION (RESTRICT behavior) +ALTER TABLE expense_nonstocks DROP CONSTRAINT IF EXISTS fk_expense_nonstocks_expense_id; + +ALTER TABLE expense_nonstocks + ADD CONSTRAINT fk_expense_nonstocks_expense_id + FOREIGN KEY (expense_id) REFERENCES expenses(id) + ON DELETE NO ACTION; + +-- Revert expense_realizations FK +ALTER TABLE expense_realizations DROP CONSTRAINT IF EXISTS fk_expense_realizations_nonstock_id; + +ALTER TABLE expense_realizations + ADD CONSTRAINT fk_expense_realizations_nonstock_id + FOREIGN KEY (expense_nonstock_id) REFERENCES expense_nonstocks(id) + ON DELETE NO ACTION; diff --git a/internal/database/migrations/20260106113501_alter_expense_fk_to_cascade.up.sql b/internal/database/migrations/20260106113501_alter_expense_fk_to_cascade.up.sql new file mode 100644 index 00000000..6567c5d2 --- /dev/null +++ b/internal/database/migrations/20260106113501_alter_expense_fk_to_cascade.up.sql @@ -0,0 +1,16 @@ +-- Drop existing FK constraints +ALTER TABLE expense_nonstocks DROP CONSTRAINT IF EXISTS fk_expense_nonstocks_expense_id; + +-- Recreate with ON DELETE CASCADE +ALTER TABLE expense_nonstocks + ADD CONSTRAINT fk_expense_nonstocks_expense_id + FOREIGN KEY (expense_id) REFERENCES expenses(id) + ON DELETE CASCADE; + +-- Drop and recreate expense_realizations FK +ALTER TABLE expense_realizations DROP CONSTRAINT IF EXISTS fk_expense_realizations_nonstock_id; + +ALTER TABLE expense_realizations + ADD CONSTRAINT fk_expense_realizations_nonstock_id + FOREIGN KEY (expense_nonstock_id) REFERENCES expense_nonstocks(id) + ON DELETE CASCADE; diff --git a/internal/database/migrations/20260106113502_ensure_marketing_fk_cascade.down.sql b/internal/database/migrations/20260106113502_ensure_marketing_fk_cascade.down.sql new file mode 100644 index 00000000..91ef5903 --- /dev/null +++ b/internal/database/migrations/20260106113502_ensure_marketing_fk_cascade.down.sql @@ -0,0 +1,20 @@ +-- Revert back to NO ACTION (for rollback safety) +DO $$ +BEGIN + ALTER TABLE marketing_products DROP CONSTRAINT IF EXISTS fk_marketing_products_marketing_id; + + ALTER TABLE marketing_products + ADD CONSTRAINT fk_marketing_products_marketing_id + FOREIGN KEY (marketing_id) REFERENCES marketings(id) + ON DELETE NO ACTION; +END $$; + +DO $$ +BEGIN + ALTER TABLE marketing_delivery_products DROP CONSTRAINT IF EXISTS fk_marketing_delivery_products_marketing_product_id; + + ALTER TABLE marketing_delivery_products + ADD CONSTRAINT fk_marketing_delivery_products_marketing_product_id + FOREIGN KEY (marketing_product_id) REFERENCES marketing_products(id) + ON DELETE NO ACTION; +END $$; diff --git a/internal/database/migrations/20260106113502_ensure_marketing_fk_cascade.up.sql b/internal/database/migrations/20260106113502_ensure_marketing_fk_cascade.up.sql new file mode 100644 index 00000000..801c841d --- /dev/null +++ b/internal/database/migrations/20260106113502_ensure_marketing_fk_cascade.up.sql @@ -0,0 +1,35 @@ +-- Ensure marketing_products FK is CASCADE (it should already be, but let's make sure) +DO $$ +BEGIN + -- Drop existing FK if exists + IF EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_marketing_products_marketing_id' + ) THEN + ALTER TABLE marketing_products DROP CONSTRAINT fk_marketing_products_marketing_id; + END IF; + + -- Recreate with ON DELETE CASCADE + ALTER TABLE marketing_products + ADD CONSTRAINT fk_marketing_products_marketing_id + FOREIGN KEY (marketing_id) REFERENCES marketings(id) + ON DELETE CASCADE; +END $$; + +-- Ensure marketing_delivery_products FK is CASCADE +DO $$ +BEGIN + -- Drop existing FK if exists + IF EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_marketing_delivery_products_marketing_product_id' + ) THEN + ALTER TABLE marketing_delivery_products DROP CONSTRAINT fk_marketing_delivery_products_marketing_product_id; + END IF; + + -- Recreate with ON DELETE CASCADE + ALTER TABLE marketing_delivery_products + ADD CONSTRAINT fk_marketing_delivery_products_marketing_product_id + FOREIGN KEY (marketing_product_id) REFERENCES marketing_products(id) + ON DELETE CASCADE; +END $$; diff --git a/internal/database/migrations/20260106113503_add_product_warehouse_to_laying_transfers.down.sql b/internal/database/migrations/20260106113503_add_product_warehouse_to_laying_transfers.down.sql new file mode 100644 index 00000000..af4a6477 --- /dev/null +++ b/internal/database/migrations/20260106113503_add_product_warehouse_to_laying_transfers.down.sql @@ -0,0 +1,9 @@ +-- Drop foreign key and column +ALTER TABLE laying_transfers +DROP CONSTRAINT IF EXISTS fk_laying_transfers_product_warehouse_id; + +ALTER TABLE laying_transfers +DROP COLUMN IF EXISTS product_warehouse_id; + +-- Drop index +DROP INDEX IF EXISTS idx_laying_transfers_product_warehouse_id; diff --git a/internal/database/migrations/20260106113503_add_product_warehouse_to_laying_transfers.up.sql b/internal/database/migrations/20260106113503_add_product_warehouse_to_laying_transfers.up.sql new file mode 100644 index 00000000..7e417ff6 --- /dev/null +++ b/internal/database/migrations/20260106113503_add_product_warehouse_to_laying_transfers.up.sql @@ -0,0 +1,19 @@ +-- Add product_warehouse_id to laying_transfers for FIFO support +ALTER TABLE laying_transfers +ADD COLUMN product_warehouse_id BIGINT; + +-- Add foreign key +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'product_warehouses') THEN + ALTER TABLE laying_transfers + ADD CONSTRAINT fk_laying_transfers_product_warehouse_id + FOREIGN KEY (product_warehouse_id) + REFERENCES product_warehouses(id) + ON DELETE SET NULL; + END IF; +END $$; + +-- Add index +CREATE INDEX idx_laying_transfers_product_warehouse_id +ON laying_transfers(product_warehouse_id); diff --git a/internal/database/migrations/20260106140815_add_stockable_fields_to_laying_transfers.down.sql b/internal/database/migrations/20260106140815_add_stockable_fields_to_laying_transfers.down.sql new file mode 100644 index 00000000..391731f2 --- /dev/null +++ b/internal/database/migrations/20260106140815_add_stockable_fields_to_laying_transfers.down.sql @@ -0,0 +1,14 @@ +-- Rollback: Remove STOCKABLE fields from laying_transfers + +-- Drop index +DROP INDEX IF EXISTS idx_laying_transfers_dest_product_warehouse_id; + +-- Drop foreign key constraint +ALTER TABLE laying_transfers +DROP CONSTRAINT IF EXISTS fk_laying_transfers_dest_product_warehouse_id; + +-- Drop columns +ALTER TABLE laying_transfers +DROP COLUMN IF EXISTS dest_product_warehouse_id, +DROP COLUMN IF EXISTS total_qty, +DROP COLUMN IF EXISTS total_used; diff --git a/internal/database/migrations/20260106140815_add_stockable_fields_to_laying_transfers.up.sql b/internal/database/migrations/20260106140815_add_stockable_fields_to_laying_transfers.up.sql new file mode 100644 index 00000000..7a4ce8a6 --- /dev/null +++ b/internal/database/migrations/20260106140815_add_stockable_fields_to_laying_transfers.up.sql @@ -0,0 +1,30 @@ +-- Add STOCKABLE fields to laying_transfers for destination warehouse +-- This enables Transfer to Laying to work as DUAL ROLE (Stockable + Usable) + +-- Add columns for STOCKABLE role (destination warehouse) +ALTER TABLE laying_transfers +ADD COLUMN dest_product_warehouse_id BIGINT, +ADD COLUMN total_qty NUMERIC(15, 3) DEFAULT 0, +ADD COLUMN total_used NUMERIC(15, 3) DEFAULT 0; + +-- Add foreign key constraint +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'product_warehouses') THEN + ALTER TABLE laying_transfers + ADD CONSTRAINT fk_laying_transfers_dest_product_warehouse_id + FOREIGN KEY (dest_product_warehouse_id) + REFERENCES product_warehouses(id) + ON DELETE SET NULL; + END IF; +END $$; + +-- Add index for performance +CREATE INDEX idx_laying_transfers_dest_product_warehouse_id +ON laying_transfers(dest_product_warehouse_id); + +-- Add comment for documentation +COMMENT ON COLUMN laying_transfers.product_warehouse_id IS 'Product warehouse at source (Growing flock) - for USABLE role'; +COMMENT ON COLUMN laying_transfers.dest_product_warehouse_id IS 'Product warehouse at destination (Laying flock) - for STOCKABLE role'; +COMMENT ON COLUMN laying_transfers.total_qty IS 'Total lot quantity introduced to destination warehouse - for STOCKABLE role'; +COMMENT ON COLUMN laying_transfers.total_used IS 'Quantity already consumed from this lot at destination - for STOCKABLE role'; diff --git a/internal/database/migrations/20260107075608_alter_uniformity_table.down.sql b/internal/database/migrations/20260107075608_alter_uniformity_table.down.sql new file mode 100644 index 00000000..bb7b412d --- /dev/null +++ b/internal/database/migrations/20260107075608_alter_uniformity_table.down.sql @@ -0,0 +1,7 @@ +-- Remove chart_data, uniform_date, and related indexes +DROP INDEX IF EXISTS idx_project_flock_kandang_uniformity_uniform_date; +DROP INDEX IF EXISTS idx_project_flock_kandang_uniformity_unique; + +ALTER TABLE project_flock_kandang_uniformity + DROP COLUMN IF EXISTS chart_data, + DROP COLUMN IF EXISTS uniform_date; diff --git a/internal/database/migrations/20260107075608_alter_uniformity_table.up.sql b/internal/database/migrations/20260107075608_alter_uniformity_table.up.sql new file mode 100644 index 00000000..4c256096 --- /dev/null +++ b/internal/database/migrations/20260107075608_alter_uniformity_table.up.sql @@ -0,0 +1,25 @@ +-- Add uniform_date (if missing), chart_data, and unique constraint for uniformity records +ALTER TABLE project_flock_kandang_uniformity + ADD COLUMN IF NOT EXISTS uniform_date TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS chart_data JSONB; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'project_flock_kandang_uniformity' + AND column_name = 'deleted_at' + ) THEN + CREATE UNIQUE INDEX IF NOT EXISTS idx_project_flock_kandang_uniformity_unique + ON project_flock_kandang_uniformity (project_flock_kandang_id, week, uniform_date) + WHERE deleted_at IS NULL; + ELSE + CREATE UNIQUE INDEX IF NOT EXISTS idx_project_flock_kandang_uniformity_unique + ON project_flock_kandang_uniformity (project_flock_kandang_id, week, uniform_date); + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_project_flock_kandang_uniformity_uniform_date + ON project_flock_kandang_uniformity (uniform_date); diff --git a/internal/database/migrations/20260107080257_add_expense_to_stock_transfer_details.down.sql b/internal/database/migrations/20260107080257_add_expense_to_stock_transfer_details.down.sql new file mode 100644 index 00000000..a8b3dfaa --- /dev/null +++ b/internal/database/migrations/20260107080257_add_expense_to_stock_transfer_details.down.sql @@ -0,0 +1,4 @@ +-- Remove expense_nonstock_id from stock_transfer_details +ALTER TABLE stock_transfer_details DROP CONSTRAINT IF EXISTS fk_stock_transfer_details_expense_nonstock; +ALTER TABLE stock_transfer_details DROP COLUMN IF EXISTS expense_nonstock_id; +DROP INDEX IF EXISTS idx_stock_transfer_details_expense_nonstock_id; diff --git a/internal/database/migrations/20260107080257_add_expense_to_stock_transfer_details.up.sql b/internal/database/migrations/20260107080257_add_expense_to_stock_transfer_details.up.sql new file mode 100644 index 00000000..25d6b199 --- /dev/null +++ b/internal/database/migrations/20260107080257_add_expense_to_stock_transfer_details.up.sql @@ -0,0 +1,10 @@ +-- Add expense_nonstock_id to stock_transfer_details +-- This allows tracking expedition/transport costs for stock transfers (same as purchase) + +ALTER TABLE stock_transfer_details +ADD COLUMN expense_nonstock_id BIGINT, +ADD CONSTRAINT fk_stock_transfer_details_expense_nonstock +FOREIGN KEY (expense_nonstock_id) REFERENCES expense_nonstocks(id) ON DELETE SET NULL; + +-- Create index for better query performance +CREATE INDEX idx_stock_transfer_details_expense_nonstock_id ON stock_transfer_details(expense_nonstock_id); diff --git a/internal/database/migrations/20260107120000_create_config_checklists_table.down.sql b/internal/database/migrations/20260107120000_create_config_checklists_table.down.sql new file mode 100644 index 00000000..e4c4d3dd --- /dev/null +++ b/internal/database/migrations/20260107120000_create_config_checklists_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS config_checklists; diff --git a/internal/database/migrations/20260107120000_create_config_checklists_table.up.sql b/internal/database/migrations/20260107120000_create_config_checklists_table.up.sql new file mode 100644 index 00000000..57589f31 --- /dev/null +++ b/internal/database/migrations/20260107120000_create_config_checklists_table.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS config_checklists ( + id BIGSERIAL PRIMARY KEY, + date DATE NOT NULL, + percentage_threshold_bad INTEGER NOT NULL, + percentage_threshold_enough INTEGER NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); diff --git a/internal/database/migrations/20260109074006_add_party_account_number_to_payments.down.sql b/internal/database/migrations/20260109074006_add_party_account_number_to_payments.down.sql new file mode 100644 index 00000000..64eb4839 --- /dev/null +++ b/internal/database/migrations/20260109074006_add_party_account_number_to_payments.down.sql @@ -0,0 +1,6 @@ +BEGIN; + +ALTER TABLE payments + DROP COLUMN IF EXISTS party_account_number; + +COMMIT; diff --git a/internal/database/migrations/20260109074006_add_party_account_number_to_payments.up.sql b/internal/database/migrations/20260109074006_add_party_account_number_to_payments.up.sql new file mode 100644 index 00000000..abd80665 --- /dev/null +++ b/internal/database/migrations/20260109074006_add_party_account_number_to_payments.up.sql @@ -0,0 +1,6 @@ +BEGIN; + +ALTER TABLE payments + ADD COLUMN IF NOT EXISTS party_account_number VARCHAR(50); + +COMMIT; diff --git a/internal/database/migrations/20260109093155_drop_unused_tables.down.sql b/internal/database/migrations/20260109093155_drop_unused_tables.down.sql new file mode 100644 index 00000000..f17c3a80 --- /dev/null +++ b/internal/database/migrations/20260109093155_drop_unused_tables.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS projects; diff --git a/internal/database/migrations/20260109093155_drop_unused_tables.up.sql b/internal/database/migrations/20260109093155_drop_unused_tables.up.sql new file mode 100644 index 00000000..f17c3a80 --- /dev/null +++ b/internal/database/migrations/20260109093155_drop_unused_tables.up.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS projects; diff --git a/internal/database/migrations/20260109093832_update_master_data_fk_restrict.down.sql b/internal/database/migrations/20260109093832_update_master_data_fk_restrict.down.sql new file mode 100644 index 00000000..bc43de5c --- /dev/null +++ b/internal/database/migrations/20260109093832_update_master_data_fk_restrict.down.sql @@ -0,0 +1,20 @@ +-- Revert master data foreign keys to CASCADE delete (except FCR) +ALTER TABLE nonstock_suppliers + DROP CONSTRAINT IF EXISTS nonstock_suppliers_nonstock_id_fkey, + DROP CONSTRAINT IF EXISTS nonstock_suppliers_supplier_id_fkey; + +ALTER TABLE nonstock_suppliers + ADD CONSTRAINT nonstock_suppliers_nonstock_id_fkey FOREIGN KEY (nonstock_id) + REFERENCES nonstocks (id) ON DELETE CASCADE ON UPDATE CASCADE, + ADD CONSTRAINT nonstock_suppliers_supplier_id_fkey FOREIGN KEY (supplier_id) + REFERENCES suppliers (id) ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE product_suppliers + DROP CONSTRAINT IF EXISTS product_suppliers_product_id_fkey, + DROP CONSTRAINT IF EXISTS product_suppliers_supplier_id_fkey; + +ALTER TABLE product_suppliers + ADD CONSTRAINT product_suppliers_product_id_fkey FOREIGN KEY (product_id) + REFERENCES products (id) ON DELETE CASCADE ON UPDATE CASCADE, + ADD CONSTRAINT product_suppliers_supplier_id_fkey FOREIGN KEY (supplier_id) + REFERENCES suppliers (id) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/internal/database/migrations/20260109093832_update_master_data_fk_restrict.up.sql b/internal/database/migrations/20260109093832_update_master_data_fk_restrict.up.sql new file mode 100644 index 00000000..dbb45637 --- /dev/null +++ b/internal/database/migrations/20260109093832_update_master_data_fk_restrict.up.sql @@ -0,0 +1,20 @@ +-- Update master data foreign keys to RESTRICT delete (except FCR) +ALTER TABLE nonstock_suppliers + DROP CONSTRAINT IF EXISTS nonstock_suppliers_nonstock_id_fkey, + DROP CONSTRAINT IF EXISTS nonstock_suppliers_supplier_id_fkey; + +ALTER TABLE nonstock_suppliers + ADD CONSTRAINT nonstock_suppliers_nonstock_id_fkey FOREIGN KEY (nonstock_id) + REFERENCES nonstocks (id) ON DELETE RESTRICT ON UPDATE CASCADE, + ADD CONSTRAINT nonstock_suppliers_supplier_id_fkey FOREIGN KEY (supplier_id) + REFERENCES suppliers (id) ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE product_suppliers + DROP CONSTRAINT IF EXISTS product_suppliers_product_id_fkey, + DROP CONSTRAINT IF EXISTS product_suppliers_supplier_id_fkey; + +ALTER TABLE product_suppliers + ADD CONSTRAINT product_suppliers_product_id_fkey FOREIGN KEY (product_id) + REFERENCES products (id) ON DELETE RESTRICT ON UPDATE CASCADE, + ADD CONSTRAINT product_suppliers_supplier_id_fkey FOREIGN KEY (supplier_id) + REFERENCES suppliers (id) ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/internal/database/migrations/20260109145555_move_fifo_fields_from_laying_transfers_to_detail_tables.down.sql b/internal/database/migrations/20260109145555_move_fifo_fields_from_laying_transfers_to_detail_tables.down.sql new file mode 100644 index 00000000..a74e2882 --- /dev/null +++ b/internal/database/migrations/20260109145555_move_fifo_fields_from_laying_transfers_to_detail_tables.down.sql @@ -0,0 +1,79 @@ +-- Rollback: Revert FIFO fields back to laying_transfers from detail tables + +-- ============================================================================ +-- PART 1: Remove FIFO columns from detail tables +-- ============================================================================ + +-- Add back old qty column first +ALTER TABLE laying_transfer_sources + ADD COLUMN IF NOT EXISTS qty NUMERIC(15, 3) NOT NULL DEFAULT 0; + +ALTER TABLE laying_transfer_targets + ADD COLUMN IF NOT EXISTS qty NUMERIC(15, 3) NOT NULL DEFAULT 0; + +-- Now drop FIFO columns +ALTER TABLE laying_transfer_sources + DROP COLUMN IF EXISTS usage_qty, + DROP COLUMN IF EXISTS pending_usage_qty; + +ALTER TABLE laying_transfer_targets + DROP COLUMN IF EXISTS total_qty, + DROP COLUMN IF EXISTS total_used; + +-- ============================================================================ +-- PART 2: Add back FIFO columns to laying_transfers table +-- ============================================================================ + +-- Add columns back for USABLE role (source warehouse) +ALTER TABLE laying_transfers + ADD COLUMN product_warehouse_id BIGINT, + ADD COLUMN pending_usage_qty NUMERIC(15, 3), + ADD COLUMN usage_qty NUMERIC(15, 3); + +-- Add columns back for STOCKABLE role (destination warehouse) +ALTER TABLE laying_transfers + ADD COLUMN dest_product_warehouse_id BIGINT, + ADD COLUMN total_qty NUMERIC(15, 3) DEFAULT 0 NOT NULL, + ADD COLUMN total_used NUMERIC(15, 3) DEFAULT 0 NOT NULL; + +-- ============================================================================ +-- PART 3: Recreate foreign key constraints +-- ============================================================================ + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'product_warehouses') THEN + -- Add source product warehouse FK + ALTER TABLE laying_transfers + ADD CONSTRAINT fk_laying_transfers_product_warehouse_id + FOREIGN KEY (product_warehouse_id) + REFERENCES product_warehouses(id) + ON DELETE SET NULL; + + -- Add destination product warehouse FK + ALTER TABLE laying_transfers + ADD CONSTRAINT fk_laying_transfers_dest_product_warehouse_id + FOREIGN KEY (dest_product_warehouse_id) + REFERENCES product_warehouses(id) + ON DELETE SET NULL; + END IF; +END $$; + +-- ============================================================================ +-- PART 4: Recreate indexes for performance +-- ============================================================================ + +CREATE INDEX idx_laying_transfers_product_warehouse_id + ON laying_transfers(product_warehouse_id); + +CREATE INDEX idx_laying_transfers_dest_product_warehouse_id + ON laying_transfers(dest_product_warehouse_id); + +-- ============================================================================ +-- PART 5: Recreate comments for documentation +-- ============================================================================ + +COMMENT ON COLUMN laying_transfers.product_warehouse_id IS 'Product warehouse at source (Growing flock) - for USABLE role'; +COMMENT ON COLUMN laying_transfers.dest_product_warehouse_id IS 'Product warehouse at destination (Laying flock) - for STOCKABLE role'; +COMMENT ON COLUMN laying_transfers.total_qty IS 'Total lot quantity introduced to destination warehouse - for STOCKABLE role'; +COMMENT ON COLUMN laying_transfers.total_used IS 'Quantity already consumed from this lot at destination - for FIFO STOCKABLE role'; diff --git a/internal/database/migrations/20260109145555_move_fifo_fields_from_laying_transfers_to_detail_tables.up.sql b/internal/database/migrations/20260109145555_move_fifo_fields_from_laying_transfers_to_detail_tables.up.sql new file mode 100644 index 00000000..19c710d9 --- /dev/null +++ b/internal/database/migrations/20260109145555_move_fifo_fields_from_laying_transfers_to_detail_tables.up.sql @@ -0,0 +1,73 @@ +-- Move FIFO fields from laying_transfers to detail tables (sources & targets) +-- This enables proper FIFO integration for transfer laying with multiple sources and targets + +-- ============================================================================ +-- PART 1: Remove FIFO-related columns from laying_transfers table +-- ============================================================================ + +-- Drop foreign key constraints first +DO $$ +BEGIN + -- Drop source product warehouse FK + IF EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_laying_transfers_product_warehouse_id' + ) THEN + ALTER TABLE laying_transfers + DROP CONSTRAINT fk_laying_transfers_product_warehouse_id; + END IF; + + -- Drop destination product warehouse FK + IF EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_laying_transfers_dest_product_warehouse_id' + ) THEN + ALTER TABLE laying_transfers + DROP CONSTRAINT fk_laying_transfers_dest_product_warehouse_id; + END IF; +END $$; + +-- Drop indexes +DROP INDEX IF EXISTS idx_laying_transfers_product_warehouse_id; +DROP INDEX IF EXISTS idx_laying_transfers_dest_product_warehouse_id; + +-- Remove columns from laying_transfers +ALTER TABLE laying_transfers + DROP COLUMN IF EXISTS product_warehouse_id, + DROP COLUMN IF EXISTS dest_product_warehouse_id, + DROP COLUMN IF EXISTS pending_usage_qty, + DROP COLUMN IF EXISTS usage_qty, + DROP COLUMN IF EXISTS total_qty, + DROP COLUMN IF EXISTS total_used; + +-- ============================================================================ +-- PART 2: Add FIFO columns to laying_transfer_sources (USABLE role) +-- ============================================================================ + +ALTER TABLE laying_transfer_sources + ADD COLUMN usage_qty NUMERIC(15, 3) DEFAULT 0 NOT NULL, + ADD COLUMN pending_usage_qty NUMERIC(15, 3) DEFAULT 0 NOT NULL; + +-- Add comments for documentation +COMMENT ON COLUMN laying_transfer_sources.usage_qty IS 'Quantity consumed from this source - for FIFO USABLE role'; +COMMENT ON COLUMN laying_transfer_sources.pending_usage_qty IS 'Quantity pending to consume from this source - for FIFO USABLE role'; + +-- Drop old qty column as it's replaced by usage_qty +ALTER TABLE laying_transfer_sources + DROP COLUMN IF EXISTS qty; + +-- ============================================================================ +-- PART 3: Add FIFO columns to laying_transfer_targets (STOCKABLE role) +-- ============================================================================ + +ALTER TABLE laying_transfer_targets + ADD COLUMN total_qty NUMERIC(15, 3) DEFAULT 0 NOT NULL, + ADD COLUMN total_used NUMERIC(15, 3) DEFAULT 0 NOT NULL; + +-- Add comments for documentation +COMMENT ON COLUMN laying_transfer_targets.total_qty IS 'Total lot quantity introduced to this target warehouse - for FIFO STOCKABLE role'; +COMMENT ON COLUMN laying_transfer_targets.total_used IS 'Quantity already consumed from this lot at target warehouse - for FIFO STOCKABLE role'; + +-- Drop old qty column as it's replaced by total_qty +ALTER TABLE laying_transfer_targets + DROP COLUMN IF EXISTS qty; diff --git a/internal/database/migrations/20260110105231_adjust_recording.down.sql b/internal/database/migrations/20260110105231_adjust_recording.down.sql new file mode 100644 index 00000000..2bcbcb67 --- /dev/null +++ b/internal/database/migrations/20260110105231_adjust_recording.down.sql @@ -0,0 +1,59 @@ +BEGIN; + +ALTER TABLE recordings + DROP CONSTRAINT IF EXISTS chk_recordings_nonnegatives_v4; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'recordings' AND column_name = 'hen_day' + ) THEN + ALTER TABLE recordings RENAME COLUMN hen_day TO hand_day; + END IF; +END $$; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'recordings' AND column_name = 'hen_house' + ) THEN + ALTER TABLE recordings RENAME COLUMN hen_house TO hand_house; + END IF; +END $$; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'recordings' AND column_name = 'egg_mass' + ) THEN + ALTER TABLE recordings RENAME COLUMN egg_mass TO egg_mesh; + END IF; +END $$; + +ALTER TABLE recordings + ADD COLUMN IF NOT EXISTS daily_gain NUMERIC(7,3), + ADD COLUMN IF NOT EXISTS avg_daily_gain NUMERIC(7,3); + +ALTER TABLE recordings + ADD CONSTRAINT chk_recordings_nonnegatives_v3 CHECK ( + (total_depletion_qty IS NULL OR total_depletion_qty >= 0) AND + (cum_depletion_rate IS NULL OR cum_depletion_rate >= 0) AND + (daily_gain IS NULL OR daily_gain >= 0) AND + (avg_daily_gain IS NULL OR avg_daily_gain >= 0) AND + (cum_intake IS NULL OR cum_intake >= 0) AND + (fcr_value IS NULL OR fcr_value >= 0) AND + (total_chick_qty IS NULL OR total_chick_qty >= 0) AND + (hand_day IS NULL OR hand_day >= 0) AND + (hand_house IS NULL OR hand_house >= 0) AND + (feed_intake IS NULL OR feed_intake >= 0) AND + (egg_mesh IS NULL OR egg_mesh >= 0) AND + (egg_weight IS NULL OR egg_weight >= 0) + ); + +COMMIT; diff --git a/internal/database/migrations/20260110105231_adjust_recording.up.sql b/internal/database/migrations/20260110105231_adjust_recording.up.sql new file mode 100644 index 00000000..ac947910 --- /dev/null +++ b/internal/database/migrations/20260110105231_adjust_recording.up.sql @@ -0,0 +1,57 @@ +BEGIN; + +ALTER TABLE recordings + DROP CONSTRAINT IF EXISTS chk_recordings_nonnegatives_v3; + +ALTER TABLE recordings + DROP COLUMN IF EXISTS daily_gain, + DROP COLUMN IF EXISTS avg_daily_gain; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'recordings' AND column_name = 'hand_day' + ) THEN + ALTER TABLE recordings RENAME COLUMN hand_day TO hen_day; + END IF; +END $$; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'recordings' AND column_name = 'hand_house' + ) THEN + ALTER TABLE recordings RENAME COLUMN hand_house TO hen_house; + END IF; +END $$; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'recordings' AND column_name = 'egg_mesh' + ) THEN + ALTER TABLE recordings RENAME COLUMN egg_mesh TO egg_mass; + END IF; +END $$; + +ALTER TABLE recordings + ADD CONSTRAINT chk_recordings_nonnegatives_v4 CHECK ( + (total_depletion_qty IS NULL OR total_depletion_qty >= 0) AND + (cum_depletion_rate IS NULL OR cum_depletion_rate >= 0) AND + (cum_intake IS NULL OR cum_intake >= 0) AND + (fcr_value IS NULL OR fcr_value >= 0) AND + (total_chick_qty IS NULL OR total_chick_qty >= 0) AND + (hen_day IS NULL OR hen_day >= 0) AND + (hen_house IS NULL OR hen_house >= 0) AND + (feed_intake IS NULL OR feed_intake >= 0) AND + (egg_mass IS NULL OR egg_mass >= 0) AND + (egg_weight IS NULL OR egg_weight >= 0) + ); + +COMMIT; diff --git a/internal/database/seed/seeder.go b/internal/database/seed/seeder.go index b4f6886e..4f666812 100644 --- a/internal/database/seed/seeder.go +++ b/internal/database/seed/seeder.go @@ -299,6 +299,7 @@ func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories Tax: tax, ExpiryPeriod: seed.Expiry, CreatedBy: createdBy, + IsVisible: seed.IsVisible, } if err := tx.Create(&product).Error; err != nil { return err diff --git a/internal/entities/config-checklist.go b/internal/entities/config-checklist.go new file mode 100644 index 00000000..563d88de --- /dev/null +++ b/internal/entities/config-checklist.go @@ -0,0 +1,17 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type ConfigChecklist struct { + Id uint `gorm:"primaryKey"` + Date time.Time `gorm:"type:date;not null"` + PercentageThresholdBad int `gorm:"not null"` + PercentageThresholdEnough int `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} diff --git a/internal/entities/dashboard.go b/internal/entities/dashboard.go new file mode 100644 index 00000000..ab9f4ea5 --- /dev/null +++ b/internal/entities/dashboard.go @@ -0,0 +1,18 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type Dashboard struct { + Id uint `gorm:"primaryKey"` + Name string `gorm:"not null;uniqueIndex:idx_name,where:deleted_at IS NULL"` + CreatedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` +} diff --git a/internal/entities/expense_nonstock.go b/internal/entities/expense_nonstock.go index ccd4194c..946b7a08 100644 --- a/internal/entities/expense_nonstock.go +++ b/internal/entities/expense_nonstock.go @@ -5,15 +5,15 @@ import ( ) type ExpenseNonstock struct { - Id uint64 `gorm:"primaryKey;autoIncrement"` - ExpenseId *uint64 `gorm:""` - ProjectFlockKandangId *uint64 `gorm:""` - KandangId *uint64 `gorm:""` - NonstockId *uint64 `gorm:""` - Qty float64 `gorm:"type:numeric(15,3);not null"` - Price float64 `gorm:"type:numeric(15,3);not null;column:price"` - Notes string `gorm:"type:text;column:notes"` - CreatedAt time.Time `gorm:"type:timestamptz;default:CURRENT_TIMESTAMP"` + Id uint64 `gorm:"primaryKey;autoIncrement"` + ExpenseId *uint64 `gorm:""` + ProjectFlockKandangId *uint64 `gorm:""` + KandangId *uint64 `gorm:""` + NonstockId *uint64 `gorm:""` + Qty float64 `gorm:"type:numeric(15,3);not null"` + Price float64 `gorm:"type:numeric(15,3);not null;column:price"` + Notes string `gorm:"type:text;column:notes"` + CreatedAt time.Time `gorm:"type:timestamptz;default:CURRENT_TIMESTAMP"` Expense *Expense `gorm:"foreignKey:ExpenseId;references:Id"` ProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:ProjectFlockKandangId;references:Id"` diff --git a/internal/entities/laying_transfer.go b/internal/entities/laying_transfer.go index dd173042..f983519f 100644 --- a/internal/entities/laying_transfer.go +++ b/internal/entities/laying_transfer.go @@ -12,18 +12,16 @@ type LayingTransfer struct { 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:"-"` + 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 index 6b54bd84..e0b85774 100644 --- a/internal/entities/laying_transfer_source.go +++ b/internal/entities/laying_transfer_source.go @@ -11,7 +11,8 @@ type LayingTransferSource struct { LayingTransferId uint `gorm:"index;not null"` SourceProjectFlockKandangId uint `gorm:"not null"` ProductWarehouseId *uint `gorm:""` - Qty float64 `gorm:"type:numeric(15,3);not null"` + UsageQty float64 `gorm:"type:numeric(15,3);default:0;not null"` // FIFO USABLE field + PendingUsageQty float64 `gorm:"type:numeric(15,3);default:0;not null"` // FIFO USABLE field Note string `gorm:"type:text"` CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` diff --git a/internal/entities/laying_transfer_target.go b/internal/entities/laying_transfer_target.go index dec98f1f..560e09f7 100644 --- a/internal/entities/laying_transfer_target.go +++ b/internal/entities/laying_transfer_target.go @@ -10,7 +10,8 @@ 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"` + TotalQty float64 `gorm:"type:numeric(15,3);default:0;not null"` // FIFO STOCKABLE field + TotalUsed float64 `gorm:"type:numeric(15,3);default:0;not null"` // FIFO STOCKABLE field ProductWarehouseId *uint `gorm:""` Note string `gorm:"type:text"` CreatedAt time.Time `gorm:"autoCreateTime"` diff --git a/internal/entities/payment.go b/internal/entities/payment.go index e48800fb..55575f20 100644 --- a/internal/entities/payment.go +++ b/internal/entities/payment.go @@ -7,22 +7,23 @@ import ( ) type Payment struct { - Id uint `gorm:"primaryKey;autoIncrement"` - PaymentCode string `gorm:"type:varchar(50);not null"` - ReferenceNumber *string `gorm:"type:varchar(100)"` - TransactionType string `gorm:"type:varchar(50)"` - PartyType string `gorm:"type:varchar(50);not null;index:payments_party_polymorphic,priority:1"` - PartyId uint `gorm:"not null;index:payments_party_polymorphic,priority:2"` - PaymentDate time.Time `gorm:"not null"` - PaymentMethod string `gorm:"type:varchar(20);not null"` - BankId *uint `gorm:"not null;index:idx_payments_bank_id"` - Direction string `gorm:"type:varchar(5);not null"` - Nominal float64 `gorm:"type:numeric(15,3);not null"` - Notes string `gorm:"type:text;not null"` - CreatedAt time.Time `gorm:"autoCreateTime"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - CreatedBy uint `gorm:"index" json:"-"` + Id uint `gorm:"primaryKey;autoIncrement"` + PaymentCode string `gorm:"type:varchar(50);not null"` + ReferenceNumber *string `gorm:"type:varchar(100)"` + TransactionType string `gorm:"type:varchar(50)"` + PartyType string `gorm:"type:varchar(50);not null;index:payments_party_polymorphic,priority:1"` + PartyId uint `gorm:"not null;index:payments_party_polymorphic,priority:2"` + PartyAccountNumber *string `gorm:"type:varchar(50)"` + PaymentDate time.Time `gorm:"not null"` + PaymentMethod string `gorm:"type:varchar(20);not null"` + BankId *uint `gorm:"not null;index:idx_payments_bank_id"` + Direction string `gorm:"type:varchar(5);not null"` + Nominal float64 `gorm:"type:numeric(15,3);not null"` + Notes string `gorm:"type:text;not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + CreatedBy uint `gorm:"index" json:"-"` BankWarehouse Bank `gorm:"foreignKey:BankId;references:Id"` CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` diff --git a/internal/entities/phase.go b/internal/entities/phase.go index 178ed695..0d924a1a 100644 --- a/internal/entities/phase.go +++ b/internal/entities/phase.go @@ -7,12 +7,13 @@ import ( ) type Phases struct { - Id uint `gorm:"primaryKey"` - Name string `gorm:"not null"` - IsActive bool `gorm:"not null;default:true"` - Category string `gorm:"type:category_code;not null"` - CreatedAt time.Time `gorm:"autoCreateTime"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + Id uint `gorm:"primaryKey"` + Name string `gorm:"not null"` + IsActive bool `gorm:"not null;default:true"` + Category string `gorm:"type:category_code;not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + ActivityCount int `gorm:"-" json:"-"` Activities []PhaseActivity `gorm:"foreignKey:PhaseId;references:Id"` } diff --git a/internal/entities/product.go b/internal/entities/product.go index d8ce59fc..f86d9a0a 100644 --- a/internal/entities/product.go +++ b/internal/entities/product.go @@ -21,7 +21,7 @@ type Product struct { CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - IsVisible bool `gorm:"column:is_visible;default:true"` + IsVisible bool `` CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` Uom Uom `gorm:"foreignKey:UomId;references:Id"` diff --git a/internal/entities/project_flock_kandang_uniformity.go b/internal/entities/project_flock_kandang_uniformity.go index bf320c72..9171eefa 100644 --- a/internal/entities/project_flock_kandang_uniformity.go +++ b/internal/entities/project_flock_kandang_uniformity.go @@ -1,20 +1,24 @@ package entities -import "time" +import ( + "encoding/json" + "time" +) type ProjectFlockKandangUniformity struct { - Id uint `gorm:"primaryKey"` - Uniformity float64 `gorm:"type:numeric(15,3)"` - Week int `gorm:"not null"` - Cv float64 `gorm:"type:numeric(15,3)"` - ChickQtyOfWeight float64 `gorm:"type:numeric(15,3)"` - MeanUp float64 `gorm:"type:numeric(15,3)"` - MeanDown float64 `gorm:"type:numeric(15,3)"` - ProjectFlockKandangId uint `gorm:"not null"` - UniformQty float64 `gorm:"type:numeric(15,3)"` - NotUniformQty float64 `gorm:"type:numeric(15,3)"` - UniformDate *time.Time `gorm:"type:timestamptz"` - CreatedBy uint `gorm:"not null"` + Id uint `gorm:"primaryKey"` + Uniformity float64 `gorm:"type:numeric(15,3)"` + Week int `gorm:"not null"` + Cv float64 `gorm:"type:numeric(15,3)"` + ChickQtyOfWeight float64 `gorm:"type:numeric(15,3)"` + MeanUp float64 `gorm:"type:numeric(15,3)"` + MeanDown float64 `gorm:"type:numeric(15,3)"` + ProjectFlockKandangId uint `gorm:"not null"` + UniformQty float64 `gorm:"type:numeric(15,3)"` + NotUniformQty float64 `gorm:"type:numeric(15,3)"` + ChartData json.RawMessage `gorm:"type:jsonb"` + UniformDate *time.Time `gorm:"type:timestamptz"` + CreatedBy uint `gorm:"not null"` ProjectFlockKandang ProjectFlockKandang `gorm:"foreignKey:ProjectFlockKandangId;references:Id"` CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` diff --git a/internal/entities/projectflock_kandang.go b/internal/entities/projectflock_kandang.go index 0ce4fc25..5fa20404 100644 --- a/internal/entities/projectflock_kandang.go +++ b/internal/entities/projectflock_kandang.go @@ -10,8 +10,9 @@ type ProjectFlockKandang struct { ClosedAt *time.Time `gorm:"index"` 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:"-"` + ProjectFlock ProjectFlock `gorm:"foreignKey:ProjectFlockId;references:Id"` + Kandang Kandang `gorm:"foreignKey:KandangId;references:Id"` + Chickins []ProjectChickin `gorm:"foreignKey:ProjectFlockKandangId;references:Id"` + LatestProjectFlockApproval *Approval `gorm:"-" json:"-"` + LatestChickinApproval *Approval `gorm:"-" json:"-"` } diff --git a/internal/entities/recording.go b/internal/entities/recording.go index 7f952a62..0cc5dc03 100644 --- a/internal/entities/recording.go +++ b/internal/entities/recording.go @@ -16,10 +16,10 @@ type Recording struct { CumIntake *int `gorm:"column:cum_intake"` FcrValue *float64 `gorm:"column:fcr_value"` TotalChickQty *float64 `gorm:"column:total_chick_qty"` - HandDay *float64 `gorm:"column:hand_day"` - HandHouse *float64 `gorm:"column:hand_house"` + HenDay *float64 `gorm:"column:hen_day"` + HenHouse *float64 `gorm:"column:hen_house"` FeedIntake *float64 `gorm:"column:feed_intake"` - EggMesh *float64 `gorm:"column:egg_mesh"` + EggMass *float64 `gorm:"column:egg_mass"` EggWeight *float64 `gorm:"column:egg_weight"` CreatedBy uint `gorm:"column:created_by"` CreatedAt time.Time `gorm:"autoCreateTime"` @@ -34,11 +34,11 @@ type Recording struct { LatestApproval *Approval `gorm:"-" json:"-"` - StandardHandDay *float64 `gorm:"-"` - StandardHandHouse *float64 `gorm:"-"` + StandardHenDay *float64 `gorm:"-"` + StandardHenHouse *float64 `gorm:"-"` StandardFeedIntake *float64 `gorm:"-"` StandardMaxDepletion *float64 `gorm:"-"` - StandardEggMesh *float64 `gorm:"-"` + StandardEggMass *float64 `gorm:"-"` StandardEggWeight *float64 `gorm:"-"` StandardFcr *float64 `gorm:"-"` } diff --git a/internal/entities/recording_egg.go b/internal/entities/recording_egg.go index 90546448..68269728 100644 --- a/internal/entities/recording_egg.go +++ b/internal/entities/recording_egg.go @@ -12,7 +12,7 @@ type RecordingEgg struct { CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` - ProductFlagName *string `gorm:"column:product_flag_name" json:"-"` + ProductFlagName *string `gorm:"->;column:product_flag_name" json:"-"` CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` } diff --git a/internal/entities/stock_transfer_detail.go b/internal/entities/stock_transfer_detail.go index 9ab27824..dd24aadb 100644 --- a/internal/entities/stock_transfer_detail.go +++ b/internal/entities/stock_transfer_detail.go @@ -8,27 +8,22 @@ type StockTransferDetail struct { StockTransferId uint64 ProductId uint64 - // === FIFO FIELDS - SOURCE WAREHOUSE (Usable) === - // Tracking stock yang DIAMBIL dari source warehouse SourceProductWarehouseID *uint64 `gorm:"column:source_product_warehouse_id"` UsageQty float64 `gorm:"column:usage_qty;default:0"` // Actual yang berhasil diambil PendingQty float64 `gorm:"column:pending_qty;default:0"` // Yang pending (nunggu stock) - - // === FIFO FIELDS - DESTINATION WAREHOUSE (Stockable) === - // Tracking stock yang DITAMBAHKAN ke destination warehouse - DestProductWarehouseID *uint64 `gorm:"column:dest_product_warehouse_id"` - TotalQty float64 `gorm:"column:total_qty;default:0"` // Total lot yang tersedia - TotalUsed float64 `gorm:"column:total_used;default:0"` // Yang sudah dipakai dari lot ini - - // === METADATA === - CreatedAt time.Time - UpdatedAt time.Time - DeletedAt *time.Time `gorm:"index"` + DestProductWarehouseID *uint64 `gorm:"column:dest_product_warehouse_id"` + TotalQty float64 `gorm:"column:total_qty;default:0"` // Total lot yang tersedia + TotalUsed float64 `gorm:"column:total_used;default:0"` // Yang sudah dipakai dari lot ini + ExpenseNonstockId *uint64 `gorm:"column:expense_nonstock_id"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time `gorm:"index"` // === RELATIONS === StockTransfer *StockTransfer `gorm:"foreignKey:StockTransferId"` Product *Product `gorm:"foreignKey:ProductId"` SourceProductWarehouse *ProductWarehouse `gorm:"foreignKey:SourceProductWarehouseID"` DestProductWarehouse *ProductWarehouse `gorm:"foreignKey:DestProductWarehouseID"` + ExpenseNonstock *ExpenseNonstock `gorm:"foreignKey:ExpenseNonstockId;references:Id"` DeliveryItems []StockTransferDeliveryItem `gorm:"foreignKey:StockTransferDetailId"` } diff --git a/internal/middleware/permissions.go b/internal/middleware/permissions.go index e9148927..10741bff 100644 --- a/internal/middleware/permissions.go +++ b/internal/middleware/permissions.go @@ -1,5 +1,8 @@ package middleware +const( + P_DashboardGetAll = "lti.dashboard.list" +) // project-flock const ( P_ProjectFlockKandangsClosing = "lti.production.project_flock_kandangs.closing" @@ -44,7 +47,9 @@ const ( P_ReportExpenseGetAll = "lti.repport.expense.list" P_ReportDeliveryGetAll = "lti.repport.delivery.list" P_ReportPurchaseSupplierGetAll = "lti.repport.purchasesupplier.list" + P_ReportDebtSupplierGetAll = "lti.repport.debtsupplier.list" P_ReportHppPerKandangGetAll = "lti.repport.gethppperkandang.list" + P_ReportProductionResultGetAll = "lti.repport.production_result.list" ) const ( @@ -134,18 +139,18 @@ const ( P_NonstocksUpdateOne = "lti.master.nonstocks.update" P_NonstocksDeleteOne = "lti.master.nonstocks.delete" - P_ProductCategoriesGetAll = "lti.master.Product_categories.list" - P_ProductCategoriesGetOne = "lti.master.Product_categories.detail" - P_ProductCategoriesCreateOne = "lti.master.Product_categories.create" - P_ProductCategoriesUpdateOne = "lti.master.Product_categories.update" - P_ProductCategoriesDeleteOne = "lti.master.Product_categories.delete" - - P_ProductsGetAll = "lti.master.Products.list" - P_ProductsGetOne = "lti.master.Products.detail" - P_ProductsCreateOne = "lti.master.Products.create" - P_ProductsUpdateOne = "lti.master.Products.update" - P_ProductsDeleteOne = "lti.master.Products.delete" + P_ProductCategoriesGetAll = "lti.master.product_categories.list" + P_ProductCategoriesGetOne = "lti.master.product_categories.detail" + P_ProductCategoriesCreateOne = "lti.master.product_categories.create" + P_ProductCategoriesUpdateOne = "lti.master.product_categories.update" + P_ProductCategoriesDeleteOne = "lti.master.product_categories.delete" + P_ProductsGetAll = "lti.master.products.list" + P_ProductsGetOne = "lti.master.products.detail" + P_ProductsCreateOne = "lti.master.products.create" + P_ProductsUpdateOne = "lti.master.products.update" + P_ProductsDeleteOne = "lti.master.products.delete" + P_SuppliersGetAll = "lti.master.suppliers.list" P_SuppliersGetOne = "lti.master.suppliers.detail" P_SuppliersCreateOne = "lti.master.suppliers.create" @@ -207,15 +212,15 @@ const ( ) const ( - P_PurchaseGetAll = "lti.Purchase.list" - P_PurchaseGetOne = "lti.Purchase.detail" - P_PurchaseCreateOne = "lti.Purchase.create" - P_PurchaseUpdateOne = "lti.Purchase.update" - P_PurchaseDeleteOne = "lti.Purchase.delete" - P_PurchaseItemDeleteOne = "lti.Purchase.delete.item" - P_PurchaseReceive = "lti.Purchase.receive" - P_PurchaseApprovalStaff = "lti.Purchase.approve.staff" - P_PurchaseApprovalManager = "lti.Purchase.approve.manager" + P_PurchaseGetAll = "lti.purchase.list" + P_PurchaseGetOne = "lti.purchase.detail" + P_PurchaseCreateOne = "lti.purchase.create" + P_PurchaseUpdateOne = "lti.purchase.update" + P_PurchaseDeleteOne = "lti.purchase.delete" + P_PurchaseItemDeleteOne = "lti.purchase.delete.item" + P_PurchaseReceive = "lti.purchase.receive" + P_PurchaseApprovalStaff = "lti.purchase.approve.staff" + P_PurchaseApprovalManager = "lti.purchase.approve.manager" ) const ( diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index c4580efb..6ab2d398 100644 --- a/internal/modules/closings/controllers/closing.controller.go +++ b/internal/modules/closings/controllers/closing.controller.go @@ -247,7 +247,7 @@ func (u *ClosingController) GetSapronakByKandang(c *fiber.Ctx) error { } func (u *ClosingController) GetClosingKeuangan(c *fiber.Ctx) error { - param := c.Params("project_flock_id") + param := c.Params("projectFlockId") projectFlockID, err := strconv.Atoi(param) if err != nil { diff --git a/internal/modules/closings/dto/closingMarketing.dto.go b/internal/modules/closings/dto/closingMarketing.dto.go index 4c7b4d35..42d95be2 100644 --- a/internal/modules/closings/dto/closingMarketing.dto.go +++ b/internal/modules/closings/dto/closingMarketing.dto.go @@ -55,16 +55,21 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO { kandang = &mapped } + var realizationDate time.Time + if e.DeliveryDate != nil { + realizationDate = *e.DeliveryDate + } + doNumber := deliveryOrdersDTO.GenerateDeliveryOrderNumber(e.MarketingProduct.Marketing.SoNumber, e.DeliveryDate, e.MarketingProduct.ProductWarehouse.Warehouse.Id) return SalesDTO{ Id: e.Id, - RealizationDate: *e.DeliveryDate, + RealizationDate: realizationDate, Age: age, DoNumber: doNumber, Product: product, Customer: customer, - Qty: e.UsageQty, // Show allocated quantity from FIFO + Qty: e.UsageQty, Weight: e.TotalWeight, AvgWeight: e.AvgWeight, Price: e.UnitPrice, diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index 4948ae5e..9d08d083 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -10,6 +10,7 @@ import ( entity "gitlab.com/mbugroup/lti-api.git/internal/entities" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "gorm.io/gorm" ) @@ -914,9 +915,8 @@ func (r *ClosingRepositoryImpl) GetActualUsageCostByProjectFlockID(ctx context.C var rows []ActualUsageCostRow - // Part 1: Get usage from recording_stocks (PAKAN, OVK, Vitamin, Obat, Kimia, dll) - purchaseStockableKey := "PURCHASE_ITEMS" - transferStockableKey := "STOCK_TRANSFER_DETAILS" + purchaseStockableKey := fifo.StockableKeyPurchaseItems.String() + transferStockableKey := fifo.StockableKeyStockTransferIn.String() recordingQuery := db. Table("recordings AS r"). @@ -982,7 +982,6 @@ func (r *ClosingRepositoryImpl) GetActualUsageCostByProjectFlockID(ctx context.C return nil, err } - // Part 2: Get usage from project_chickins (DOC, Pullet) chickinQuery := db. Table("project_chickins AS pc"). Select(` @@ -1006,7 +1005,6 @@ func (r *ClosingRepositoryImpl) GetActualUsageCostByProjectFlockID(ctx context.C return nil, err } - // Merge results rows = append(rows, chickinRows...) return rows, nil diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index ddf52b49..245fd24c 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -94,7 +94,7 @@ func (s closingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]dto.Cl closings, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withClosingRelations(db) if params.Search != "" { - return db.Where("flock_name LIKE ?", "%"+params.Search+"%") + return db.Where("flock_name ILIKE ?", "%"+params.Search+"%") } return db.Order("created_at DESC").Order("updated_at DESC") }) @@ -151,9 +151,19 @@ func (s closingService) GetPenjualan(c *fiber.Ctx, projectFlockID uint) ([]entit return nil, err } if len(realisasi) == 0 { - return nil, fiber.NewError(fiber.StatusNotFound, "Penjualan realisasi not found") + return []entity.MarketingDeliveryProduct{}, nil } - return realisasi, nil + + filtered := make([]entity.MarketingDeliveryProduct, 0, len(realisasi)) + for _, item := range realisasi { + + if item.UsageQty != 0 || item.TotalWeight != 0 || item.AvgWeight != 0 || + item.UnitPrice != 0 || item.TotalPrice != 0 { + filtered = append(filtered, item) + } + } + + return filtered, nil } func (s closingService) GetClosingSummary(c *fiber.Ctx, projectFlockID uint) (*dto.ClosingSummaryDTO, error) { @@ -403,18 +413,9 @@ func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint) (*dto.Ove } func (s closingService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (*dto.ReportResponse, error) { - if projectFlockID == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id") - } if err := commonSvc.EnsureRelations(c.Context(), - commonSvc.RelationCheck{Name: "Project Flock", ID: &projectFlockID, Exists: func(ctx context.Context, id uint) (bool, error) { - _, err := s.ProjectFlockRepo.GetByID(ctx, id, nil) - if err != nil && errors.Is(err, gorm.ErrRecordNotFound) { - return false, nil - } - return err == nil, err - }}, + commonSvc.RelationCheck{Name: "Project Flock", ID: &projectFlockID, Exists: s.ProjectFlockRepo.IdExists}, ); err != nil { return nil, err } @@ -429,13 +430,11 @@ func (s closingService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (* return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch budgets") } - // Get actual usage cost instead of purchase items actualUsageRows, err := s.Repository.GetActualUsageCostByProjectFlockID(c.Context(), projectFlockID) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch actual usage cost") } - // Convert actual usage rows to pseudo purchase items purchaseItems := s.convertActualUsageToPurchaseItems(c.Context(), actualUsageRows) realizations, err := s.ExpenseRealizationRepo.GetByProjectFlockID(c.Context(), projectFlockID) diff --git a/internal/modules/daily-checklists/controllers/daily-checklist.controller.go b/internal/modules/daily-checklists/controllers/daily-checklist.controller.go index b5a9b7b5..d97424fa 100644 --- a/internal/modules/daily-checklists/controllers/daily-checklist.controller.go +++ b/internal/modules/daily-checklists/controllers/daily-checklist.controller.go @@ -7,6 +7,7 @@ import ( "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/dto" service "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/services" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/validations" + kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" "gitlab.com/mbugroup/lti-api.git/internal/response" "github.com/gofiber/fiber/v2" @@ -28,6 +29,18 @@ func (u *DailyChecklistController) GetAll(c *fiber.Ctx) error { Limit: c.QueryInt("limit", 10), Search: c.Query("search", ""), } + query.DateFrom = c.Query("date_from", "") + query.DateTo = c.Query("date_to", "") + query.Status = c.Query("status", "") + + if kandangParam := c.Query("kandang_id", ""); kandangParam != "" { + kandangID, err := strconv.ParseUint(kandangParam, 10, 64) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid kandang_id") + } + value := uint(kandangID) + query.KandangID = &value + } if query.Page < 1 || query.Limit < 1 { return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") @@ -38,6 +51,41 @@ func (u *DailyChecklistController) GetAll(c *fiber.Ctx) error { return err } + responseData := make([]dto.DailyChecklistListDTO, len(result)) + for i, item := range result { + var name string + if item.Name != nil { + name = *item.Name + } + + var status string + if item.Status != nil { + status = *item.Status + } + + var kandang *kandangDTO.KandangRelationDTO + if item.Kandang.Id != 0 { + mapped := kandangDTO.ToKandangRelationDTO(item.Kandang) + kandang = &mapped + } + + responseData[i] = dto.DailyChecklistListDTO{ + Id: item.ID, + Name: name, + Status: status, + Category: item.Category, + RejectReason: item.RejectReason, + Date: item.Date, + Kandang: kandang, + CreatedUser: nil, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + TotalPhase: item.TotalPhase, + TotalActivity: item.TotalActivity, + Progress: item.Progress, + } + } + return c.Status(fiber.StatusOK). JSON(response.SuccessWithPaginate[dto.DailyChecklistListDTO]{ Code: fiber.StatusOK, @@ -49,29 +97,233 @@ func (u *DailyChecklistController) GetAll(c *fiber.Ctx) error { TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), TotalResults: totalResults, }, - Data: dto.ToDailyChecklistListDTOs(result), + Data: responseData, }) } +func (u *DailyChecklistController) GetSummary(c *fiber.Ctx) error { + query := &validation.SummaryQuery{ + DateFrom: c.Query("date_from"), + DateTo: c.Query("date_to"), + Category: c.Query("category"), + } + + if query.DateFrom == "" || query.DateTo == "" { + return fiber.NewError(fiber.StatusBadRequest, "date_from and date_to are required") + } + + if kandangParam := c.Query("kandang_id"); kandangParam != "" { + kandangID, err := strconv.ParseUint(kandangParam, 10, 64) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid kandang_id") + } + value := uint(kandangID) + query.KandangID = &value + } + + result, err := u.DailyChecklistService.GetSummary(c, query) + if err != nil { + return err + } + + type summaryResponse struct { + PerformanceOverview []dto.DailyChecklistPerformanceOverviewDTO `json:"performance_overview"` + TrackingABK []dto.DailyChecklistSummaryDTO `json:"tracking_abk"` + } + + performanceMap := make(map[uint]*dto.DailyChecklistPerformanceOverviewDTO) + tracking := make([]dto.DailyChecklistSummaryDTO, len(result)) + + for i, summary := range result { + tracking[i] = dto.DailyChecklistSummaryDTO{ + EmployeeID: summary.EmployeeID, + EmployeeName: summary.EmployeeName, + KandangID: summary.KandangID, + KandangName: summary.KandangName, + TotalActivity: summary.TotalActivity, + ActivityDone: summary.ActivityDone, + ActivityLeft: summary.ActivityLeft, + CompletionRate: summary.CompletionRate, + LastActivity: summary.LastActivity, + } + + if _, ok := performanceMap[summary.EmployeeID]; !ok { + performanceMap[summary.EmployeeID] = &dto.DailyChecklistPerformanceOverviewDTO{ + EmployeeID: summary.EmployeeID, + EmployeeName: summary.EmployeeName, + Kandang: dto.DailyChecklistReportEntityDTO{ + Id: summary.KandangID, + Name: summary.KandangName, + }, + } + } + + performanceMap[summary.EmployeeID].TotalActivity += summary.TotalActivity + performanceMap[summary.EmployeeID].ActivityDone += summary.ActivityDone + performanceMap[summary.EmployeeID].ActivityLeft += summary.ActivityLeft + } + + performance := make([]dto.DailyChecklistPerformanceOverviewDTO, 0, len(performanceMap)) + for _, v := range performanceMap { + performance = append(performance, *v) + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get daily checklist summary successfully", + Data: summaryResponse{ + PerformanceOverview: performance, + TrackingABK: tracking, + }, + }) +} + +func (u *DailyChecklistController) GetReport(c *fiber.Ctx) error { + query := &validation.ReportQuery{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Month: c.QueryInt("bulan", 0), + Year: c.QueryInt("tahun", 0), + } + + parseUintParam := func(param string) (*uint, error) { + if param == "" { + return nil, nil + } + value, err := strconv.ParseUint(param, 10, 64) + if err != nil { + return nil, err + } + u := uint(value) + return &u, nil + } + + if val, err := parseUintParam(c.Query("area_id", "")); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid area_id") + } else { + query.AreaID = val + } + + if val, err := parseUintParam(c.Query("location_id", "")); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid location_id") + } else { + query.LocationID = val + } + + if val, err := parseUintParam(c.Query("kandang_id", "")); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid kandang_id") + } else { + query.KandangID = val + } + + if val, err := parseUintParam(c.Query("employee_id", "")); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid employee_id") + } else { + query.EmployeeID = val + } + + if val, err := parseUintParam(c.Query("phase_id", "")); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid phase_id") + } else { + query.PhaseID = val + } + + if query.Month == 0 || query.Year == 0 { + return fiber.NewError(fiber.StatusBadRequest, "bulan and tahun are required") + } + + result, totalResults, err := u.DailyChecklistService.GetReport(c, query) + withoutActivities := func(src map[string]int) map[string]int { + if src == nil { + return map[string]int{} + } + return src + } + + if err != nil { + return err + } + + responseData := make([]dto.DailyChecklistReportDTO, len(result)) + for i, item := range result { + responseData[i] = dto.DailyChecklistReportDTO{ + Area: dto.DailyChecklistReportEntityDTO{ + Id: item.AreaID, + Name: item.AreaName, + }, + Farm: dto.DailyChecklistReportEntityDTO{ + Id: item.LocationID, + Name: item.LocationName, + }, + Kandang: dto.DailyChecklistReportEntityDTO{ + Id: item.KandangID, + Name: item.KandangName, + }, + ABK: dto.DailyChecklistReportEntityDTO{ + Id: item.EmployeeID, + Name: item.EmployeeName, + }, + Phase: item.PhaseName, + DailyActivities: withoutActivities(item.DailyActivities), + Summary: dto.DailyChecklistReportSummaryDTO{ + TotalChecklist: item.Summary.TotalChecklist, + JumlahHariEfektif: item.Summary.JumlahHariEfektif, + AbkPercentage: item.Summary.AbkPercentage, + KandangPercentage: item.Summary.KandangPercentage, + Kategori: dto.DailyChecklistReportCategoryDTO{ + Kurang: item.Summary.Category.Kurang, + Cukup: item.Summary.Category.Cukup, + Baik: item.Summary.Category.Baik, + }, + }, + } + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.DailyChecklistReportDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get daily checklist report successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: responseData, + }) +} func (u *DailyChecklistController) GetOne(c *fiber.Ctx) error { - param := c.Params("id") + param := c.Params("idDailyChecklist") id, err := strconv.Atoi(param) if err != nil { return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") } - result, err := u.DailyChecklistService.GetOne(c, uint(id)) + detail, err := u.DailyChecklistService.GetDetail(c, uint(id)) if err != nil { return err } + documentDTOs := make([]dto.DailyChecklistDocumentDTO, len(detail.DocumentURLs)) + for i, doc := range detail.DocumentURLs { + documentDTOs[i] = dto.DailyChecklistDocumentDTO{ + Id: doc.ID, + Name: doc.Name, + Size: doc.Size, + URL: doc.URL, + } + } + return c.Status(fiber.StatusOK). JSON(response.Success{ Code: fiber.StatusOK, Status: "success", Message: "Get dailyChecklist successfully", - Data: dto.ToDailyChecklistListDTO(*result), + Data: dto.ToDailyChecklistDetailDTO(detail.Checklist, detail.Phases, detail.Tasks, detail.AssignedEmployees, detail.TotalActivities, detail.Progress, documentDTOs), }) } @@ -98,13 +350,19 @@ func (u *DailyChecklistController) CreateOne(c *fiber.Ctx) error { func (u *DailyChecklistController) UpdateOne(c *fiber.Ctx) error { req := new(validation.Update) - param := c.Params("id") + param := c.Params("idDailyChecklist") id, err := strconv.Atoi(param) if err != nil { return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") } + form, err := c.MultipartForm() + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid multipart form") + } + req.Documents = form.File["documents"] + if err := c.BodyParser(req); err != nil { return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") } @@ -124,7 +382,7 @@ func (u *DailyChecklistController) UpdateOne(c *fiber.Ctx) error { } func (u *DailyChecklistController) DeleteOne(c *fiber.Ctx) error { - param := c.Params("id") + param := c.Params("idDailyChecklist") id, err := strconv.Atoi(param) if err != nil { @@ -217,6 +475,32 @@ func (u *DailyChecklistController) RemoveAssignment(c *fiber.Ctx) error { }) } +func (u *DailyChecklistController) GetPhaseByIdChecklist(c *fiber.Ctx) error { + param := c.Params("idDailyChecklist") + id, err := strconv.Atoi(param) + if err != nil || id <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid daily checklist id") + } + + phaseIDs, err := u.DailyChecklistService.GetChecklistPhaseIDs(c, uint(id)) + if err != nil { + return err + } + + responseData := make([]map[string]uint, len(phaseIDs)) + for i, phaseID := range phaseIDs { + responseData[i] = map[string]uint{"phase_id": phaseID} + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get phases successfully", + Data: responseData, + }) +} + func (u *DailyChecklistController) GetAllTasks(c *fiber.Ctx) error { checklistParam := c.Query("checklist_id", "") if checklistParam == "" { @@ -241,3 +525,21 @@ func (u *DailyChecklistController) GetAllTasks(c *fiber.Ctx) error { Data: result, }) } + +func (u *DailyChecklistController) UpdateAssignment(c *fiber.Ctx) error { + req := new(validation.UpdateAssignment) + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + if err := u.DailyChecklistService.UpdateAssignment(c, req); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Assignment updated successfully", + }) +} diff --git a/internal/modules/daily-checklists/dto/daily-checklist.dto.go b/internal/modules/daily-checklists/dto/daily-checklist.dto.go index 31953def..58ca6bb0 100644 --- a/internal/modules/daily-checklists/dto/daily-checklist.dto.go +++ b/internal/modules/daily-checklists/dto/daily-checklist.dto.go @@ -4,6 +4,10 @@ import ( "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + employeeDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/employees/dto" + kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" + phaseActivityDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phase-activities/dto" + phasesDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) @@ -15,15 +19,110 @@ type DailyChecklistRelationDTO struct { } type DailyChecklistListDTO struct { - Id uint `json:"id"` - Name string `json:"name"` - CreatedUser *userDTO.UserRelationDTO `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"` + Category string `json:"category"` + Date time.Time `json:"date"` + Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + TotalPhase int `json:"total_phase"` + TotalActivity int `json:"total_activity"` + Progress int `json:"progress"` + RejectReason *string `json:"reject_reason"` } type DailyChecklistDetailDTO struct { DailyChecklistListDTO + Phases []DailyChecklistPhaseDTO `json:"phases"` + Tasks []DailyChecklistActivityTaskDTO `json:"tasks"` + AssignedEmployees []employeeDTO.EmployeesRelationDTO `json:"assigned_employees"` + TotalActivity int `json:"total_activity"` + Progress float64 `json:"progress"` + DocumentURLs []DailyChecklistDocumentDTO `json:"document_urls"` +} + +type DailyChecklistDocumentDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + Size float64 `json:"size"` + URL string `json:"url"` +} + +type DailyChecklistSummaryDTO struct { + EmployeeID uint `json:"employee_id"` + EmployeeName string `json:"employee_name"` + KandangID uint `json:"kandang_id"` + KandangName string `json:"kandang_name"` + TotalActivity int `json:"total_activity"` + ActivityDone int `json:"activity_done"` + ActivityLeft int `json:"activity_left"` + CompletionRate int `json:"completion_rate"` + LastActivity *time.Time `json:"last_activity,omitempty"` +} + +type DailyChecklistPerformanceOverviewDTO struct { + EmployeeID uint `json:"employee_id"` + EmployeeName string `json:"employee_name"` + Kandang DailyChecklistReportEntityDTO `json:"kandang"` + TotalActivity int `json:"total_activity"` + ActivityDone int `json:"activity_done"` + ActivityLeft int `json:"activity_left"` +} + +type DailyChecklistReportDTO struct { + Area DailyChecklistReportEntityDTO `json:"area"` + Farm DailyChecklistReportEntityDTO `json:"farm"` + Kandang DailyChecklistReportEntityDTO `json:"kandang"` + ABK DailyChecklistReportEntityDTO `json:"abk"` + Phase string `json:"phase"` + DailyActivities map[string]int `json:"daily_activities"` + Summary DailyChecklistReportSummaryDTO `json:"summary"` +} + +type DailyChecklistReportEntityDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type DailyChecklistReportSummaryDTO struct { + TotalChecklist int `json:"total_checklist"` + JumlahHariEfektif int `json:"jumlah_hari_efektif"` + AbkPercentage int `json:"abk_percentage"` + KandangPercentage int `json:"kandang_percentage"` + Kategori DailyChecklistReportCategoryDTO `json:"kategori"` +} + +type DailyChecklistReportCategoryDTO struct { + Kurang int `json:"kurang"` + Cukup int `json:"cukup"` + Baik int `json:"baik"` +} + +type DailyChecklistPhaseDTO struct { + Id uint `json:"id"` + PhaseId uint `json:"phase_id"` + Phase phasesDTO.PhasesListDTO `json:"phase"` +} + +type DailyChecklistActivityTaskDTO struct { + Id uint `json:"id"` + ChecklistId uint `json:"checklist_id"` + PhaseId uint `json:"phase_id"` + PhaseActivityId uint `json:"phase_activity_id"` + TimeType *string `json:"time_type"` + Notes *string `json:"notes"` + Phase phasesDTO.PhasesListDTO `json:"phase"` + PhaseActivity phaseActivityDTO.PhaseActivityListDTO `json:"phase_activity"` + Assignments []DailyChecklistAssignmentDTO `json:"assignments"` +} + +type DailyChecklistAssignmentDTO struct { + Employee employeeDTO.EmployeesRelationDTO `json:"employee"` + Checked bool `json:"checked"` + Note *string `json:"note"` } // === Mapper Functions === @@ -52,25 +151,94 @@ func ToDailyChecklistListDTO(e entity.DailyChecklist) DailyChecklistListDTO { name = *e.Name } + var status string + if e.Status != nil { + status = *e.Status + } + + var kandang *kandangDTO.KandangRelationDTO + if e.Kandang.Id != 0 { + mapped := kandangDTO.ToKandangRelationDTO(e.Kandang) + kandang = &mapped + } + return DailyChecklistListDTO{ - Id: e.Id, - Name: name, - CreatedAt: e.CreatedAt, - UpdatedAt: e.UpdatedAt, - CreatedUser: createdUser, + Id: e.Id, + Name: name, + Status: status, + Category: e.Category, + Date: e.Date, + Kandang: kandang, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, + TotalPhase: 0, + TotalActivity: 0, + Progress: 0, + RejectReason: e.RejectReason, } } -func ToDailyChecklistListDTOs(e []entity.DailyChecklist) []DailyChecklistListDTO { - result := make([]DailyChecklistListDTO, len(e)) - for i, r := range e { - result[i] = ToDailyChecklistListDTO(r) +func ToDailyChecklistDetailDTO(checklist entity.DailyChecklist, phases []entity.DailyChecklistPhase, tasks []entity.DailyChecklistActivityTask, assignedEmployees []entity.Employee, totalActivities int, progress float64, documentURLs []DailyChecklistDocumentDTO) DailyChecklistDetailDTO { + phaseDTOs := make([]DailyChecklistPhaseDTO, 0, len(phases)) + for _, phase := range phases { + phaseDTOs = append(phaseDTOs, DailyChecklistPhaseDTO{ + Id: phase.Id, + PhaseId: phase.PhaseId, + Phase: phasesDTO.ToPhasesListDTO(phase.Phase), + }) + } + + taskDTOs := make([]DailyChecklistActivityTaskDTO, 0, len(tasks)) + for _, task := range tasks { + mappedAssignments := make([]DailyChecklistAssignmentDTO, 0, len(task.Assignments)) + for _, assignment := range task.Assignments { + if assignment.Employee.Id == 0 { + continue + } + mapped := DailyChecklistAssignmentDTO{ + Employee: employeeDTO.ToEmployeesRelationDTO(assignment.Employee), + Checked: assignment.Checked, + Note: assignment.Note, + } + mappedAssignments = append(mappedAssignments, mapped) + } + + phaseDTO := phasesDTO.PhasesListDTO{} + if task.Phase.Id != 0 { + phaseDTO = phasesDTO.ToPhasesListDTO(task.Phase) + } + + activityDTO := phaseActivityDTO.PhaseActivityListDTO{} + if task.PhaseActivity.Id != 0 { + activityDTO = phaseActivityDTO.ToPhaseActivityListDTO(task.PhaseActivity) + } + + taskDTOs = append(taskDTOs, DailyChecklistActivityTaskDTO{ + Id: task.Id, + ChecklistId: task.ChecklistId, + PhaseId: task.PhaseId, + PhaseActivityId: task.PhaseActivityId, + TimeType: task.TimeType, + Notes: task.Notes, + Phase: phaseDTO, + PhaseActivity: activityDTO, + Assignments: mappedAssignments, + }) + } + + assignedDTOs := make([]employeeDTO.EmployeesRelationDTO, 0, len(assignedEmployees)) + for _, emp := range assignedEmployees { + assignedDTOs = append(assignedDTOs, employeeDTO.ToEmployeesRelationDTO(emp)) } - return result -} -func ToDailyChecklistDetailDTO(e entity.DailyChecklist) DailyChecklistDetailDTO { return DailyChecklistDetailDTO{ - DailyChecklistListDTO: ToDailyChecklistListDTO(e), + DailyChecklistListDTO: ToDailyChecklistListDTO(checklist), + Phases: phaseDTOs, + Tasks: taskDTOs, + AssignedEmployees: assignedDTOs, + TotalActivity: totalActivities, + Progress: progress, + DocumentURLs: documentURLs, } } diff --git a/internal/modules/daily-checklists/module.go b/internal/modules/daily-checklists/module.go index bc82d5f6..a1455501 100644 --- a/internal/modules/daily-checklists/module.go +++ b/internal/modules/daily-checklists/module.go @@ -1,10 +1,15 @@ package dailyChecklists import ( + "context" + "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" rDailyChecklist "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/repositories" sDailyChecklist "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/services" rPhases "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/repositories" @@ -19,8 +24,13 @@ func (DailyChecklistModule) RegisterRoutes(router fiber.Router, db *gorm.DB, val dailyChecklistRepo := rDailyChecklist.NewDailyChecklistRepository(db) phasesRepo := rPhases.NewPhasesRepository(db) userRepo := rUser.NewUserRepository(db) + documentRepo := commonRepo.NewDocumentRepository(db) + documentSvc, err := commonSvc.NewDocumentServiceFromConfig(context.Background(), documentRepo) + if err != nil { + panic(fmt.Sprintf("failed to create document service: %v", err)) + } - dailyChecklistService := sDailyChecklist.NewDailyChecklistService(dailyChecklistRepo, phasesRepo, validate) + dailyChecklistService := sDailyChecklist.NewDailyChecklistService(dailyChecklistRepo, phasesRepo, validate, documentSvc) userService := sUser.NewUserService(userRepo, validate) DailyChecklistRoutes(router, userService, dailyChecklistService) diff --git a/internal/modules/daily-checklists/route.go b/internal/modules/daily-checklists/route.go index c8542671..0f6657c0 100644 --- a/internal/modules/daily-checklists/route.go +++ b/internal/modules/daily-checklists/route.go @@ -1,7 +1,7 @@ package dailyChecklists 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/daily-checklists/controllers" dailyChecklist "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,23 +13,51 @@ func DailyChecklistRoutes(v1 fiber.Router, u user.UserService, s dailyChecklist. ctrl := controller.NewDailyChecklistController(s) route := v1.Group("/daily-checklists") - route.Use(m.Auth(u)) + // route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) + route.Get("/report", ctrl.GetReport) + + route.Get("/summary", ctrl.GetSummary) + + route.Get("/report", ctrl.GetReport) + + // create daily checklist route.Post("/", ctrl.CreateOne) + // get detail data daily checklist by id + route.Get("/relation/:idDailyChecklist", ctrl.GetOne) + + // get phases by daily checklist id + route.Get("/phase/:idDailyChecklist", ctrl.GetPhaseByIdChecklist) + // create task + /* + ketika add phase + */ route.Post("/phase/:idDailyChecklist", ctrl.CreateDailyChecklistPhase) // create assigment + /* + ketika add ABK + */ route.Post("/assignment/:idDailyChecklist", ctrl.CreateAssignment) + // remove assignment + /* + ketika remove ABK + */ route.Delete("/:idDailyChecklist/assignments/:idEmployee", ctrl.RemoveAssignment) //get all tasks route.Get("/tasks", ctrl.GetAllTasks) - route.Get("/:id", ctrl.GetOne) - route.Patch("/:id", ctrl.UpdateOne) - route.Delete("/:id", ctrl.DeleteOne) + // update assignment + /* + ketika check dan uncheck tugas oleh ABK + */ + route.Post("/assignment", ctrl.UpdateAssignment) + + route.Patch("/:idDailyChecklist", ctrl.UpdateOne) + route.Delete("/:idDailyChecklist", ctrl.DeleteOne) } diff --git a/internal/modules/daily-checklists/services/daily-checklist.service.go b/internal/modules/daily-checklists/services/daily-checklist.service.go index bf5320e6..f306c74d 100644 --- a/internal/modules/daily-checklists/services/daily-checklist.service.go +++ b/internal/modules/daily-checklists/services/daily-checklist.service.go @@ -2,11 +2,14 @@ package service import ( "errors" + "math" + "sort" "strconv" "strings" "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + middleware "gitlab.com/mbugroup/lti-api.git/internal/middleware" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/validations" phaseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/repositories" @@ -15,12 +18,13 @@ import ( "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "github.com/sirupsen/logrus" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" "gorm.io/gorm" "gorm.io/gorm/clause" ) type DailyChecklistService interface { - GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.DailyChecklist, int64, error) + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]DailyChecklistListItem, int64, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.DailyChecklist, error) CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.DailyChecklist, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.DailyChecklist, error) @@ -29,48 +33,264 @@ type DailyChecklistService interface { AssignTasks(ctx *fiber.Ctx, id uint, req *validation.AssignTask) error RemoveAssignment(ctx *fiber.Ctx, id uint, employeeID uint) error GetTasks(ctx *fiber.Ctx, checklistID uint) ([]entity.DailyChecklistActivityTask, error) + UpdateAssignment(ctx *fiber.Ctx, req *validation.UpdateAssignment) error + GetChecklistPhaseIDs(ctx *fiber.Ctx, checklistID uint) ([]uint, error) + GetDetail(ctx *fiber.Ctx, id uint) (*DailyChecklistDetail, error) + GetSummary(ctx *fiber.Ctx, params *validation.SummaryQuery) ([]DailyChecklistSummary, error) + GetReport(ctx *fiber.Ctx, params *validation.ReportQuery) ([]DailyChecklistReportItem, int64, error) } type dailyChecklistService struct { - Log *logrus.Logger - Validate *validator.Validate - Repository repository.DailyChecklistRepository - PhaseRepo phaseRepo.PhasesRepository + Log *logrus.Logger + Validate *validator.Validate + Repository repository.DailyChecklistRepository + PhaseRepo phaseRepo.PhasesRepository + DocumentSvc commonSvc.DocumentService } -func NewDailyChecklistService(repo repository.DailyChecklistRepository, phaseRepo phaseRepo.PhasesRepository, validate *validator.Validate) DailyChecklistService { +type DailyChecklistDocument struct { + ID uint + Name string + Size float64 + URL string +} + +type DailyChecklistDetail struct { + Checklist entity.DailyChecklist + Phases []entity.DailyChecklistPhase + Tasks []entity.DailyChecklistActivityTask + AssignedEmployees []entity.Employee + TotalActivities int + Progress float64 + DocumentURLs []DailyChecklistDocument +} + +type DailyChecklistListItem struct { + ID uint + Name *string + Date time.Time + Category string + Status *string + RejectReason *string + CreatedAt time.Time + UpdatedAt time.Time + Kandang entity.Kandang + TotalPhase int + TotalActivity int + Progress int +} + +type DailyChecklistSummary struct { + EmployeeID uint + EmployeeName string + KandangID uint + KandangName string + TotalActivity int + ActivityDone int + ActivityLeft int + CompletionRate int + LastActivity *time.Time +} + +type DailyChecklistReportItem struct { + AreaID uint + AreaName string + LocationID uint + LocationName string + KandangID uint + KandangName string + EmployeeID uint + EmployeeName string + PhaseName string + DailyActivities map[string]int + Summary DailyChecklistReportSummary +} + +type DailyChecklistReportSummary struct { + TotalChecklist int + JumlahHariEfektif int + AbkPercentage int + KandangPercentage int + Category DailyChecklistReportCategory +} + +type DailyChecklistReportCategory struct { + Kurang int + Cukup int + Baik int +} + +func NewDailyChecklistService(repo repository.DailyChecklistRepository, phaseRepo phaseRepo.PhasesRepository, validate *validator.Validate, documentSvc commonSvc.DocumentService) DailyChecklistService { return &dailyChecklistService{ - Log: utils.Log, - Validate: validate, - Repository: repo, - PhaseRepo: phaseRepo, + Log: utils.Log, + Validate: validate, + Repository: repo, + PhaseRepo: phaseRepo, + DocumentSvc: documentSvc, } } func (s dailyChecklistService) withRelations(db *gorm.DB) *gorm.DB { - return db + return db.Preload("Kandang") } -func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.DailyChecklist, int64, error) { +func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([]DailyChecklistListItem, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err } offset := (params.Page - 1) * params.Limit - dailyChecklists, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { - db = s.withRelations(db) - if params.Search != "" { - return db.Where("name LIKE ?", "%"+params.Search+"%") - } - return db.Order("created_at DESC").Order("updated_at DESC") - }) + db := s.Repository.DB().WithContext(c.Context()). + Table("daily_checklists dc"). + Joins("JOIN kandangs k ON k.id = dc.kandang_id") - if err != nil { + if params.DateFrom != "" { + dateFrom, err := time.Parse("2006-01-02", params.DateFrom) + if err != nil { + return nil, 0, fiber.NewError(fiber.StatusBadRequest, "invalid date_from format, use YYYY-MM-DD") + } + db = db.Where("dc.date >= ?", dateFrom) + } + + if params.DateTo != "" { + dateTo, err := time.Parse("2006-01-02", params.DateTo) + if err != nil { + return nil, 0, fiber.NewError(fiber.StatusBadRequest, "invalid date_to format, use YYYY-MM-DD") + } + db = db.Where("dc.date <= ?", dateTo) + } + + if params.KandangID != nil { + db = db.Where("dc.kandang_id = ?", *params.KandangID) + } + + if params.Status != "" { + db = db.Where("dc.status = ?", params.Status) + } + + if params.Search != "" { + like := "%" + params.Search + "%" + db = db.Where("(k.name ILIKE ? OR dc.category::text ILIKE ?)", like, like) + } + + countDB := db.Session(&gorm.Session{}) + var total int64 + if err := countDB.Count(&total).Error; err != nil { + s.Log.Errorf("Failed to count dailyChecklists: %+v", err) + return nil, 0, err + } + + type dailyChecklistListRow struct { + ID uint + Name *string + Date time.Time + Category string + Status *string + RejectReason *string + CreatedAt time.Time + UpdatedAt time.Time + KandangID uint + TotalPhase int64 + TotalActivity int64 + TotalAssignments int64 + CompletedAssignments int64 + } + + rows := make([]dailyChecklistListRow, 0) + selectDB := db.Session(&gorm.Session{}) + if err := selectDB. + Select(` + dc.id, + dc.name, + dc.date, + dc.category, + dc.status, + dc.reject_reason, + dc.created_at, + dc.updated_at, + dc.kandang_id, + COALESCE(( + SELECT COUNT(*) + FROM daily_checklist_phases dcp + WHERE dcp.checklist_id = dc.id + ), 0) AS total_phase, + COALESCE(( + SELECT COUNT(pa.id) + FROM daily_checklist_phases dcp + JOIN phase_activities pa ON pa.phase_id = dcp.phase_id + WHERE dcp.checklist_id = dc.id AND pa.deleted_at IS NULL + ), 0) AS total_activity, + COALESCE(( + SELECT COUNT(*) + FROM daily_checklist_activity_task_assignments dca + JOIN daily_checklist_activity_tasks dcat ON dcat.id = dca.task_id + WHERE dcat.checklist_id = dc.id + ), 0) AS total_assignments, + COALESCE(( + SELECT COUNT(*) + FROM daily_checklist_activity_task_assignments dca + JOIN daily_checklist_activity_tasks dcat ON dcat.id = dca.task_id + WHERE dcat.checklist_id = dc.id AND dca.checked + ), 0) AS completed_assignments`). + Order("dc.date DESC, dc.created_at DESC"). + Offset(offset). + Limit(params.Limit). + Scan(&rows).Error; err != nil { s.Log.Errorf("Failed to get dailyChecklists: %+v", err) return nil, 0, err } - return dailyChecklists, total, nil + + kandangIDs := make([]uint, 0, len(rows)) + seen := make(map[uint]struct{}) + for _, row := range rows { + if _, ok := seen[row.KandangID]; !ok { + seen[row.KandangID] = struct{}{} + kandangIDs = append(kandangIDs, row.KandangID) + } + } + + kandangMap := make(map[uint]entity.Kandang) + if len(kandangIDs) > 0 { + var kandangs []entity.Kandang + if err := s.Repository.DB().WithContext(c.Context()). + Where("id IN ?", kandangIDs). + Preload("Location"). + Preload("Pic"). + Preload("CreatedUser"). + Find(&kandangs).Error; err != nil { + s.Log.Errorf("Failed to get kandangs for daily checklist list: %+v", err) + return nil, 0, err + } + for _, kandang := range kandangs { + kandangMap[kandang.Id] = kandang + } + } + + items := make([]DailyChecklistListItem, len(rows)) + for i, row := range rows { + progress := 0 + if row.TotalAssignments > 0 { + progress = int(math.Round(float64(row.CompletedAssignments) / float64(row.TotalAssignments) * 100)) + } + + items[i] = DailyChecklistListItem{ + ID: row.ID, + Name: row.Name, + Date: row.Date, + Category: row.Category, + Status: row.Status, + RejectReason: row.RejectReason, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + Kandang: kandangMap[row.KandangID], + TotalPhase: int(row.TotalPhase), + TotalActivity: int(row.TotalActivity), + Progress: progress, + } + } + + return items, total, nil } func (s dailyChecklistService) GetOne(c *fiber.Ctx, id uint) (*entity.DailyChecklist, error) { @@ -85,6 +305,96 @@ func (s dailyChecklistService) GetOne(c *fiber.Ctx, id uint) (*entity.DailyCheck return dailyChecklist, nil } +func (s dailyChecklistService) GetDetail(c *fiber.Ctx, id uint) (*DailyChecklistDetail, error) { + checklist, err := s.GetOne(c, id) + if err != nil { + return nil, err + } + + db := s.Repository.DB().WithContext(c.Context()) + + var phases []entity.DailyChecklistPhase + if err := db. + Where("checklist_id = ?", id). + Preload("Phase", func(tx *gorm.DB) *gorm.DB { + return tx.Preload("Activities") + }). + Order("created_at ASC"). + Find(&phases).Error; err != nil { + s.Log.Errorf("Failed to get phases for daily checklist %d: %+v", id, err) + return nil, err + } + + var tasks []entity.DailyChecklistActivityTask + if err := db. + Where("checklist_id = ?", id). + Preload("Phase"). + Preload("PhaseActivity"). + Preload("Assignments", func(tx *gorm.DB) *gorm.DB { + return tx.Preload("Employee") + }). + Order("created_at ASC"). + Find(&tasks).Error; err != nil { + s.Log.Errorf("Failed to get tasks for daily checklist %d: %+v", id, err) + return nil, err + } + + assignedEmployees := collectAssignedEmployees(tasks) + + totalActivities := 0 + for _, phase := range phases { + totalActivities += len(phase.Phase.Activities) + } + + var totalAssignments, completedAssignments int + for _, task := range tasks { + for _, assignment := range task.Assignments { + totalAssignments++ + if assignment.Checked { + completedAssignments++ + } + } + } + + var progress float64 + if totalAssignments > 0 { + progress = math.Round((float64(completedAssignments) / float64(totalAssignments)) * 100) + } + + documentURLs := make([]DailyChecklistDocument, 0) + if s.DocumentSvc != nil { + documents, err := s.DocumentSvc.ListByTarget(c.Context(), string(utils.DocumentTypeDailyChecklist), uint64(id)) + if err != nil { + s.Log.Errorf("Failed to list documents for daily checklist %d: %+v", id, err) + return nil, err + } + + for _, doc := range documents { + url, err := s.DocumentSvc.PresignURL(c.Context(), doc, 0) + if err != nil { + s.Log.Errorf("Failed to presign document %d for daily checklist %d: %+v", doc.Id, id, err) + continue + } + documentURLs = append(documentURLs, DailyChecklistDocument{ + ID: doc.Id, + Name: doc.Name, + Size: doc.Size, + URL: url, + }) + } + } + + return &DailyChecklistDetail{ + Checklist: *checklist, + Phases: phases, + Tasks: tasks, + AssignedEmployees: assignedEmployees, + TotalActivities: totalActivities, + Progress: progress, + DocumentURLs: documentURLs, + }, nil +} + func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.DailyChecklist, error) { if err := s.Validate.Struct(req); err != nil { return nil, err @@ -107,7 +417,7 @@ func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create) err = s.Repository.DB().WithContext(c.Context()).Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "date"}, {Name: "kandang_id"}, {Name: "category"}}, - DoUpdates: clause.Assignments(map[string]any{"status": status, "updated_at": time.Now()}), + DoUpdates: clause.Assignments(map[string]any{"updated_at": time.Now()}), }).Create(createBody).Error if err != nil { s.Log.Errorf("Failed to upsert dailyChecklist: %+v", err) @@ -122,14 +432,62 @@ func (s dailyChecklistService) UpdateOne(c *fiber.Ctx, req *validation.Update, i return nil, err } - updateBody := make(map[string]any) - - if req.Name != nil { - updateBody["name"] = *req.Name + deletedIDs := make([]uint, 0) + if req.DeletedDocumentIDs != nil { + parts := strings.Split(*req.DeletedDocumentIDs, ",") + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + parsedID, err := strconv.ParseUint(part, 10, 64) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "invalid deleted_document_ids") + } + deletedIDs = append(deletedIDs, uint(parsedID)) + } } - if len(updateBody) == 0 { - return s.GetOne(c, id) + updateBody := map[string]any{ + "status": req.Status, + } + + if req.RejectReason != nil { + updateBody["reject_reason"] = *req.RejectReason + } + + actorID, err := middleware.ActorIDFromContext(c) + if err != nil { + return &entity.DailyChecklist{}, fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context") + } + + if len(deletedIDs) > 0 && s.DocumentSvc != nil { + if err := s.DocumentSvc.DeleteDocuments(c.Context(), deletedIDs, true); err != nil { + s.Log.Errorf("Failed to delete daily checklist documents: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to delete daily checklist documents") + } + } + + if len(req.Documents) > 0 { + documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents)) + for idx, file := range req.Documents { + documentFiles = append(documentFiles, commonSvc.DocumentFile{ + File: file, + Type: string(utils.DocumentTypeDailyChecklist), + Index: &idx, + }) + } + + _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ + DocumentableType: string(utils.DocumentTypeDailyChecklist), + DocumentableID: uint64(id), + CreatedBy: &actorID, + Files: documentFiles, + }) + if err != nil { + s.Log.Errorf("Failed to upload daily checklist documents: %+v", err) + return &entity.DailyChecklist{}, fiber.NewError(fiber.StatusInternalServerError, "Failed to upload daily checklist documents") + } } if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { @@ -296,6 +654,71 @@ func (s dailyChecklistService) GetTasks(c *fiber.Ctx, checklistID uint) ([]entit return tasks, nil } +func (s dailyChecklistService) GetChecklistPhaseIDs(c *fiber.Ctx, checklistID uint) ([]uint, error) { + if checklistID == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "checklist_id is required") + } + + if _, err := s.Repository.GetByID(c.Context(), checklistID, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found") + } + return nil, err + } + + var phases []entity.DailyChecklistPhase + if err := s.Repository.DB().WithContext(c.Context()). + Where("checklist_id = ?", checklistID). + Order("created_at ASC"). + Find(&phases).Error; err != nil { + s.Log.Errorf("Failed to get daily checklist phases: %+v", err) + return nil, err + } + + phaseIDs := make([]uint, len(phases)) + for i, p := range phases { + phaseIDs[i] = p.PhaseId + } + + return phaseIDs, nil +} + +func (s dailyChecklistService) UpdateAssignment(c *fiber.Ctx, req *validation.UpdateAssignment) error { + if err := s.Validate.Struct(req); err != nil { + return err + } + + task := new(entity.DailyChecklistActivityTask) + if err := s.Repository.DB().WithContext(c.Context()).First(task, req.TaskID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Task not found") + } + return err + } + + if req.EmployeeID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid employee id") + } + + updates := map[string]any{"updated_at": time.Now()} + if req.Checked != nil { + updates["checked"] = *req.Checked + } + if req.Note != nil { + updates["note"] = *req.Note + } + + return s.Repository.DB().WithContext(c.Context()).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "task_id"}, {Name: "employee_id"}}, + DoUpdates: clause.Assignments(updates), + }).Create(&entity.DailyChecklistActivityTaskAssignment{ + TaskId: req.TaskID, + EmployeeId: req.EmployeeID, + Checked: req.Checked != nil && *req.Checked, + Note: req.Note, + }).Error +} + func parsePhaseIDs(raw string) ([]uint, error) { parts := strings.Split(raw, ",") result := make([]uint, 0, len(parts)) @@ -355,6 +778,32 @@ func collectTaskIDs(tasks []entity.DailyChecklistActivityTask) []uint { } return result } + +func collectAssignedEmployees(tasks []entity.DailyChecklistActivityTask) []entity.Employee { + employeeMap := make(map[uint]entity.Employee) + for _, task := range tasks { + for _, assignment := range task.Assignments { + if assignment.Employee.Id == 0 { + continue + } + if _, exists := employeeMap[assignment.Employee.Id]; exists { + continue + } + employeeMap[assignment.Employee.Id] = assignment.Employee + } + } + + employees := make([]entity.Employee, 0, len(employeeMap)) + for _, emp := range employeeMap { + employees = append(employees, emp) + } + + sort.Slice(employees, func(i, j int) bool { + return employees[i].Id < employees[j].Id + }) + + return employees +} func (s dailyChecklistService) AssignTasks(c *fiber.Ctx, id uint, req *validation.AssignTask) error { if err := s.Validate.Struct(req); err != nil { return err @@ -408,3 +857,414 @@ func (s dailyChecklistService) AssignTasks(c *fiber.Ctx, id uint, req *validatio return nil } + +func (s dailyChecklistService) GetSummary(c *fiber.Ctx, params *validation.SummaryQuery) ([]DailyChecklistSummary, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, err + } + + dateFrom, err := time.Parse("2006-01-02", params.DateFrom) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "invalid date_from format, use YYYY-MM-DD") + } + + dateTo, err := time.Parse("2006-01-02", params.DateTo) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "invalid date_to format, use YYYY-MM-DD") + } + + type summaryRow struct { + EmployeeID uint + EmployeeName string + KandangID uint + KandangName string + TotalActivity int64 + ActivityDone int64 + ActivityLeft int64 + LastActivity *time.Time + } + + rows := make([]summaryRow, 0) + db := s.Repository.DB().WithContext(c.Context()). + Table("daily_checklist_activity_task_assignments AS a"). + Select(` + a.employee_id, + e.name AS employee_name, + d.kandang_id, + k.name AS kandang_name, + COUNT(*) AS total_activity, + SUM(CASE WHEN a.checked THEN 1 ELSE 0 END) AS activity_done, + SUM(CASE WHEN NOT a.checked THEN 1 ELSE 0 END) AS activity_left, + MAX(a.updated_at) AS last_activity`). + Joins("JOIN daily_checklist_activity_tasks t ON t.id = a.task_id"). + Joins("JOIN daily_checklists d ON d.id = t.checklist_id"). + Joins("JOIN kandangs k ON k.id = d.kandang_id"). + Joins("JOIN employees e ON e.id = a.employee_id"). + Where("d.date BETWEEN ? AND ? AND d.status = ?", dateFrom, dateTo, "APPROVED") + + if params.Category != "" { + db = db.Where("d.category = ?", params.Category) + } + + if params.KandangID != nil { + db = db.Where("d.kandang_id = ?", *params.KandangID) + } + + if err := db. + Group("a.employee_id, e.name, d.kandang_id, k.name"). + Order("e.name ASC"). + Find(&rows).Error; err != nil { + s.Log.Errorf("Failed to get daily checklist summary: %+v", err) + return nil, err + } + + summaries := make([]DailyChecklistSummary, len(rows)) + for i, row := range rows { + completionRate := 0 + if row.TotalActivity > 0 { + completionRate = int(math.Round(float64(row.ActivityDone) / float64(row.TotalActivity) * 100)) + } + + summaries[i] = DailyChecklistSummary{ + EmployeeID: row.EmployeeID, + EmployeeName: row.EmployeeName, + KandangID: row.KandangID, + KandangName: row.KandangName, + TotalActivity: int(row.TotalActivity), + ActivityDone: int(row.ActivityDone), + ActivityLeft: int(row.ActivityLeft), + CompletionRate: completionRate, + LastActivity: row.LastActivity, + } + } + + return summaries, nil +} + +func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.ReportQuery) ([]DailyChecklistReportItem, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + buildBase := func() *gorm.DB { + db := s.Repository.DB().WithContext(c.Context()). + Table("daily_checklist_activity_task_assignments AS dca"). + Joins("JOIN daily_checklist_activity_tasks dcat ON dcat.id = dca.task_id"). + Joins("JOIN daily_checklists dc ON dc.id = dcat.checklist_id"). + Joins("JOIN employees e ON e.id = dca.employee_id"). + Joins("JOIN kandangs k ON k.id = dc.kandang_id"). + Joins("JOIN locations loc ON loc.id = k.location_id"). + Joins("JOIN areas a ON a.id = loc.area_id"). + Joins("JOIN phases p ON p.id = dcat.phase_id"). + Where("EXTRACT(MONTH FROM dc.date) = ?", params.Month). + Where("EXTRACT(YEAR FROM dc.date) = ?", params.Year). + Where("dc.status = ?", "APPROVED") + + if params.AreaID != nil { + db = db.Where("a.id = ?", *params.AreaID) + } + if params.LocationID != nil { + db = db.Where("loc.id = ?", *params.LocationID) + } + if params.KandangID != nil { + db = db.Where("k.id = ?", *params.KandangID) + } + if params.EmployeeID != nil { + db = db.Where("dca.employee_id = ?", *params.EmployeeID) + } + if params.PhaseID != nil { + db = db.Where("p.id = ?", *params.PhaseID) + } + return db + } + + buildGroupedQuery := func() *gorm.DB { + return buildBase(). + Select(` + a.id AS area_id, + a.name AS area_name, + loc.id AS location_id, + loc.name AS location_name, + k.id AS kandang_id, + k.name AS kandang_name, + e.id AS employee_id, + e.name AS employee_name, + p.id AS phase_id, + p.name AS phase_name, + SUM(CASE WHEN dca.checked THEN 1 ELSE 0 END) AS completed_assignments, + COUNT(*) AS total_assignments`). + Group("a.id, a.name, loc.id, loc.name, k.id, k.name, e.id, e.name, p.id, p.name") + } + + var total int64 + groupedForCount := buildGroupedQuery() + if err := s.Repository.DB().WithContext(c.Context()). + Table("(?) AS grouped", groupedForCount). + Count(&total).Error; err != nil { + s.Log.Errorf("Failed to count report data: %+v", err) + return nil, 0, err + } + + type reportRow struct { + AreaID uint + AreaName string + LocationID uint + LocationName string + KandangID uint + KandangName string + EmployeeID uint + EmployeeName string + PhaseID uint + PhaseName string + CompletedAssignments int64 + TotalAssignments int64 + } + + rows := make([]reportRow, 0) + if err := buildGroupedQuery(). + Order("a.name, loc.name, k.name, e.name"). + Offset(offset). + Limit(params.Limit). + Scan(&rows).Error; err != nil { + s.Log.Errorf("Failed to fetch report data: %+v", err) + return nil, 0, err + } + + if len(rows) == 0 { + return []DailyChecklistReportItem{}, total, nil + } + + type comboKey struct { + EmployeeID uint + KandangID uint + PhaseID uint + } + + type dailyActivityStat struct { + Completed int + Total int + Date time.Time + } + + employeeIDs := make([]uint, 0) + kandangIDs := make([]uint, 0) + phaseIDs := make([]uint, 0) + comboSet := make(map[comboKey]struct{}) + employeeSet := make(map[uint]struct{}) + kandangSet := make(map[uint]struct{}) + phaseSet := make(map[uint]struct{}) + + for _, row := range rows { + key := comboKey{EmployeeID: row.EmployeeID, KandangID: row.KandangID, PhaseID: row.PhaseID} + comboSet[key] = struct{}{} + if _, ok := employeeSet[row.EmployeeID]; !ok { + employeeSet[row.EmployeeID] = struct{}{} + employeeIDs = append(employeeIDs, row.EmployeeID) + } + if _, ok := kandangSet[row.KandangID]; !ok { + kandangSet[row.KandangID] = struct{}{} + kandangIDs = append(kandangIDs, row.KandangID) + } + if _, ok := phaseSet[row.PhaseID]; !ok { + phaseSet[row.PhaseID] = struct{}{} + phaseIDs = append(phaseIDs, row.PhaseID) + } + } + + dailyActivityMap := make(map[comboKey]map[string]dailyActivityStat) + if len(employeeIDs) > 0 { + var dailyRows []struct { + EmployeeID uint + KandangID uint + PhaseID uint + Date time.Time + Completed int64 + Total int64 + } + + dailyQuery := buildBase(). + Where("dca.employee_id IN ?", employeeIDs). + Where("dc.kandang_id IN ?", kandangIDs). + Where("dcat.phase_id IN ?", phaseIDs). + Select(` + dca.employee_id, + dc.kandang_id, + dcat.phase_id, + dc.date, + SUM(CASE WHEN dca.checked THEN 1 ELSE 0 END) AS completed, + COUNT(*) AS total`). + Group("dca.employee_id, dc.kandang_id, dcat.phase_id, dc.date") + + if err := dailyQuery.Scan(&dailyRows).Error; err != nil { + s.Log.Errorf("Failed to fetch daily activities for report: %+v", err) + return nil, 0, err + } + + for _, row := range dailyRows { + key := comboKey{EmployeeID: row.EmployeeID, KandangID: row.KandangID, PhaseID: row.PhaseID} + if _, ok := comboSet[key]; !ok { + continue + } + if _, ok := dailyActivityMap[key]; !ok { + dailyActivityMap[key] = make(map[string]dailyActivityStat) + } + day := strconv.Itoa(row.Date.Day()) + dailyActivityMap[key][day] = dailyActivityStat{ + Completed: int(row.Completed), + Total: int(row.Total), + Date: row.Date, + } + } + } + + employeeStats := make(map[uint]struct { + Completed int64 + Total int64 + }) + var employeeRows []struct { + EmployeeID uint + Completed int64 + Total int64 + } + if err := buildBase(). + Select(` + dca.employee_id, + SUM(CASE WHEN dca.checked THEN 1 ELSE 0 END) AS completed, + COUNT(*) AS total`). + Group("dca.employee_id"). + Scan(&employeeRows).Error; err != nil { + s.Log.Errorf("Failed to fetch employee stats for report: %+v", err) + return nil, 0, err + } + for _, row := range employeeRows { + employeeStats[row.EmployeeID] = struct { + Completed int64 + Total int64 + }{Completed: row.Completed, Total: row.Total} + } + + kandangStats := make(map[uint]struct { + Completed int64 + Total int64 + }) + var kandangRows []struct { + KandangID uint + Completed int64 + Total int64 + } + if err := buildBase(). + Select(` + dc.kandang_id, + SUM(CASE WHEN dca.checked THEN 1 ELSE 0 END) AS completed, + COUNT(*) AS total`). + Group("dc.kandang_id"). + Scan(&kandangRows).Error; err != nil { + s.Log.Errorf("Failed to fetch kandang stats for report: %+v", err) + return nil, 0, err + } + for _, row := range kandangRows { + kandangStats[row.KandangID] = struct { + Completed int64 + Total int64 + }{Completed: row.Completed, Total: row.Total} + } + + var configs []entity.ConfigChecklist + if err := s.Repository.DB().WithContext(c.Context()). + Order("date ASC"). + Find(&configs).Error; err != nil { + s.Log.Errorf("Failed to load config checklists: %+v", err) + return nil, 0, err + } + + getConfigForDate := func(date time.Time) *entity.ConfigChecklist { + var selected *entity.ConfigChecklist + for i := range configs { + if !configs[i].Date.After(date) { + selected = &configs[i] + } else { + break + } + } + if selected == nil { + return &entity.ConfigChecklist{ + PercentageThresholdBad: 50, + PercentageThresholdEnough: 75, + } + } + return selected + } + + items := make([]DailyChecklistReportItem, len(rows)) + for i, row := range rows { + key := comboKey{EmployeeID: row.EmployeeID, KandangID: row.KandangID, PhaseID: row.PhaseID} + + activities := dailyActivityMap[key] + if activities == nil { + activities = map[string]dailyActivityStat{} + } + + totalChecklist := 0 + categoryCounts := DailyChecklistReportCategory{} + activityOutput := make(map[string]int, len(activities)) + + for day, stat := range activities { + activityOutput[day] = stat.Completed + totalChecklist += stat.Completed + + if stat.Total == 0 { + continue + } + + cfg := getConfigForDate(stat.Date) + if cfg == nil { + continue + } + + progress := int(math.Ceil(float64(stat.Completed) / float64(stat.Total) * 100)) + if progress <= cfg.PercentageThresholdBad { + categoryCounts.Kurang++ + } else if progress <= cfg.PercentageThresholdEnough { + categoryCounts.Cukup++ + } else { + categoryCounts.Baik++ + } + } + + employeeStat := employeeStats[row.EmployeeID] + abkPercentage := 0 + if employeeStat.Total > 0 { + abkPercentage = int(math.Round(float64(employeeStat.Completed) / float64(employeeStat.Total) * 100)) + } + + kandangStat := kandangStats[row.KandangID] + kandangPercentage := 0 + if kandangStat.Total > 0 { + kandangPercentage = int(math.Round(float64(kandangStat.Completed) / float64(kandangStat.Total) * 100)) + } + + items[i] = DailyChecklistReportItem{ + AreaID: row.AreaID, + AreaName: row.AreaName, + LocationID: row.LocationID, + LocationName: row.LocationName, + KandangID: row.KandangID, + KandangName: row.KandangName, + EmployeeID: row.EmployeeID, + EmployeeName: row.EmployeeName, + PhaseName: row.PhaseName, + DailyActivities: activityOutput, + Summary: DailyChecklistReportSummary{ + TotalChecklist: totalChecklist, + JumlahHariEfektif: len(activities), + AbkPercentage: abkPercentage, + KandangPercentage: kandangPercentage, + Category: categoryCounts, + }, + } + } + + return items, total, nil +} diff --git a/internal/modules/daily-checklists/validations/daily-checklist.validation.go b/internal/modules/daily-checklists/validations/daily-checklist.validation.go index ba81fd0d..35ef8bb9 100644 --- a/internal/modules/daily-checklists/validations/daily-checklist.validation.go +++ b/internal/modules/daily-checklists/validations/daily-checklist.validation.go @@ -1,5 +1,9 @@ package validation +import ( + "mime/multipart" +) + type Create struct { Date string `json:"date" validate:"required"` KandangId uint `json:"kandang_id" validate:"required"` @@ -8,13 +12,20 @@ type Create struct { } type Update struct { - Name *string `json:"name,omitempty" validate:"omitempty"` + Status string `form:"status" json:"status" validate:"required"` + RejectReason *string `form:"reject_reason" json:"reject_reason"` + Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"` + DeletedDocumentIDs *string `form:"deleted_document_ids" json:"deleted_document_ids"` } type Query struct { - Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` - Search string `query:"search" validate:"omitempty,max=50"` + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` + DateFrom string `query:"date_from" validate:"omitempty"` + DateTo string `query:"date_to" validate:"omitempty"` + Status string `query:"status" validate:"omitempty"` + KandangID *uint `query:"kandang_id" validate:"omitempty"` } type AssignPhases struct { @@ -24,3 +35,29 @@ type AssignPhases struct { type AssignTask struct { EmployeeIDs string `json:"employee_ids" validate:"required"` } + +type UpdateAssignment struct { + TaskID uint `json:"task_id" validate:"required"` + EmployeeID uint `json:"employee_id" validate:"required"` + Checked *bool `json:"checked,omitempty"` + Note *string `json:"note,omitempty"` +} + +type SummaryQuery struct { + DateFrom string `query:"date_from" validate:"required"` + DateTo string `query:"date_to" validate:"required"` + Category string `query:"category" validate:"omitempty"` + KandangID *uint `query:"kandang_id" validate:"omitempty"` +} + +type ReportQuery struct { + Page int `query:"page" validate:"required,number,min=1,gt=0"` + Limit int `query:"limit" validate:"required,number,min=1,max=100,gt=0"` + Month int `query:"bulan" validate:"required,number,min=1,max=12"` + Year int `query:"tahun" validate:"required,number,min=1900"` + AreaID *uint `query:"area_id" validate:"omitempty"` + LocationID *uint `query:"location_id" validate:"omitempty"` + KandangID *uint `query:"kandang_id" validate:"omitempty"` + EmployeeID *uint `query:"employee_id" validate:"omitempty"` + PhaseID *uint `query:"phase_id" validate:"omitempty"` +} diff --git a/internal/modules/dashboards/controllers/dashboard.controller.go b/internal/modules/dashboards/controllers/dashboard.controller.go new file mode 100644 index 00000000..bebad10f --- /dev/null +++ b/internal/modules/dashboards/controllers/dashboard.controller.go @@ -0,0 +1,206 @@ +package controller + +import ( + "math" + "strconv" + "strings" + "time" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type DashboardController struct { + DashboardService service.DashboardService +} + +func NewDashboardController(dashboardService service.DashboardService) *DashboardController { + return &DashboardController{ + DashboardService: dashboardService, + } +} + +func (u *DashboardController) GetAll(c *fiber.Ctx) error { + parseStringListParam := func(param string) ([]string, error) { + if param == "" { + return nil, nil + } + parts := strings.Split(param, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed == "" { + return nil, strconv.ErrSyntax + } + result = append(result, trimmed) + } + return result, nil + } + + parseUintListParam := func(param string) ([]uint, error) { + if param == "" { + return nil, nil + } + parts := strings.Split(param, ",") + ids := make([]uint, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed == "" { + return nil, strconv.ErrSyntax + } + parsed, err := strconv.ParseUint(trimmed, 10, 64) + if err != nil { + return nil, err + } + ids = append(ids, uint(parsed)) + } + return ids, nil + } + + lokasiIds, err := parseUintListParam(c.Query("location_ids", "")) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid location_ids") + } + + flockIds, err := parseUintListParam(c.Query("flock_ids", "")) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid flock_ids") + } + + kandangIds, err := parseUintListParam(c.Query("kandang_ids", "")) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid kandang_ids") + } + + include, err := parseStringListParam(strings.ToLower(c.Query("include", ""))) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid include") + } + + analysisMode := strings.ToUpper(strings.TrimSpace(c.Query("analysis_mode", validation.AnalysisModeOverview))) + metric := strings.ToLower(strings.TrimSpace(c.Query("metric", ""))) + + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: strings.TrimSpace(c.Query("search", "")), + PerformanceOverviewFilter: validation.PerformanceOverviewFilter{ + StartDate: c.Query("start_date", ""), + EndDate: c.Query("end_date", ""), + AnalysisMode: analysisMode, + ComparisonType: strings.ToUpper(strings.TrimSpace(c.Query("comparison_type", ""))), + Metric: metric, + LokasiIds: lokasiIds, + FlockIds: flockIds, + KandangIds: kandangIds, + Include: include, + }, + } + + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + if query.AnalysisMode == validation.AnalysisModeComparison && query.ComparisonType == "" { + return fiber.NewError(fiber.StatusBadRequest, "comparison_type is required for comparison mode") + } + + location, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "failed to load timezone configuration") + } + + startDate, endDate, endExclusive, err := parsePeriodDates(query.StartDate, query.EndDate, location) + if err != nil { + return err + } + + query.PeriodStart = startDate + query.PeriodEnd = endDate + query.PeriodEndExclusive = endExclusive + + result, totalResults, err := u.DashboardService.GetAll(c.Context(), query) + if err != nil { + return err + } + + hasFilter := query.StartDate != "" || + query.EndDate != "" || + len(query.LokasiIds) > 0 || + len(query.FlockIds) > 0 || + len(query.KandangIds) > 0 || + len(query.Include) > 0 || + query.ComparisonType != "" || + query.Metric != "" || + query.AnalysisMode != validation.AnalysisModeOverview + + var filters interface{} + if hasFilter { + filters = dto.DashboardFiltersDTO{ + StartDate: query.StartDate, + EndDate: query.EndDate, + AnalysisMode: query.AnalysisMode, + ComparisonType: query.ComparisonType, + Metric: query.Metric, + LokasiIds: defaultUintSlice(query.LokasiIds), + FlockIds: defaultUintSlice(query.FlockIds), + KandangIds: defaultUintSlice(query.KandangIds), + Include: query.Include, + } + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithMeta{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get dashboard successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + Filters: filters, + }, + Data: result, + }) +} + +func defaultUintSlice(values []uint) []uint { + if values == nil { + return []uint{} + } + return values +} + +func parsePeriodDates(startDateRaw, endDateRaw string, location *time.Location) (time.Time, time.Time, time.Time, error) { + now := time.Now().In(location) + startDate := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, location) + endDate := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, location) + + if startDateRaw != "" { + parsed, err := time.ParseInLocation("2006-01-02", startDateRaw, location) + if err != nil { + return time.Time{}, time.Time{}, time.Time{}, fiber.NewError(fiber.StatusBadRequest, "start_date must follow format YYYY-MM-DD") + } + startDate = parsed + } + + if endDateRaw != "" { + parsed, err := time.ParseInLocation("2006-01-02", endDateRaw, location) + if err != nil { + return time.Time{}, time.Time{}, time.Time{}, fiber.NewError(fiber.StatusBadRequest, "end_date must follow format YYYY-MM-DD") + } + endDate = parsed + } + + if endDate.Before(startDate) { + return time.Time{}, time.Time{}, time.Time{}, fiber.NewError(fiber.StatusBadRequest, "end_date must be greater than or equal to start_date") + } + + endExclusive := endDate.AddDate(0, 0, 1) + return startDate, endDate, endExclusive, nil +} diff --git a/internal/modules/dashboards/dto/dashboard.dto.go b/internal/modules/dashboards/dto/dashboard.dto.go new file mode 100644 index 00000000..affa02a6 --- /dev/null +++ b/internal/modules/dashboards/dto/dashboard.dto.go @@ -0,0 +1,82 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" +) + +// === DTO Structs === + +type DashboardListDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type DashboardDetailDTO struct { + DashboardListDTO +} + +type DashboardFiltersDTO struct { + StartDate string `json:"start_date"` + EndDate string `json:"end_date"` + AnalysisMode string `json:"analysis_mode"` + ComparisonType string `json:"comparison_type,omitempty"` + Metric string `json:"metric,omitempty"` + LokasiIds []uint `json:"location_ids"` + FlockIds []uint `json:"flock_ids"` + KandangIds []uint `json:"kandang_ids"` + Include []string `json:"include,omitempty"` +} + +type DashboardStatisticsDTO struct { + Label string `json:"label"` + Value float64 `json:"value"` + PercentLastMonth float64 `json:"percent_last_month"` +} + +type DashboardPerformanceOverviewDTO struct { + StatisticsData []DashboardStatisticsDTO `json:"statistics_data"` + Charts map[string]DashboardChartDTO `json:"charts,omitempty"` +} + +type DashboardChartSeriesDTO struct { + Id string `json:"id"` + Label string `json:"label"` + Unit string `json:"unit,omitempty"` +} + +type DashboardChartDTO struct { + Series []DashboardChartSeriesDTO `json:"series"` + Dataset []map[string]interface{} `json:"dataset"` +} + +// === Mapper Functions === + +func ToDashboardListDTO(e entity.Dashboard) DashboardListDTO { + var createdUser *userDTO.UserRelationDTO + if e.CreatedUser.Id != 0 { + mapped := userDTO.ToUserRelationDTO(e.CreatedUser) + createdUser = &mapped + } + + return DashboardListDTO{ + Id: e.Id, + Name: e.Name, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, + } +} + +func ToDashboardListDTOs(e []entity.Dashboard) []DashboardListDTO { + result := make([]DashboardListDTO, len(e)) + for i, r := range e { + result[i] = ToDashboardListDTO(r) + } + return result +} diff --git a/internal/modules/dashboards/module.go b/internal/modules/dashboards/module.go new file mode 100644 index 00000000..24574dc7 --- /dev/null +++ b/internal/modules/dashboards/module.go @@ -0,0 +1,26 @@ +package dashboards + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + rDashboard "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/repositories" + sDashboard "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type DashboardModule struct{} + +func (DashboardModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + dashboardRepo := rDashboard.NewDashboardRepository(db) + userRepo := rUser.NewUserRepository(db) + + dashboardService := sDashboard.NewDashboardService(dashboardRepo, validate) + userService := sUser.NewUserService(userRepo, validate) + + DashboardRoutes(router, userService, dashboardService) +} + diff --git a/internal/modules/dashboards/repositories/dashboard.repository.go b/internal/modules/dashboards/repositories/dashboard.repository.go new file mode 100644 index 00000000..90ee3bf8 --- /dev/null +++ b/internal/modules/dashboards/repositories/dashboard.repository.go @@ -0,0 +1,44 @@ +package repository + +import ( + "context" + "time" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/validations" + "gorm.io/gorm" +) + +type DashboardRepository interface { + repository.BaseRepository[entity.Dashboard] + GetFeedUsageByUom(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]FeedUsageByUom, error) + SumDepletions(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error) + SumInitialPopulation(ctx context.Context, endDate time.Time, filters *validation.DashboardFilter) (float64, error) + SumSapronakCost(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error) + SumBopCost(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error) + SumEkspedisiCost(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error) + SumSellingPrice(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (SellingPriceAggregate, error) + SumEggProductionWeightGrams(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error) + SumEggProductionWeightKg(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error) + GetRecordingWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]RecordingWeeklyMetric, error) + GetUniformityWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]UniformityWeeklyMetric, error) + GetStandardWeeklyMetrics(ctx context.Context, weeks []int, filters *validation.DashboardFilter) ([]StandardWeeklyMetric, error) + GetStandardFcrWeekly(ctx context.Context, weeks []int, filters *validation.DashboardFilter) ([]StandardWeeklyFcrMetric, error) + GetComparisonSeries(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter, comparisonType string) ([]ComparisonSeries, error) + GetComparisonWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter, comparisonType, metric string) ([]ComparisonWeeklyMetric, error) + GetComparisonWeeklyUniformityMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter, comparisonType string) ([]ComparisonUniformityMetric, error) + GetEggQualityWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]EggQualityWeeklyMetric, error) + GetEggWeightWeeklyGrams(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]WeeklyEggWeightMetric, error) + GetFeedUsageWeeklyByUom(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]WeeklyFeedUsageMetric, error) +} + +type DashboardRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Dashboard] +} + +func NewDashboardRepository(db *gorm.DB) DashboardRepository { + return &DashboardRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Dashboard](db), + } +} diff --git a/internal/modules/dashboards/repositories/dashboard_stats.repository.go b/internal/modules/dashboards/repositories/dashboard_stats.repository.go new file mode 100644 index 00000000..7582680b --- /dev/null +++ b/internal/modules/dashboards/repositories/dashboard_stats.repository.go @@ -0,0 +1,725 @@ +package repository + +import ( + "context" + "fmt" + "strings" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gorm.io/gorm" +) + +type SellingPriceAggregate struct { + TotalPrice float64 + TotalWeight float64 +} + +type FeedUsageByUom struct { + TotalQty float64 + UomName string +} + +type RecordingWeeklyMetric struct { + Week int + HenDay float64 + EggWeight float64 + FeedIntake float64 + FcrValue float64 + CumDepletionRate float64 +} + +type UniformityWeeklyMetric struct { + Week int + Uniformity float64 + AverageWeight float64 +} + +type StandardWeeklyMetric struct { + Week int + StdLaying float64 + StdEggWeight float64 + StdFeedIntake float64 + StdUniformity float64 + StdDepletion float64 + StdBodyWeight float64 +} + +type StandardWeeklyFcrMetric struct { + Week int + StdFcr float64 +} + +type ComparisonSeries struct { + Id uint + Label string +} + +type ComparisonWeeklyMetric struct { + Week int + SeriesId uint + Value float64 +} + +type ComparisonUniformityMetric struct { + Week int + SeriesId uint + Uniformity float64 + AverageWeight float64 +} + +type EggQualityWeeklyMetric struct { + Week int + NormalQty float64 + AbnormalQty float64 + TotalQty float64 +} + +type WeeklyEggWeightMetric struct { + Week int + EggWeightGrams float64 +} + +type WeeklyFeedUsageMetric struct { + Week int + TotalQty float64 + UomName string +} + +func applyDashboardFilters(db *gorm.DB, filters *validation.DashboardFilter) *gorm.DB { + if filters == nil { + return db + } + if len(filters.FlockIds) > 0 { + db = db.Where("pfk.project_flock_id IN ?", filters.FlockIds) + } + if len(filters.KandangIds) > 0 { + db = db.Where("k.id IN ?", filters.KandangIds) + } + if len(filters.LokasiIds) > 0 { + db = db.Where("k.location_id IN ?", filters.LokasiIds) + } + return db +} + +func (r *DashboardRepositoryImpl) GetRecordingWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]RecordingWeeklyMetric, error) { + var rows []RecordingWeeklyMetric + + db := r.DB().WithContext(ctx). + Table("recordings AS r"). + Select(`((r.day - 1) / 7 + 1) AS week, + COALESCE(AVG(r.hen_day), 0) AS hen_day, + COALESCE(AVG(r.egg_weight), 0) AS egg_weight, + COALESCE(AVG(r.feed_intake), 0) AS feed_intake, + COALESCE(AVG(r.fcr_value), 0) AS fcr_value, + COALESCE(AVG(r.cum_depletion_rate), 0) AS cum_depletion_rate`). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL"). + Where("r.day IS NOT NULL AND r.day > 0") + + db = applyDashboardFilters(db, filters) + + if err := db.Group("week").Order("week ASC").Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *DashboardRepositoryImpl) GetUniformityWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]UniformityWeeklyMetric, error) { + var rows []UniformityWeeklyMetric + + db := r.DB().WithContext(ctx). + Table("project_flock_kandang_uniformity AS u"). + Select(`u.week AS week, + COALESCE(AVG(u.uniformity), 0) AS uniformity, + COALESCE(AVG((u.chart_data->'statistics'->>'average_weight')::numeric), 0) AS average_weight`). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = u.project_flock_kandang_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Where("u.uniform_date IS NOT NULL"). + Where("u.uniform_date >= ? AND u.uniform_date < ?", start, end) + + db = applyDashboardFilters(db, filters) + + if err := db.Group("u.week").Order("u.week ASC").Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *DashboardRepositoryImpl) GetStandardWeeklyMetrics(ctx context.Context, weeks []int, filters *validation.DashboardFilter) ([]StandardWeeklyMetric, error) { + if len(weeks) == 0 { + return nil, nil + } + + standardIDs := r.standardIDSubquery(filters) + if standardIDs == nil { + return nil, nil + } + + var rows []StandardWeeklyMetric + db := r.DB().WithContext(ctx). + Table("standard_growth_details AS sgd"). + Select(`sgd.week AS week, + COALESCE(AVG(psd.target_hen_day_production), 0) AS std_laying, + COALESCE(AVG(psd.target_egg_weight), 0) AS std_egg_weight, + COALESCE(AVG(sgd.feed_intake), 0) AS std_feed_intake, + COALESCE(AVG(sgd.min_uniformity), 0) AS std_uniformity, + COALESCE(AVG(sgd.max_depletion), 0) AS std_depletion, + COALESCE(AVG(sgd.target_mean_bw), 0) AS std_body_weight`). + Joins("LEFT JOIN production_standard_details AS psd ON psd.production_standard_id = sgd.production_standard_id AND psd.week = sgd.week"). + Where("sgd.week IN ?", weeks). + Where("sgd.production_standard_id IN (?)", standardIDs) + + if err := db.Group("sgd.week").Order("sgd.week ASC").Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *DashboardRepositoryImpl) GetStandardFcrWeekly(ctx context.Context, weeks []int, filters *validation.DashboardFilter) ([]StandardWeeklyFcrMetric, error) { + if len(weeks) == 0 { + return nil, nil + } + + filterClause := "" + filterArgs := make([]interface{}, 0) + if filters != nil { + if len(filters.FlockIds) > 0 { + filterClause += " AND pf.id IN ?" + filterArgs = append(filterArgs, filters.FlockIds) + } + if len(filters.KandangIds) > 0 { + filterClause += " AND k.id IN ?" + filterArgs = append(filterArgs, filters.KandangIds) + } + if len(filters.LokasiIds) > 0 { + filterClause += " AND k.location_id IN ?" + filterArgs = append(filterArgs, filters.LokasiIds) + } + } + + query := fmt.Sprintf(` +WITH src AS ( + SELECT DISTINCT pf.production_standard_id, pf.fcr_id + FROM project_flocks pf + JOIN project_flock_kandangs pfk ON pfk.project_flock_id = pf.id + JOIN kandangs k ON k.id = pfk.kandang_id + WHERE pf.production_standard_id > 0 AND pf.fcr_id > 0 + %s +), +actual AS ( + SELECT u.week AS week, + pf.fcr_id AS fcr_id, + AVG((u.chart_data->'statistics'->>'average_weight')::numeric) AS avg_weight + FROM project_flock_kandang_uniformity u + JOIN project_flock_kandangs pfk ON pfk.id = u.project_flock_kandang_id + JOIN project_flocks pf ON pf.id = pfk.project_flock_id + JOIN kandangs k ON k.id = pfk.kandang_id + WHERE u.week IN ? AND u.uniform_date IS NOT NULL AND pf.fcr_id > 0 + %s + GROUP BY u.week, pf.fcr_id +), +target AS ( + SELECT sgd.week AS week, + src.fcr_id AS fcr_id, + AVG(sgd.target_mean_bw) AS target_mean_bw + FROM standard_growth_details sgd + JOIN src ON src.production_standard_id = sgd.production_standard_id + WHERE sgd.week IN ? + GROUP BY sgd.week, src.fcr_id +), +weights AS ( + SELECT COALESCE(a.week, t.week) AS week, + COALESCE(a.fcr_id, t.fcr_id) AS fcr_id, + COALESCE( + CASE WHEN a.avg_weight > 10 THEN a.avg_weight / 1000 ELSE a.avg_weight END, + CASE WHEN t.target_mean_bw > 10 THEN t.target_mean_bw / 1000 ELSE t.target_mean_bw END + ) AS weight + FROM actual a + FULL OUTER JOIN target t ON t.week = a.week AND t.fcr_id = a.fcr_id +) +SELECT w.week AS week, + COALESCE(AVG( + COALESCE( + (SELECT fs.fcr_number + FROM fcr_standards fs + WHERE fs.fcr_id = w.fcr_id + AND fs.weight >= w.weight + ORDER BY fs.weight ASC + LIMIT 1), + (SELECT fs.fcr_number + FROM fcr_standards fs + WHERE fs.fcr_id = w.fcr_id + ORDER BY fs.weight DESC + LIMIT 1) + ) + ), 0) AS std_fcr +FROM weights w +GROUP BY w.week +ORDER BY w.week ASC +`, filterClause, filterClause) + + args := make([]interface{}, 0, len(filterArgs)*2+2) + args = append(args, filterArgs...) + args = append(args, weeks) + args = append(args, filterArgs...) + args = append(args, weeks) + + var rows []StandardWeeklyFcrMetric + if err := r.DB().WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *DashboardRepositoryImpl) SumEggProductionWeightGrams(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error) { + var total float64 + + db := r.DB().WithContext(ctx). + Table("recording_eggs AS re"). + Select("COALESCE(SUM(re.qty * re.weight), 0)"). + Joins("JOIN recordings AS r ON r.id = re.recording_id"). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL") + + db = applyDashboardFilters(db, filters) + + if err := db.Scan(&total).Error; err != nil { + return 0, err + } + + return total, nil +} + +func (r *DashboardRepositoryImpl) SumEggProductionWeightKg(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error) { + grams, err := r.SumEggProductionWeightGrams(ctx, start, end, filters) + if err != nil { + return 0, err + } + return grams / 1000, nil +} + +func (r *DashboardRepositoryImpl) GetFeedUsageByUom(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]FeedUsageByUom, error) { + var rows []FeedUsageByUom + + db := r.DB().WithContext(ctx). + Table("recording_stocks AS rs"). + Select("COALESCE(SUM(rs.usage_qty), 0) + COALESCE(SUM(rs.pending_qty), 0) AS total_qty, LOWER(uoms.name) AS uom_name"). + Joins("JOIN recordings AS r ON r.id = rs.recording_id"). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id"). + Joins("JOIN products AS p ON p.id = pw.product_id"). + Joins("JOIN uoms ON uoms.id = p.uom_id"). + Joins("JOIN flags AS f ON f.flagable_id = p.id AND f.flagable_type = ? AND UPPER(f.name) = ?", entity.FlagableTypeProduct, "PAKAN"). + Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL") + + db = applyDashboardFilters(db, filters) + + if err := db.Group("LOWER(uoms.name)").Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *DashboardRepositoryImpl) SumDepletions(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error) { + var total float64 + + db := r.DB().WithContext(ctx). + Table("recording_depletions AS rd"). + Select("COALESCE(SUM(rd.qty), 0)"). + Joins("JOIN recordings AS r ON r.id = rd.recording_id"). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL") + + db = applyDashboardFilters(db, filters) + + if err := db.Scan(&total).Error; err != nil { + return 0, err + } + + return total, nil +} + +func (r *DashboardRepositoryImpl) SumInitialPopulation(ctx context.Context, endDate time.Time, filters *validation.DashboardFilter) (float64, error) { + var total float64 + endOfDate := endDate.AddDate(0, 0, 1) + + db := r.DB().WithContext(ctx). + Table("project_chickins AS pc"). + Select("COALESCE(SUM(pc.usage_qty), 0)"). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = pc.project_flock_kandang_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Where("pc.chick_in_date < ?", endOfDate). + Where("pc.deleted_at IS NULL") + + db = applyDashboardFilters(db, filters) + + if err := db.Scan(&total).Error; err != nil { + return 0, err + } + + return total, nil +} + +func (r *DashboardRepositoryImpl) SumSellingPrice(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (SellingPriceAggregate, error) { + var result SellingPriceAggregate + + db := r.DB().WithContext(ctx). + Table("marketing_delivery_products AS mdp"). + Select("COALESCE(SUM(mdp.total_price), 0) AS total_price, COALESCE(SUM(mdp.total_weight), 0) AS total_weight"). + Joins("JOIN marketing_products AS mp ON mp.id = mdp.marketing_product_id"). + Joins("JOIN product_warehouses AS pw ON pw.id = mp.product_warehouse_id"). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = pw.project_flock_kandang_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Where("mdp.delivery_date IS NOT NULL"). + Where("mdp.delivery_date >= ? AND mdp.delivery_date < ?", start, end) + + db = applyDashboardFilters(db, filters) + + if err := db.Scan(&result).Error; err != nil { + return SellingPriceAggregate{}, err + } + + return result, nil +} + +func (r *DashboardRepositoryImpl) SumSapronakCost(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error) { + var total float64 + + db := r.DB().WithContext(ctx). + Table("purchase_items AS pi"). + Select("COALESCE(SUM(pi.total_price), 0) AS total"). + Joins("JOIN products AS p ON p.id = pi.product_id"). + Joins("JOIN flags AS f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Joins("LEFT JOIN product_warehouses AS pw ON pw.id = pi.product_warehouse_id"). + Joins("LEFT JOIN project_flock_kandangs AS pfk ON pfk.id = COALESCE(pi.project_flock_kandang_id, pw.project_flock_kandang_id)"). + Joins("LEFT JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Where("f.name IN ?", []utils.FlagType{utils.FlagDOC, utils.FlagPakan, utils.FlagOVK}). + Where("pi.received_date IS NOT NULL"). + Where("pi.received_date >= ? AND pi.received_date < ?", start, end) + + db = applyDashboardFilters(db, filters) + + if err := db.Scan(&total).Error; err != nil { + return 0, err + } + + return total, nil +} + +func (r *DashboardRepositoryImpl) SumBopCost(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error) { + return r.sumExpenseRealization(ctx, start, end, filters, func(db *gorm.DB) *gorm.DB { + return db. + Where("e.category = ?", utils.ExpenseCategoryBOP). + Joins("LEFT JOIN nonstocks AS n ON n.id = en.nonstock_id"). + Joins("LEFT JOIN flags AS f ON f.flagable_id = n.id AND f.flagable_type = ? AND f.name = ?", entity.FlagableTypeNonstock, utils.FlagEkspedisi). + Where("f.id IS NULL") + }) +} + +func (r *DashboardRepositoryImpl) SumEkspedisiCost(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error) { + return r.sumExpenseRealization(ctx, start, end, filters, func(db *gorm.DB) *gorm.DB { + return db. + Joins("JOIN nonstocks AS n ON n.id = en.nonstock_id"). + Joins("JOIN flags AS f ON f.flagable_id = n.id AND f.flagable_type = ?", entity.FlagableTypeNonstock). + Where("f.name = ?", utils.FlagEkspedisi) + }) +} + +func (r *DashboardRepositoryImpl) sumExpenseRealization(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter, modifier func(*gorm.DB) *gorm.DB) (float64, error) { + var total float64 + + db := r.DB().WithContext(ctx). + Table("expense_realizations AS er"). + Select("COALESCE(SUM(er.qty * er.price), 0) AS total"). + Joins("JOIN expense_nonstocks AS en ON en.id = er.expense_nonstock_id"). + Joins("JOIN expenses AS e ON e.id = en.expense_id"). + Joins("LEFT JOIN project_flock_kandangs AS pfk ON pfk.id = en.project_flock_kandang_id"). + Joins("LEFT JOIN kandangs AS k ON k.id = COALESCE(en.kandang_id, pfk.kandang_id)"). + Where("e.realization_date >= ? AND e.realization_date < ?", start, end) + + db = applyDashboardFilters(db, filters) + + if modifier != nil { + db = modifier(db) + } + + if err := db.Scan(&total).Error; err != nil { + return 0, err + } + + return total, nil +} + +func (r *DashboardRepositoryImpl) standardIDSubquery(filters *validation.DashboardFilter) *gorm.DB { + db := r.DB(). + Table("project_flocks AS pf"). + Select("DISTINCT pf.production_standard_id"). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.project_flock_id = pf.id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Where("pf.production_standard_id > 0") + + if filters != nil { + if len(filters.FlockIds) > 0 { + db = db.Where("pf.id IN ?", filters.FlockIds) + } + if len(filters.KandangIds) > 0 { + db = db.Where("k.id IN ?", filters.KandangIds) + } + if len(filters.LokasiIds) > 0 { + db = db.Where("k.location_id IN ?", filters.LokasiIds) + } + } + + return db +} + +func (r *DashboardRepositoryImpl) standardSourceSubquery(filters *validation.DashboardFilter) *gorm.DB { + db := r.DB(). + Table("project_flocks AS pf"). + Select("DISTINCT pf.production_standard_id, pf.fcr_id"). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.project_flock_id = pf.id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Where("pf.production_standard_id > 0"). + Where("pf.fcr_id > 0") + + if filters != nil { + if len(filters.FlockIds) > 0 { + db = db.Where("pf.id IN ?", filters.FlockIds) + } + if len(filters.KandangIds) > 0 { + db = db.Where("k.id IN ?", filters.KandangIds) + } + if len(filters.LokasiIds) > 0 { + db = db.Where("k.location_id IN ?", filters.LokasiIds) + } + } + + return db +} + +func (r *DashboardRepositoryImpl) GetComparisonSeries(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter, comparisonType string) ([]ComparisonSeries, error) { + seriesExpr, labelExpr, groupExpr, orderExpr, err := comparisonSeriesColumns(comparisonType) + if err != nil { + return nil, err + } + + var rows []ComparisonSeries + db := r.DB().WithContext(ctx). + Table("recordings AS r"). + Select(fmt.Sprintf("%s AS id, %s AS label", seriesExpr, labelExpr)). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id"). + Joins("JOIN locations AS loc ON loc.id = k.location_id"). + Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL") + + db = applyDashboardFilters(db, filters) + + if err := db.Group(groupExpr).Order(orderExpr).Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *DashboardRepositoryImpl) GetComparisonWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter, comparisonType, metric string) ([]ComparisonWeeklyMetric, error) { + seriesExpr, _, groupExpr, orderExpr, err := comparisonSeriesColumns(comparisonType) + if err != nil { + return nil, err + } + + metricExpr, err := comparisonMetricColumn(metric) + if err != nil { + return nil, err + } + + var rows []ComparisonWeeklyMetric + db := r.DB().WithContext(ctx). + Table("recordings AS r"). + Select(fmt.Sprintf(`((r.day - 1) / 7 + 1) AS week, + %s AS series_id, + COALESCE(AVG(%s), 0) AS value`, seriesExpr, metricExpr)). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id"). + Joins("JOIN locations AS loc ON loc.id = k.location_id"). + Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL"). + Where("r.day IS NOT NULL AND r.day > 0") + + db = applyDashboardFilters(db, filters) + + groupBy := fmt.Sprintf("week, %s", groupExpr) + orderBy := fmt.Sprintf("week ASC, %s", orderExpr) + if err := db.Group(groupBy).Order(orderBy).Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *DashboardRepositoryImpl) GetComparisonWeeklyUniformityMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter, comparisonType string) ([]ComparisonUniformityMetric, error) { + seriesExpr, _, groupExpr, orderExpr, err := comparisonSeriesColumns(comparisonType) + if err != nil { + return nil, err + } + + var rows []ComparisonUniformityMetric + db := r.DB().WithContext(ctx). + Table("project_flock_kandang_uniformity AS u"). + Select(fmt.Sprintf(`u.week AS week, + %s AS series_id, + COALESCE(AVG(u.uniformity), 0) AS uniformity, + COALESCE(AVG((u.chart_data->'statistics'->>'average_weight')::numeric), 0) AS average_weight`, seriesExpr)). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = u.project_flock_kandang_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id"). + Joins("JOIN locations AS loc ON loc.id = k.location_id"). + Where("u.uniform_date IS NOT NULL"). + Where("u.uniform_date >= ? AND u.uniform_date < ?", start, end) + + db = applyDashboardFilters(db, filters) + + groupBy := fmt.Sprintf("u.week, %s", groupExpr) + orderBy := fmt.Sprintf("u.week ASC, %s", orderExpr) + if err := db.Group(groupBy).Order(orderBy).Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *DashboardRepositoryImpl) GetEggQualityWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]EggQualityWeeklyMetric, error) { + var rows []EggQualityWeeklyMetric + + db := r.DB().WithContext(ctx). + Table("recording_eggs AS re"). + Select(` + ((r.day - 1) / 7 + 1) AS week, + COALESCE(SUM(CASE WHEN f.name = ? THEN re.qty ELSE 0 END), 0) AS normal_qty, + COALESCE(SUM(CASE WHEN f.name IN (?, ?, ?) THEN re.qty ELSE 0 END), 0) AS abnormal_qty, + COALESCE(SUM(re.qty), 0) AS total_qty`, + utils.FlagTelurUtuh, + utils.FlagTelurPutih, + utils.FlagTelurRetak, + utils.FlagTelurPecah, + ). + Joins("JOIN recordings AS r ON r.id = re.recording_id"). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Joins("JOIN product_warehouses AS pw ON pw.id = re.product_warehouse_id"). + Joins("JOIN products AS p ON p.id = pw.product_id"). + Joins("JOIN flags AS f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Where("f.name IN ?", []utils.FlagType{utils.FlagTelurUtuh, utils.FlagTelurPutih, utils.FlagTelurRetak, utils.FlagTelurPecah}). + Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL"). + Where("r.day IS NOT NULL AND r.day > 0") + + db = applyDashboardFilters(db, filters) + + if err := db.Group("week").Order("week ASC").Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *DashboardRepositoryImpl) GetEggWeightWeeklyGrams(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]WeeklyEggWeightMetric, error) { + var rows []WeeklyEggWeightMetric + + db := r.DB().WithContext(ctx). + Table("recording_eggs AS re"). + Select(` + ((r.day - 1) / 7 + 1) AS week, + COALESCE(SUM(re.qty * re.weight), 0) AS egg_weight_grams`). + Joins("JOIN recordings AS r ON r.id = re.recording_id"). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL"). + Where("r.day IS NOT NULL AND r.day > 0") + + db = applyDashboardFilters(db, filters) + + if err := db.Group("week").Order("week ASC").Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *DashboardRepositoryImpl) GetFeedUsageWeeklyByUom(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]WeeklyFeedUsageMetric, error) { + var rows []WeeklyFeedUsageMetric + + db := r.DB().WithContext(ctx). + Table("recording_stocks AS rs"). + Select(` + ((r.day - 1) / 7 + 1) AS week, + COALESCE(SUM(rs.usage_qty), 0) + COALESCE(SUM(rs.pending_qty), 0) AS total_qty, + LOWER(uoms.name) AS uom_name`). + Joins("JOIN recordings AS r ON r.id = rs.recording_id"). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id"). + Joins("JOIN products AS p ON p.id = pw.product_id"). + Joins("JOIN uoms ON uoms.id = p.uom_id"). + Joins("JOIN flags AS f ON f.flagable_id = p.id AND f.flagable_type = ? AND UPPER(f.name) = ?", entity.FlagableTypeProduct, "PAKAN"). + Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL"). + Where("r.day IS NOT NULL AND r.day > 0") + + db = applyDashboardFilters(db, filters) + + if err := db.Group("week, LOWER(uoms.name)").Order("week ASC").Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func comparisonSeriesColumns(comparisonType string) (string, string, string, string, error) { + switch strings.ToUpper(strings.TrimSpace(comparisonType)) { + case validation.ComparisonTypeFarm: + return "loc.id", "loc.name", "loc.id, loc.name", "loc.name", nil + case validation.ComparisonTypeFlock: + return "pf.id", "pf.flock_name", "pf.id, pf.flock_name", "pf.flock_name", nil + case validation.ComparisonTypeKandang: + return "k.id", "k.name", "k.id, k.name", "k.name", nil + default: + return "", "", "", "", fmt.Errorf("invalid comparison_type") + } +} + +func comparisonMetricColumn(metric string) (string, error) { + switch strings.ToLower(strings.TrimSpace(metric)) { + case validation.MetricFcr: + return "r.fcr_value", nil + case validation.MetricMortality: + return "r.cum_depletion_rate", nil + case validation.MetricLaying: + return "r.hen_day", nil + case validation.MetricEggWeight: + return "r.egg_weight", nil + case validation.MetricFeedIntake: + return "r.feed_intake", nil + default: + return "", fmt.Errorf("invalid metric") + } +} diff --git a/internal/modules/dashboards/route.go b/internal/modules/dashboards/route.go new file mode 100644 index 00000000..34f2d00b --- /dev/null +++ b/internal/modules/dashboards/route.go @@ -0,0 +1,18 @@ +package dashboards + +import ( + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/controllers" + dashboard "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func DashboardRoutes(v1 fiber.Router, u user.UserService, s dashboard.DashboardService) { + ctrl := controller.NewDashboardController(s) + + route := v1.Group("/dashboards") + route.Use(m.Auth(u)) + route.Get("/",m.RequirePermissions(m.P_DashboardGetAll) ,ctrl.GetAll) +} diff --git a/internal/modules/dashboards/services/dashboard.service.go b/internal/modules/dashboards/services/dashboard.service.go new file mode 100644 index 00000000..8fa0a2c9 --- /dev/null +++ b/internal/modules/dashboards/services/dashboard.service.go @@ -0,0 +1,1029 @@ +package service + +import ( + "context" + "errors" + "fmt" + "math" + "sort" + "strconv" + "strings" + "time" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/dto" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "github.com/go-playground/validator/v10" + "github.com/sirupsen/logrus" +) + +type DashboardService interface { + GetAll(ctx context.Context, params *validation.Query) (dto.DashboardPerformanceOverviewDTO, int64, error) +} + +type dashboardService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.DashboardRepository +} + +func NewDashboardService(repo repository.DashboardRepository, validate *validator.Validate) DashboardService { + return &dashboardService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + } +} + +func (s dashboardService) GetAll(ctx context.Context, params *validation.Query) (dto.DashboardPerformanceOverviewDTO, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return dto.DashboardPerformanceOverviewDTO{}, 0, err + } + + filter := &validation.DashboardFilter{ + LokasiIds: params.LokasiIds, + FlockIds: params.FlockIds, + KandangIds: params.KandangIds, + } + + statistics, err := s.buildPerformanceStatistics(ctx, params, filter) + if err != nil { + return dto.DashboardPerformanceOverviewDTO{}, 0, err + } + + charts, err := s.buildPerformanceCharts(ctx, params, filter) + if err != nil { + return dto.DashboardPerformanceOverviewDTO{}, 0, err + } + + response := dto.DashboardPerformanceOverviewDTO{ + StatisticsData: statistics, + Charts: charts, + } + + if len(params.Include) > 0 { + include := map[string]bool{} + for _, item := range params.Include { + include[item] = true + } + if !include["statistics"] { + response.StatisticsData = []dto.DashboardStatisticsDTO{} + } + if !include["charts"] { + response.Charts = map[string]dto.DashboardChartDTO{} + } + } + if response.StatisticsData == nil { + response.StatisticsData = []dto.DashboardStatisticsDTO{} + } + if response.Charts == nil { + response.Charts = map[string]dto.DashboardChartDTO{} + } + + return response, 1, nil +} + +func (s dashboardService) buildPerformanceStatistics(ctx context.Context, params *validation.Query, filter *validation.DashboardFilter) ([]dto.DashboardStatisticsDTO, error) { + location, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + return nil, fmt.Errorf("failed to load timezone configuration: %w", err) + } + + if params.PeriodStart.IsZero() || params.PeriodEnd.IsZero() || params.PeriodEndExclusive.IsZero() { + return nil, errors.New("period dates are not initialized") + } + startDate := params.PeriodStart + endDate := params.PeriodEnd + endExclusive := params.PeriodEndExclusive + + hppCurrent, hppLast, err := s.calculateHppGlobal(ctx, filter, startDate, endExclusive, endDate, location) + if err != nil { + return nil, err + } + + sellingCurrent, sellingLast, err := s.calculateSellingPrice(ctx, filter, endDate, location) + if err != nil { + return nil, err + } + + hasFilter := filter != nil && (len(filter.LokasiIds) > 0 || len(filter.FlockIds) > 0 || len(filter.KandangIds) > 0) + fcrCurrent := 0.0 + fcrLast := 0.0 + mortalityCurrent := 0.0 + mortalityLast := 0.0 + if hasFilter { + fcrCurrent, fcrLast, err = s.calculateFcr(ctx, filter, startDate, endExclusive, endDate, location) + if err != nil { + return nil, err + } + mortalityCurrent, mortalityLast, err = s.calculateMortality(ctx, filter, startDate, endExclusive, endDate, location) + if err != nil { + return nil, err + } + } + + hppPercent := percentDelta(hppCurrent, hppLast) + sellingPercent := percentDelta(sellingCurrent, sellingLast) + + stats := []dto.DashboardStatisticsDTO{ + { + Label: "HPP Global", + Value: roundTo(hppCurrent, 0), + PercentLastMonth: roundTo(hppPercent*100, 2), + }, + { + Label: "Avg. Selling Price", + Value: roundTo(sellingCurrent, 0), + PercentLastMonth: roundTo(sellingPercent*100, 2), + }, + } + + if hasFilter { + fcrPercent := percentDelta(fcrCurrent, fcrLast) + mortalityPercent := percentDelta(mortalityCurrent, mortalityLast) + stats = append(stats, + dto.DashboardStatisticsDTO{ + Label: "FCR", + Value: roundTo(fcrCurrent, 2), + PercentLastMonth: roundTo(fcrPercent*100, 2), + }, + dto.DashboardStatisticsDTO{ + Label: "Mortality", + Value: roundTo(mortalityCurrent, 2), + PercentLastMonth: roundTo(mortalityPercent*100, 2), + }, + ) + } + + return stats, nil +} + +func (s dashboardService) buildPerformanceCharts(ctx context.Context, params *validation.Query, filter *validation.DashboardFilter) (map[string]dto.DashboardChartDTO, error) { + if params.AnalysisMode == validation.AnalysisModeComparison { + return s.buildComparisonCharts(ctx, params, filter) + } + + if params.PeriodStart.IsZero() || params.PeriodEndExclusive.IsZero() { + return nil, errors.New("period dates are not initialized") + } + + if filter == nil || (len(filter.LokasiIds) == 0 && len(filter.FlockIds) == 0 && len(filter.KandangIds) == 0) { + return map[string]dto.DashboardChartDTO{}, nil + } + + startDate := params.PeriodStart + endExclusive := params.PeriodEndExclusive + + recordings, err := s.Repository.GetRecordingWeeklyMetrics(ctx, startDate, endExclusive, filter) + if err != nil { + return nil, err + } + + uniformities, err := s.Repository.GetUniformityWeeklyMetrics(ctx, startDate, endExclusive, filter) + if err != nil { + return nil, err + } + + weekSet := map[int]struct{}{} + for _, row := range recordings { + if row.Week > 0 { + weekSet[row.Week] = struct{}{} + } + } + for _, row := range uniformities { + if row.Week > 0 { + weekSet[row.Week] = struct{}{} + } + } + + weeks := make([]int, 0, len(weekSet)) + for week := range weekSet { + weeks = append(weeks, week) + } + sort.Ints(weeks) + + standards, err := s.Repository.GetStandardWeeklyMetrics(ctx, weeks, filter) + if err != nil { + return nil, err + } + + standardFcr, err := s.Repository.GetStandardFcrWeekly(ctx, weeks, filter) + if err != nil { + return nil, err + } + + recordingMap := map[int]repository.RecordingWeeklyMetric{} + for _, row := range recordings { + recordingMap[row.Week] = row + } + + uniformityMap := map[int]repository.UniformityWeeklyMetric{} + for _, row := range uniformities { + uniformityMap[row.Week] = row + } + + standardMap := map[int]repository.StandardWeeklyMetric{} + for _, row := range standards { + standardMap[row.Week] = row + } + + standardFcrMap := map[int]float64{} + for _, row := range standardFcr { + standardFcrMap[row.Week] = row.StdFcr + } + + weeklyEggs, err := s.Repository.GetEggWeightWeeklyGrams(ctx, startDate, endExclusive, filter) + if err != nil { + return nil, err + } + weeklyFeedRows, err := s.Repository.GetFeedUsageWeeklyByUom(ctx, startDate, endExclusive, filter) + if err != nil { + return nil, err + } + + weeklyEggMap := map[int]float64{} + for _, row := range weeklyEggs { + weeklyEggMap[row.Week] = row.EggWeightGrams + } + + weeklyFeedMap := map[int]float64{} + for _, row := range weeklyFeedRows { + weeklyFeedMap[row.Week] += feedUsageRowToGrams(row.TotalQty, row.UomName) + } + + bodyWeightDataset := make([]map[string]interface{}, 0, len(weeks)) + performanceDataset := make([]map[string]interface{}, 0, len(weeks)) + fcrDataset := make([]map[string]interface{}, 0, len(weeks)) + deplesiDataset := make([]map[string]interface{}, 0, len(weeks)) + qualityDataset := make([]map[string]interface{}, 0, len(weeks)) + + cumEgg := 0.0 + cumFeed := 0.0 + + for _, week := range weeks { + rec := recordingMap[week] + uni := uniformityMap[week] + std := standardMap[week] + stdFcr := standardFcrMap[week] + weekEgg := weeklyEggMap[week] + weekFeed := weeklyFeedMap[week] + + actFcr := 0.0 + if weekFeed > 0 { + actFcr = weekEgg / weekFeed + } + + cumEgg += weekEgg + cumFeed += weekFeed + actFcrCum := 0.0 + if cumFeed > 0 { + actFcrCum = cumEgg / cumFeed + } + + bodyWeightDataset = append(bodyWeightDataset, map[string]interface{}{ + "week": week, + "body_weight": roundTo(uni.AverageWeight, 2), + "std_body_weight": roundTo(std.StdBodyWeight, 2), + }) + + performanceDataset = append(performanceDataset, map[string]interface{}{ + "week": week, + "act_laying": roundTo(rec.HenDay, 2), + "std_laying": roundTo(std.StdLaying, 2), + "act_egg_weight": roundTo(rec.EggWeight, 2), + "std_egg_weight": roundTo(std.StdEggWeight, 2), + "act_feed_intake": roundTo(rec.FeedIntake, 2), + "std_feed_intake": roundTo(std.StdFeedIntake, 2), + "act_uniformity": roundTo(uni.Uniformity, 2), + "std_uniformity": roundTo(std.StdUniformity, 2), + }) + + fcrDataset = append(fcrDataset, map[string]interface{}{ + "week": week, + "act_fcr": roundTo(actFcr, 2), + "std_fcr": roundTo(stdFcr, 2), + "act_fcr_cum": roundTo(actFcrCum, 2), + "std_fcr_cum": roundTo(stdFcr, 2), + }) + + deplesiDataset = append(deplesiDataset, map[string]interface{}{ + "week": week, + "act_deplesi": roundTo(rec.CumDepletionRate, 2), + "std_deplesi": roundTo(std.StdDepletion, 2), + }) + } + + qualityRows, err := s.Repository.GetEggQualityWeeklyMetrics(ctx, startDate, endExclusive, filter) + if err != nil { + return nil, err + } + + for _, row := range qualityRows { + normalPercent := 0.0 + abnormalPercent := 0.0 + if row.TotalQty > 0 { + normalPercent = (row.NormalQty / row.TotalQty) * 100 + abnormalPercent = (row.AbnormalQty / row.TotalQty) * 100 + } + qualityDataset = append(qualityDataset, map[string]interface{}{ + "week": row.Week, + "normal": roundTo(normalPercent, 2), + "abnormal": roundTo(abnormalPercent, 2), + }) + } + + charts := map[string]dto.DashboardChartDTO{ + "body_weight": { + Series: []dto.DashboardChartSeriesDTO{ + {Id: "body_weight", Label: "Body Weight", Unit: "g"}, + {Id: "std_body_weight", Label: "STD. Body Weight", Unit: "g"}, + }, + Dataset: bodyWeightDataset, + }, + "performance": { + Series: []dto.DashboardChartSeriesDTO{ + {Id: "act_laying", Label: "Act. % Laying", Unit: "%"}, + {Id: "std_laying", Label: "STD. % Laying", Unit: "%"}, + {Id: "act_egg_weight", Label: "Act. Egg Weight", Unit: "%"}, + {Id: "std_egg_weight", Label: "STD. Egg Weight", Unit: "%"}, + {Id: "act_feed_intake", Label: "Act. Feed Intake", Unit: "%"}, + {Id: "std_feed_intake", Label: "STD. Feed Intake", Unit: "%"}, + {Id: "act_uniformity", Label: "Act. Uniformity", Unit: "%"}, + {Id: "std_uniformity", Label: "STD. Uniformity", Unit: "%"}, + }, + Dataset: performanceDataset, + }, + "fcr": { + Series: []dto.DashboardChartSeriesDTO{ + {Id: "act_fcr", Label: "Act. FCR", Unit: "%"}, + {Id: "std_fcr", Label: "STD. FCR", Unit: "%"}, + {Id: "act_fcr_cum", Label: "Act. FCR Cummulative", Unit: "%"}, + {Id: "std_fcr_cum", Label: "STD. FCR Cummulative", Unit: "%"}, + }, + Dataset: fcrDataset, + }, + "deplesi": { + Series: []dto.DashboardChartSeriesDTO{ + {Id: "act_deplesi", Label: "Act. Deplesi", Unit: "%"}, + {Id: "std_deplesi", Label: "STD. Deplesi", Unit: "%"}, + }, + Dataset: deplesiDataset, + }, + "quality_control": { + Series: []dto.DashboardChartSeriesDTO{ + {Id: "normal", Label: "Normal", Unit: "%"}, + {Id: "abnormal", Label: "Abnormal", Unit: "%"}, + }, + Dataset: qualityDataset, + }, + } + + return charts, nil +} + +func (s dashboardService) buildComparisonCharts(ctx context.Context, params *validation.Query, filter *validation.DashboardFilter) (map[string]dto.DashboardChartDTO, error) { + if params.PeriodStart.IsZero() || params.PeriodEndExclusive.IsZero() { + return nil, errors.New("period dates are not initialized") + } + + startDate := params.PeriodStart + endExclusive := params.PeriodEndExclusive + + metric := strings.ToLower(strings.TrimSpace(params.Metric)) + if metric == "" { + return s.buildComparisonChartsAll(ctx, startDate, endExclusive, params, filter) + } + + seriesRows, err := s.Repository.GetComparisonSeries(ctx, startDate, endExclusive, filter, params.ComparisonType) + if err != nil { + return nil, err + } + + metricRows, err := s.Repository.GetComparisonWeeklyMetrics(ctx, startDate, endExclusive, filter, params.ComparisonType, metric) + if err != nil { + return nil, err + } + + weeks, actualMap := mapComparisonWeeklyMetricRows(metricRows) + if len(weeks) == 0 { + return map[string]dto.DashboardChartDTO{}, nil + } + + standardMap, err := s.standardComparisonMap(ctx, weeks, metric, filter) + if err != nil { + return nil, err + } + + chart := buildComparisonPercentChart(seriesRows, weeks, actualMap, standardMap) + return map[string]dto.DashboardChartDTO{ + strings.ToLower(params.ComparisonType): chart, + }, nil +} + +func (s dashboardService) buildComparisonChartsAll(ctx context.Context, startDate, endExclusive time.Time, params *validation.Query, filter *validation.DashboardFilter) (map[string]dto.DashboardChartDTO, error) { + seriesRows, err := s.Repository.GetComparisonSeries(ctx, startDate, endExclusive, filter, params.ComparisonType) + if err != nil { + return nil, err + } + + layingRows, err := s.Repository.GetComparisonWeeklyMetrics(ctx, startDate, endExclusive, filter, params.ComparisonType, validation.MetricLaying) + if err != nil { + return nil, err + } + eggWeightRows, err := s.Repository.GetComparisonWeeklyMetrics(ctx, startDate, endExclusive, filter, params.ComparisonType, validation.MetricEggWeight) + if err != nil { + return nil, err + } + feedIntakeRows, err := s.Repository.GetComparisonWeeklyMetrics(ctx, startDate, endExclusive, filter, params.ComparisonType, validation.MetricFeedIntake) + if err != nil { + return nil, err + } + fcrRows, err := s.Repository.GetComparisonWeeklyMetrics(ctx, startDate, endExclusive, filter, params.ComparisonType, validation.MetricFcr) + if err != nil { + return nil, err + } + deplesiRows, err := s.Repository.GetComparisonWeeklyMetrics(ctx, startDate, endExclusive, filter, params.ComparisonType, validation.MetricMortality) + if err != nil { + return nil, err + } + uniformityRows, err := s.Repository.GetComparisonWeeklyUniformityMetrics(ctx, startDate, endExclusive, filter, params.ComparisonType) + if err != nil { + return nil, err + } + + weeks := mergeComparisonWeeks( + layingRows, + eggWeightRows, + feedIntakeRows, + fcrRows, + deplesiRows, + uniformityRows, + ) + if len(weeks) == 0 { + return map[string]dto.DashboardChartDTO{}, nil + } + + standards, err := s.Repository.GetStandardWeeklyMetrics(ctx, weeks, filter) + if err != nil { + return nil, err + } + standardFcr, err := s.Repository.GetStandardFcrWeekly(ctx, weeks, filter) + if err != nil { + return nil, err + } + + stdBodyWeight := map[int]float64{} + stdLaying := map[int]float64{} + stdEggWeight := map[int]float64{} + stdFeedIntake := map[int]float64{} + stdUniformity := map[int]float64{} + stdDeplesi := map[int]float64{} + for _, row := range standards { + stdBodyWeight[row.Week] = row.StdBodyWeight + stdLaying[row.Week] = row.StdLaying + stdEggWeight[row.Week] = row.StdEggWeight + stdFeedIntake[row.Week] = row.StdFeedIntake + stdUniformity[row.Week] = row.StdUniformity + stdDeplesi[row.Week] = row.StdDepletion + } + + stdFcr := map[int]float64{} + for _, row := range standardFcr { + stdFcr[row.Week] = row.StdFcr + } + + _, layingActual := mapComparisonWeeklyMetricRows(layingRows) + _, eggWeightActual := mapComparisonWeeklyMetricRows(eggWeightRows) + _, feedActual := mapComparisonWeeklyMetricRows(feedIntakeRows) + _, fcrActual := mapComparisonWeeklyMetricRows(fcrRows) + _, deplesiActual := mapComparisonWeeklyMetricRows(deplesiRows) + _, bodyWeightActual, _, uniformityActual := mapComparisonUniformityRows(uniformityRows) + + aggregateActual := buildAggregateComparisonPercent(weeks, seriesRows, aggregateComparisonInput{ + BodyWeightActual: bodyWeightActual, + LayingActual: layingActual, + EggWeightActual: eggWeightActual, + FeedIntakeActual: feedActual, + UniformityActual: uniformityActual, + FcrActual: fcrActual, + DeplesiActual: deplesiActual, + StdBodyWeight: stdBodyWeight, + StdLaying: stdLaying, + StdEggWeight: stdEggWeight, + StdFeedIntake: stdFeedIntake, + StdUniformity: stdUniformity, + StdFcr: stdFcr, + StdDeplesi: stdDeplesi, + }) + + if len(aggregateActual) == 0 { + return map[string]dto.DashboardChartDTO{}, nil + } + + chartKey := strings.ToLower(params.ComparisonType) + return map[string]dto.DashboardChartDTO{ + chartKey: buildComparisonAggregateChart(seriesRows, weeks, aggregateActual), + }, nil +} + +type aggregateComparisonInput struct { + BodyWeightActual map[int]map[uint]float64 + LayingActual map[int]map[uint]float64 + EggWeightActual map[int]map[uint]float64 + FeedIntakeActual map[int]map[uint]float64 + UniformityActual map[int]map[uint]float64 + FcrActual map[int]map[uint]float64 + DeplesiActual map[int]map[uint]float64 + StdBodyWeight map[int]float64 + StdLaying map[int]float64 + StdEggWeight map[int]float64 + StdFeedIntake map[int]float64 + StdUniformity map[int]float64 + StdFcr map[int]float64 + StdDeplesi map[int]float64 +} + +func buildAggregateComparisonPercent(weeks []int, seriesRows []repository.ComparisonSeries, input aggregateComparisonInput) map[int]map[uint]float64 { + result := map[int]map[uint]float64{} + + for _, week := range weeks { + stdBodyWeight := input.StdBodyWeight[week] + stdLaying := input.StdLaying[week] + stdEggWeight := input.StdEggWeight[week] + stdFeedIntake := input.StdFeedIntake[week] + stdUniformity := input.StdUniformity[week] + stdFcr := input.StdFcr[week] + stdDeplesi := input.StdDeplesi[week] + + for _, series := range seriesRows { + sum := 0.0 + count := 0.0 + + if percent, ok := higherIsBetterPercent(input.LayingActual, week, series.Id, stdLaying); ok { + sum += percent + count++ + } + if percent, ok := higherIsBetterPercent(input.EggWeightActual, week, series.Id, stdEggWeight); ok { + sum += percent + count++ + } + if percent, ok := higherIsBetterPercent(input.UniformityActual, week, series.Id, stdUniformity); ok { + sum += percent + count++ + } + if percent, ok := lowerIsBetterPercent(input.FcrActual, week, series.Id, stdFcr); ok { + sum += percent + count++ + } + if percent, ok := lowerIsBetterPercent(input.DeplesiActual, week, series.Id, stdDeplesi); ok { + sum += percent + count++ + } + if percent, ok := higherIsBetterPercent(input.BodyWeightActual, week, series.Id, stdBodyWeight); ok { + sum += percent + count++ + } + if percent, ok := lowerIsBetterPercent(input.FeedIntakeActual, week, series.Id, stdFeedIntake); ok { + sum += percent + count++ + } + + if count == 0 { + continue + } + + if result[week] == nil { + result[week] = map[uint]float64{} + } + result[week][series.Id] = sum / count + } + } + + return result +} + +func higherIsBetterPercent(actual map[int]map[uint]float64, week int, seriesId uint, standard float64) (float64, bool) { + if standard <= 0 { + return 0, false + } + val, ok := metricValue(actual, week, seriesId) + if !ok || val <= 0 { + return 0, false + } + return clampPercent((val / standard) * 100), true +} + +func lowerIsBetterPercent(actual map[int]map[uint]float64, week int, seriesId uint, standard float64) (float64, bool) { + if standard <= 0 { + return 0, false + } + val, ok := metricValue(actual, week, seriesId) + if !ok || val <= 0 { + return 0, false + } + return clampPercent((standard / val) * 100), true +} + +func metricValue(actual map[int]map[uint]float64, week int, seriesId uint) (float64, bool) { + weekRows, ok := actual[week] + if !ok { + return 0, false + } + val, ok := weekRows[seriesId] + return val, ok +} + +func clampPercent(value float64) float64 { + if value < 0 { + return 0 + } + if value > 200 { + return 200 + } + return value +} + +func buildComparisonAggregateChart(seriesRows []repository.ComparisonSeries, weeks []int, actual map[int]map[uint]float64) dto.DashboardChartDTO { + series := make([]dto.DashboardChartSeriesDTO, 0, len(seriesRows)) + for _, sRow := range seriesRows { + series = append(series, dto.DashboardChartSeriesDTO{ + Id: strconv.FormatUint(uint64(sRow.Id), 10), + Label: sRow.Label, + Unit: "%", + }) + } + + dataset := make([]map[string]interface{}, 0, len(weeks)) + for _, week := range weeks { + row := map[string]interface{}{ + "week": week, + } + values, ok := actual[week] + if !ok { + continue + } + for _, sRow := range seriesRows { + if val, exists := values[sRow.Id]; exists { + row[strconv.FormatUint(uint64(sRow.Id), 10)] = roundTo(val, 2) + } + } + if len(row) > 1 { + dataset = append(dataset, row) + } + } + + return dto.DashboardChartDTO{ + Series: series, + Dataset: dataset, + } +} + +func (s dashboardService) standardComparisonMap(ctx context.Context, weeks []int, metric string, filter *validation.DashboardFilter) (map[int]float64, error) { + switch metric { + case validation.MetricFcr: + rows, err := s.Repository.GetStandardFcrWeekly(ctx, weeks, filter) + if err != nil { + return nil, err + } + result := map[int]float64{} + for _, row := range rows { + result[row.Week] = row.StdFcr + } + return result, nil + case validation.MetricLaying, validation.MetricEggWeight, validation.MetricFeedIntake, validation.MetricMortality: + rows, err := s.Repository.GetStandardWeeklyMetrics(ctx, weeks, filter) + if err != nil { + return nil, err + } + result := map[int]float64{} + for _, row := range rows { + switch metric { + case validation.MetricLaying: + result[row.Week] = row.StdLaying + case validation.MetricEggWeight: + result[row.Week] = row.StdEggWeight + case validation.MetricFeedIntake: + result[row.Week] = row.StdFeedIntake + case validation.MetricMortality: + result[row.Week] = row.StdDepletion + } + } + return result, nil + default: + return map[int]float64{}, nil + } +} + +func mapComparisonWeeklyMetricRows(rows []repository.ComparisonWeeklyMetric) ([]int, map[int]map[uint]float64) { + weekSet := map[int]struct{}{} + values := map[int]map[uint]float64{} + for _, row := range rows { + if row.Week <= 0 { + continue + } + weekSet[row.Week] = struct{}{} + if values[row.Week] == nil { + values[row.Week] = map[uint]float64{} + } + values[row.Week][row.SeriesId] = row.Value + } + + weeks := make([]int, 0, len(weekSet)) + for week := range weekSet { + weeks = append(weeks, week) + } + sort.Ints(weeks) + return weeks, values +} + +func mapComparisonUniformityRows(rows []repository.ComparisonUniformityMetric) ([]int, map[int]map[uint]float64, []int, map[int]map[uint]float64) { + bodyWeightSet := map[int]struct{}{} + bodyWeightValues := map[int]map[uint]float64{} + uniformitySet := map[int]struct{}{} + uniformityValues := map[int]map[uint]float64{} + + for _, row := range rows { + if row.Week <= 0 { + continue + } + bodyWeightSet[row.Week] = struct{}{} + uniformitySet[row.Week] = struct{}{} + if bodyWeightValues[row.Week] == nil { + bodyWeightValues[row.Week] = map[uint]float64{} + } + if uniformityValues[row.Week] == nil { + uniformityValues[row.Week] = map[uint]float64{} + } + bodyWeightValues[row.Week][row.SeriesId] = row.AverageWeight + uniformityValues[row.Week][row.SeriesId] = row.Uniformity + } + + bodyWeightWeeks := make([]int, 0, len(bodyWeightSet)) + for week := range bodyWeightSet { + bodyWeightWeeks = append(bodyWeightWeeks, week) + } + sort.Ints(bodyWeightWeeks) + + uniformityWeeks := make([]int, 0, len(uniformitySet)) + for week := range uniformitySet { + uniformityWeeks = append(uniformityWeeks, week) + } + sort.Ints(uniformityWeeks) + + return bodyWeightWeeks, bodyWeightValues, uniformityWeeks, uniformityValues +} + +func mergeComparisonWeeks(rows ...interface{}) []int { + weekSet := map[int]struct{}{} + for _, row := range rows { + switch typed := row.(type) { + case []repository.ComparisonWeeklyMetric: + for _, item := range typed { + if item.Week > 0 { + weekSet[item.Week] = struct{}{} + } + } + case []repository.ComparisonUniformityMetric: + for _, item := range typed { + if item.Week > 0 { + weekSet[item.Week] = struct{}{} + } + } + } + } + + weeks := make([]int, 0, len(weekSet)) + for week := range weekSet { + weeks = append(weeks, week) + } + sort.Ints(weeks) + return weeks +} + +func buildComparisonPercentChart(seriesRows []repository.ComparisonSeries, weeks []int, actual map[int]map[uint]float64, standard map[int]float64) dto.DashboardChartDTO { + series := make([]dto.DashboardChartSeriesDTO, 0, len(seriesRows)) + for _, row := range seriesRows { + series = append(series, dto.DashboardChartSeriesDTO{ + Id: strconv.FormatUint(uint64(row.Id), 10), + Label: row.Label, + Unit: "%", + }) + } + + dataset := make([]map[string]interface{}, 0, len(weeks)) + for _, week := range weeks { + row := map[string]interface{}{ + "week": week, + } + std := standard[week] + for _, sRow := range seriesRows { + key := strconv.FormatUint(uint64(sRow.Id), 10) + actualVal := actual[week][sRow.Id] + percent := 0.0 + if std > 0 { + percent = (actualVal / std) * 100 + } + row[key] = roundTo(percent, 2) + } + dataset = append(dataset, row) + } + + return dto.DashboardChartDTO{ + Series: series, + Dataset: dataset, + } +} + +func percentDelta(current, last float64) float64 { + if last <= 0 { + return 0 + } + return (current - last) / last +} + +func (s dashboardService) calculateHppGlobal(ctx context.Context, filter *validation.DashboardFilter, startDate, endExclusive, endDate time.Time, location *time.Location) (float64, float64, error) { + totalEggKg, err := s.Repository.SumEggProductionWeightKg(ctx, startDate, endExclusive, filter) + if err != nil { + return 0, 0, err + } + totalCost, err := s.sumHppCost(ctx, filter, startDate, endExclusive) + if err != nil { + return 0, 0, err + } + + hppCurrent := 0.0 + if totalEggKg > 0 { + hppCurrent = totalCost / totalEggKg + } + + lastMonthStart, lastMonthEndExclusive := monthRange(endDate.AddDate(0, -1, 0), location) + lastEggKg, err := s.Repository.SumEggProductionWeightKg(ctx, lastMonthStart, lastMonthEndExclusive, filter) + if err != nil { + return 0, 0, err + } + lastCost, err := s.sumHppCost(ctx, filter, lastMonthStart, lastMonthEndExclusive) + if err != nil { + return 0, 0, err + } + + hppLast := 0.0 + if lastEggKg > 0 { + hppLast = lastCost / lastEggKg + } + + return hppCurrent, hppLast, nil +} + +func (s dashboardService) calculateSellingPrice(ctx context.Context, filter *validation.DashboardFilter, endDate time.Time, location *time.Location) (float64, float64, error) { + startPrevMonth, endPrevMonthExclusive := monthRange(endDate.AddDate(0, -1, 0), location) + currentEndExclusive := endDate.AddDate(0, 0, 1) + + currentAvg, err := s.avgSellingPrice(ctx, filter, startPrevMonth, currentEndExclusive) + if err != nil { + return 0, 0, err + } + + lastAvg, err := s.avgSellingPrice(ctx, filter, startPrevMonth, endPrevMonthExclusive) + if err != nil { + return 0, 0, err + } + + return currentAvg, lastAvg, nil +} + +func (s dashboardService) calculateFcr(ctx context.Context, filter *validation.DashboardFilter, startDate, endExclusive, endDate time.Time, location *time.Location) (float64, float64, error) { + current, err := s.fcrValue(ctx, filter, startDate, endExclusive) + if err != nil { + return 0, 0, err + } + + lastMonthStart, lastMonthEndExclusive := monthRange(endDate.AddDate(0, -1, 0), location) + last, err := s.fcrValue(ctx, filter, lastMonthStart, lastMonthEndExclusive) + if err != nil { + return 0, 0, err + } + + return current, last, nil +} + +func (s dashboardService) calculateMortality(ctx context.Context, filter *validation.DashboardFilter, startDate, endExclusive, endDate time.Time, location *time.Location) (float64, float64, error) { + current, err := s.mortalityValue(ctx, filter, startDate, endExclusive) + if err != nil { + return 0, 0, err + } + + lastMonthStart, lastMonthEndExclusive := monthRange(endDate.AddDate(0, -1, 0), location) + last, err := s.mortalityValue(ctx, filter, lastMonthStart, lastMonthEndExclusive) + if err != nil { + return 0, 0, err + } + + return current, last, nil +} + +func (s dashboardService) fcrValue(ctx context.Context, filter *validation.DashboardFilter, startDate, endExclusive time.Time) (float64, error) { + eggWeightGrams, err := s.Repository.SumEggProductionWeightGrams(ctx, startDate, endExclusive, filter) + if err != nil { + return 0, err + } + + feedRows, err := s.Repository.GetFeedUsageByUom(ctx, startDate, endExclusive, filter) + if err != nil { + return 0, err + } + feedUsageGrams := feedUsageToGrams(feedRows) + + if feedUsageGrams <= 0 { + return 0, nil + } + + return eggWeightGrams / feedUsageGrams, nil +} + +func (s dashboardService) mortalityValue(ctx context.Context, filter *validation.DashboardFilter, startDate, endExclusive time.Time) (float64, error) { + depletions, err := s.Repository.SumDepletions(ctx, startDate, endExclusive, filter) + if err != nil { + return 0, err + } + + initialPopulation, err := s.Repository.SumInitialPopulation(ctx, endExclusive.AddDate(0, 0, -1), filter) + if err != nil { + return 0, err + } + + if initialPopulation <= 0 { + return 0, nil + } + + return (depletions / initialPopulation) * 100, nil +} + +func (s dashboardService) sumHppCost(ctx context.Context, filter *validation.DashboardFilter, startDate, endExclusive time.Time) (float64, error) { + sapronak, err := s.Repository.SumSapronakCost(ctx, startDate, endExclusive, filter) + if err != nil { + return 0, err + } + + bop, err := s.Repository.SumBopCost(ctx, startDate, endExclusive, filter) + if err != nil { + return 0, err + } + + ekspedisi, err := s.Repository.SumEkspedisiCost(ctx, startDate, endExclusive, filter) + if err != nil { + return 0, err + } + + return sapronak + bop + ekspedisi, nil +} + +func (s dashboardService) avgSellingPrice(ctx context.Context, filter *validation.DashboardFilter, startDate, endExclusive time.Time) (float64, error) { + result, err := s.Repository.SumSellingPrice(ctx, startDate, endExclusive, filter) + if err != nil { + return 0, err + } + + if result.TotalWeight <= 0 { + return 0, nil + } + + return result.TotalPrice / result.TotalWeight, nil +} + +func feedUsageToGrams(rows []repository.FeedUsageByUom) float64 { + total := 0.0 + for _, row := range rows { + total += feedUsageRowToGrams(row.TotalQty, row.UomName) + } + return total +} + +func feedUsageRowToGrams(totalQty float64, uomName string) float64 { + if totalQty <= 0 { + return 0 + } + switch strings.TrimSpace(strings.ToLower(uomName)) { + case "kilogram", "kg", "kilograms", "kilo": + return totalQty * 1000 + case "gram", "g", "grams": + return totalQty + default: + return totalQty + } +} + +func roundTo(value float64, decimals int) float64 { + if decimals <= 0 { + return math.Round(value) + } + multiplier := math.Pow(10, float64(decimals)) + return math.Round(value*multiplier) / multiplier +} + +func monthRange(t time.Time, location *time.Location) (time.Time, time.Time) { + start := time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, location) + endExclusive := start.AddDate(0, 1, 0) + return start, endExclusive +} diff --git a/internal/modules/dashboards/validations/dashboard.validation.go b/internal/modules/dashboards/validations/dashboard.validation.go new file mode 100644 index 00000000..b372f493 --- /dev/null +++ b/internal/modules/dashboards/validations/dashboard.validation.go @@ -0,0 +1,54 @@ +package validation + +import "time" + +type Create struct { + Name string `json:"name" validate:"required_strict,min=3"` +} + +const ( + AnalysisModeOverview = "OVERVIEW" + AnalysisModeComparison = "COMPARISON" + + ComparisonTypeFarm = "FARM" + ComparisonTypeFlock = "FLOCK" + ComparisonTypeKandang = "KANDANG" + + MetricFcr = "fcr" + MetricMortality = "mortality" + MetricLaying = "laying" + MetricEggWeight = "egg_weight" + MetricFeedIntake = "feed_intake" +) + +type Update struct { + Name *string `json:"name,omitempty" validate:"omitempty"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` + PerformanceOverviewFilter + PeriodStart time.Time `json:"-" query:"-"` + PeriodEnd time.Time `json:"-" query:"-"` + PeriodEndExclusive time.Time `json:"-" query:"-"` +} + +type PerformanceOverviewFilter struct { + StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"` + EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"` + AnalysisMode string `query:"analysis_mode" validate:"omitempty,oneof=OVERVIEW COMPARISON"` + ComparisonType string `query:"comparison_type" validate:"omitempty,oneof=FARM FLOCK KANDANG"` + Metric string `query:"metric" validate:"omitempty,oneof=fcr mortality laying egg_weight feed_intake"` + LokasiIds []uint `query:"location_ids" validate:"omitempty,dive,gt=0"` + FlockIds []uint `query:"flock_ids" validate:"omitempty,dive,gt=0"` + KandangIds []uint `query:"kandang_ids" validate:"omitempty,dive,gt=0"` + Include []string `query:"include" validate:"omitempty,dive,oneof=statistics charts"` +} + +type DashboardFilter struct { + LokasiIds []uint + FlockIds []uint + KandangIds []uint +} diff --git a/internal/modules/expenses/dto/expense.dto.go b/internal/modules/expenses/dto/expense.dto.go index 129c2e96..30cecd99 100644 --- a/internal/modules/expenses/dto/expense.dto.go +++ b/internal/modules/expenses/dto/expense.dto.go @@ -9,6 +9,7 @@ import ( nonstockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/dto" supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" + "gitlab.com/mbugroup/lti-api.git/internal/utils" ) // === DTO Structs === @@ -32,6 +33,7 @@ type ExpenseBaseDTO struct { type ExpenseListDTO struct { ExpenseBaseDTO + GrandTotal float64 `json:"grand_total"` CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -140,8 +142,11 @@ func ToExpenseListDTO(e entity.Expense) ExpenseListDTO { latestApproval = &mapped } + grandTotal := calculateGrandTotal(&e) + return ExpenseListDTO{ ExpenseBaseDTO: ToExpenseBaseDTO(&e), + GrandTotal: grandTotal, CreatedUser: createdUser, CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt, @@ -344,3 +349,25 @@ func ToKandangGroupDTO(pengajuans []ExpenseNonstockDTO, realisasi []ExpenseReali return kandangs } + +func calculateGrandTotal(expense *entity.Expense) float64 { + + useRealization := expense.LatestApproval != nil && expense.LatestApproval.StepNumber >= uint16(utils.ExpenseStepRealisasi) + + if useRealization { + + var total float64 + for _, ns := range expense.Nonstocks { + if ns.Realization != nil { + total += ns.Realization.Qty * ns.Realization.Price + } + } + return total + } + + var total float64 + for _, ns := range expense.Nonstocks { + total += ns.Qty * ns.Price + } + return total +} diff --git a/internal/modules/expenses/repositories/expense_realization.repository.go b/internal/modules/expenses/repositories/expense_realization.repository.go index 474b2962..f1387483 100644 --- a/internal/modules/expenses/repositories/expense_realization.repository.go +++ b/internal/modules/expenses/repositories/expense_realization.repository.go @@ -75,7 +75,7 @@ func (r *ExpenseRealizationRepositoryImpl) GetAllWithFilters(ctx context.Context Joins("LEFT JOIN suppliers ON suppliers.id = expenses.supplier_id") if filters.Search != "" { - db = db.Where("expenses.category LIKE ? OR expenses.reference_number LIKE ? OR expenses.po_number LIKE ? OR expenses.notes LIKE ? OR suppliers.name LIKE ?", + db = db.Where("expenses.category ILIKE ? OR expenses.reference_number ILIKE ? OR expenses.po_number ILIKE ? OR expenses.notes ILIKE ? OR suppliers.name ILIKE ?", "%"+filters.Search+"%", "%"+filters.Search+"%", "%"+filters.Search+"%", "%"+filters.Search+"%", "%"+filters.Search+"%") } diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index 50646ed6..4e2e218f 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -92,7 +92,7 @@ func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expens expenses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.Search != "" { - return db.Where("category LIKE ?", "%"+params.Search+"%") + return db.Where("category ILIKE ?", "%"+params.Search+"%") } return db.Order("created_at DESC").Order("updated_at DESC") }) @@ -211,6 +211,7 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen if !hasKandang && req.Category == string(utils.ExpenseCategoryBOP) { projectFlockRepoTx := projectFlockKandangRepo.NewProjectflockRepository(dbTransaction) activeProjectFlocks, err := projectFlockRepoTx.GetActiveByLocationID(c.Context(), req.LocationID) + if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get active project flocks for location") } diff --git a/internal/modules/finance/initials/dto/initial.dto.go b/internal/modules/finance/initials/dto/initial.dto.go index 5eb76e9c..1311024f 100644 --- a/internal/modules/finance/initials/dto/initial.dto.go +++ b/internal/modules/finance/initials/dto/initial.dto.go @@ -101,20 +101,25 @@ func ToInitialDetailDTO(e entity.Payment) InitialDetailDTO { func partyFromInitial(e entity.Payment) Party { party := Party{ - Id: e.PartyId, - Type: e.PartyType, + Id: e.PartyId, + Type: e.PartyType, + } + if e.PartyAccountNumber != nil { + party.AccountNumber = *e.PartyAccountNumber } switch utils.PaymentParty(e.PartyType) { case utils.PaymentPartyCustomer: if e.Customer != nil && e.Customer.Id != 0 { party.Name = e.Customer.Name - party.AccountNumber = e.Customer.AccountNumber + if party.AccountNumber == "" { + party.AccountNumber = e.Customer.AccountNumber + } } case utils.PaymentPartySupplier: if e.Supplier != nil && e.Supplier.Id != 0 { party.Name = e.Supplier.Name - if e.Supplier.AccountNumber != nil { + if party.AccountNumber == "" && e.Supplier.AccountNumber != nil { party.AccountNumber = *e.Supplier.AccountNumber } } diff --git a/internal/modules/finance/initials/services/initial.service.go b/internal/modules/finance/initials/services/initial.service.go index 2eb15d3b..e06e99dd 100644 --- a/internal/modules/finance/initials/services/initial.service.go +++ b/internal/modules/finance/initials/services/initial.service.go @@ -120,6 +120,7 @@ func (s *initialService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit TransactionType: string(utils.TransactionTypeSaldoAwal), PartyType: party, PartyId: req.PartyId, + PartyAccountNumber: nil, PaymentDate: time.Now(), PaymentMethod: string(utils.PaymentMethodSaldo), BankId: req.BankId, diff --git a/internal/modules/finance/injections/services/injection.service.go b/internal/modules/finance/injections/services/injection.service.go index 1b1062b4..8cb80e1c 100644 --- a/internal/modules/finance/injections/services/injection.service.go +++ b/internal/modules/finance/injections/services/injection.service.go @@ -106,6 +106,7 @@ func (s *injectionService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent TransactionType: string(utils.TransactionTypeInjection), PartyType: string(utils.PaymentPartyCustomer), PartyId: 0, + PartyAccountNumber: nil, PaymentDate: adjustmentDate, PaymentMethod: string(utils.PaymentMethodSaldo), BankId: req.BankId, diff --git a/internal/modules/finance/injections/validations/injection.validation.go b/internal/modules/finance/injections/validations/injection.validation.go index eb324525..b5b75087 100644 --- a/internal/modules/finance/injections/validations/injection.validation.go +++ b/internal/modules/finance/injections/validations/injection.validation.go @@ -1,17 +1,17 @@ package validation type Create struct { - BankId *uint `json:"bank_id" validate:"required_strict,number,gt=0"` - AdjustmentDate string `json:"adjustment_date" validate:"required_strict"` - Nominal float64 `json:"nominal" validate:"required_strict,gt=0"` - Notes string `json:"notes" validate:"required_strict,max=500"` + BankId *uint `json:"bank_id" validate:"required_strict,number,gt=0"` + AdjustmentDate string `json:"adjustment_date" validate:"required_strict"` + Nominal float64 `json:"nominal" validate:"required_strict,gt=0"` + Notes string `json:"notes" validate:"required_strict,max=500"` } type Update struct { - BankId *uint `json:"bank_id,omitempty" validate:"omitempty,number,gt=0"` - AdjustmentDate *string `json:"adjustment_date,omitempty" validate:"omitempty"` - Nominal *float64 `json:"nominal,omitempty" validate:"omitempty,gt=0"` - Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` + BankId *uint `json:"bank_id,omitempty" validate:"omitempty,number,gt=0"` + AdjustmentDate *string `json:"adjustment_date,omitempty" validate:"omitempty"` + Nominal *float64 `json:"nominal,omitempty" validate:"omitempty,gt=0"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` } type Query struct { diff --git a/internal/modules/finance/payments/dto/payment.dto.go b/internal/modules/finance/payments/dto/payment.dto.go index 23005e2d..3fbc8ad4 100644 --- a/internal/modules/finance/payments/dto/payment.dto.go +++ b/internal/modules/finance/payments/dto/payment.dto.go @@ -124,20 +124,25 @@ func ToPaymentDetailDTO(e entity.Payment) PaymentDetailDTO { func partyFromPayment(e entity.Payment) Party { party := Party{ - Id: e.PartyId, - Type: e.PartyType, + Id: e.PartyId, + Type: e.PartyType, + } + if e.PartyAccountNumber != nil { + party.AccountNumber = *e.PartyAccountNumber } switch utils.PaymentParty(e.PartyType) { case utils.PaymentPartyCustomer: if e.Customer != nil && e.Customer.Id != 0 { party.Name = e.Customer.Name - party.AccountNumber = e.Customer.AccountNumber + if party.AccountNumber == "" { + party.AccountNumber = e.Customer.AccountNumber + } } case utils.PaymentPartySupplier: if e.Supplier != nil && e.Supplier.Id != 0 { party.Name = e.Supplier.Name - if e.Supplier.AccountNumber != nil { + if party.AccountNumber == "" && e.Supplier.AccountNumber != nil { party.AccountNumber = *e.Supplier.AccountNumber } } diff --git a/internal/modules/finance/payments/route.go b/internal/modules/finance/payments/route.go index c5147fc0..c2931f0a 100644 --- a/internal/modules/finance/payments/route.go +++ b/internal/modules/finance/payments/route.go @@ -15,7 +15,7 @@ func PaymentRoutes(v1 fiber.Router, u user.UserService, s payment.PaymentService route := v1.Group("/payments") route.Use(m.Auth(u)) - route.Post("/",m.RequirePermissions(m.P_Finances_Payments_CreateOne), ctrl.CreateOne) - route.Get("/:id",m.RequirePermissions(m.P_Finances_Payments_GetOne), ctrl.GetOne) - route.Patch("/:id",m.RequirePermissions(m.P_Finances_Payments_UpdateOne), ctrl.UpdateOne) + route.Post("/", m.RequirePermissions(m.P_Finances_Payments_CreateOne), ctrl.CreateOne) + route.Get("/:id", m.RequirePermissions(m.P_Finances_Payments_GetOne), ctrl.GetOne) + route.Patch("/:id", m.RequirePermissions(m.P_Finances_Payments_UpdateOne), ctrl.UpdateOne) } diff --git a/internal/modules/finance/payments/services/payment.service.go b/internal/modules/finance/payments/services/payment.service.go index 356288f1..8860f3f4 100644 --- a/internal/modules/finance/payments/services/payment.service.go +++ b/internal/modules/finance/payments/services/payment.service.go @@ -121,18 +121,19 @@ func (s *paymentService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit } createBody := &entity.Payment{ - PaymentCode: code, - ReferenceNumber: req.ReferenceNumber, - TransactionType: transactionType, - PartyType: party, - PartyId: req.PartyId, - PaymentDate: paymentDate, - PaymentMethod: method, - BankId: req.BankId, - Direction: directionForParty(party), - Nominal: req.Nominal, - Notes: req.Notes, - CreatedBy: actorID, + PaymentCode: code, + ReferenceNumber: req.ReferenceNumber, + TransactionType: transactionType, + PartyType: party, + PartyId: req.PartyId, + PartyAccountNumber: req.PartyAccountNumber, + PaymentDate: paymentDate, + PaymentMethod: method, + BankId: req.BankId, + Direction: directionForParty(party), + Nominal: req.Nominal, + Notes: req.Notes, + CreatedBy: actorID, } err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { @@ -188,6 +189,9 @@ func (s paymentService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) if req.ReferenceNumber != nil { updateBody["reference_number"] = *req.ReferenceNumber } + if req.PartyAccountNumber != nil { + updateBody["party_account_number"] = *req.PartyAccountNumber + } if req.PaymentMethod != nil { method, err := normalizePaymentMethod(*req.PaymentMethod) if err != nil { diff --git a/internal/modules/finance/payments/validations/payment.validation.go b/internal/modules/finance/payments/validations/payment.validation.go index 14c8f151..a2ab9950 100644 --- a/internal/modules/finance/payments/validations/payment.validation.go +++ b/internal/modules/finance/payments/validations/payment.validation.go @@ -1,25 +1,27 @@ package validation type Create struct { - PartyType string `json:"party_type" validate:"required_strict,min=1,max=50"` - PartyId uint `json:"party_id" validate:"required_strict,number,gt=0"` - PaymentDate string `json:"payment_date" validate:"required_strict,datetime=2006-01-02"` - Nominal float64 `json:"nominal" validate:"required_strict"` - ReferenceNumber *string `json:"reference_number,omitempty"` - PaymentMethod string `json:"payment_method" validate:"required_strict,max=20"` - BankId *uint `json:"bank_id" validate:"omitempty,number,gt=0"` - Notes string `json:"notes" validate:"required_strict,max=500"` + PartyType string `json:"party_type" validate:"required_strict,min=1,max=50"` + PartyId uint `json:"party_id" validate:"required_strict,number,gt=0"` + PartyAccountNumber *string `json:"party_account_number"` + PaymentDate string `json:"payment_date" validate:"required_strict,datetime=2006-01-02"` + Nominal float64 `json:"nominal" validate:"required_strict"` + ReferenceNumber *string `json:"reference_number,omitempty"` + PaymentMethod string `json:"payment_method" validate:"required_strict,max=20"` + BankId *uint `json:"bank_id" validate:"omitempty,number,gt=0"` + Notes string `json:"notes" validate:"required_strict,max=500"` } type Update struct { - PartyType *string `json:"party_type,omitempty" validate:"omitempty,max=50"` - PartyId *uint `json:"party_id,omitempty" validate:"omitempty,number,gt=0"` - PaymentDate *string `json:"payment_date,omitempty" validate:"omitempty,datetime=2006-01-02"` - Nominal *float64 `json:"nominal,omitempty" validate:"omitempty,gt=0"` - ReferenceNumber *string `json:"reference_number,omitempty"` - PaymentMethod *string `json:"payment_method,omitempty" validate:"omitempty,max=20"` - BankId *uint `json:"bank_id,omitempty" validate:"omitempty,number,gt=0"` - Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` + PartyType *string `json:"party_type,omitempty" validate:"omitempty,max=50"` + PartyId *uint `json:"party_id,omitempty" validate:"omitempty,number,gt=0"` + PartyAccountNumber *string `json:"party_account_number,omitempty"` + PaymentDate *string `json:"payment_date,omitempty" validate:"omitempty,datetime=2006-01-02"` + Nominal *float64 `json:"nominal,omitempty" validate:"omitempty,gt=0"` + ReferenceNumber *string `json:"reference_number,omitempty"` + PaymentMethod *string `json:"payment_method,omitempty" validate:"omitempty,max=20"` + BankId *uint `json:"bank_id,omitempty" validate:"omitempty,number,gt=0"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` } type Query struct { diff --git a/internal/modules/finance/transactions/dto/transaction.dto.go b/internal/modules/finance/transactions/dto/transaction.dto.go index 25740344..07703fce 100644 --- a/internal/modules/finance/transactions/dto/transaction.dto.go +++ b/internal/modules/finance/transactions/dto/transaction.dto.go @@ -124,20 +124,25 @@ func ToTransactionDetailDTO(e entity.Payment) TransactionDetailDTO { func partyFromPayment(e entity.Payment) Party { party := Party{ - Id: e.PartyId, - Type: e.PartyType, + Id: e.PartyId, + Type: e.PartyType, + } + if e.PartyAccountNumber != nil { + party.AccountNumber = *e.PartyAccountNumber } switch utils.PaymentParty(e.PartyType) { case utils.PaymentPartyCustomer: if e.Customer != nil && e.Customer.Id != 0 { party.Name = e.Customer.Name - party.AccountNumber = e.Customer.AccountNumber + if party.AccountNumber == "" { + party.AccountNumber = e.Customer.AccountNumber + } } case utils.PaymentPartySupplier: if e.Supplier != nil && e.Supplier.Id != 0 { party.Name = e.Supplier.Name - if e.Supplier.AccountNumber != nil { + if party.AccountNumber == "" && e.Supplier.AccountNumber != nil { party.AccountNumber = *e.Supplier.AccountNumber } } diff --git a/internal/modules/inventory/adjustments/module.go b/internal/modules/inventory/adjustments/module.go index 08e556ea..6b137902 100644 --- a/internal/modules/inventory/adjustments/module.go +++ b/internal/modules/inventory/adjustments/module.go @@ -36,7 +36,7 @@ func (AdjustmentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validat fifoService := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log) err := fifoService.RegisterStockable(fifo.StockableConfig{ - Key: fifo.StockableKey("ADJUSTMENT_IN"), + Key: fifo.StockableKeyAdjustmentIn, Table: "adjustment_stocks", Columns: fifo.StockableColumns{ ID: "id", @@ -52,7 +52,7 @@ func (AdjustmentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validat } err = fifoService.RegisterUsable(fifo.UsableConfig{ - Key: fifo.UsableKey("ADJUSTMENT_OUT"), + Key: fifo.UsableKeyAdjustmentOut, Table: "adjustment_stocks", Columns: fifo.UsableColumns{ ID: "id", diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index 47d41648..71b985c2 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -20,6 +20,7 @@ import ( projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" stockLogsRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "gorm.io/gorm" ) @@ -123,15 +124,9 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e projectFlockKandangID = &pfkID } - pw, err := s.ProductWarehouseRepo.FindByProductWarehouseAndPfk( - ctx, - uint(req.ProductID), - uint(req.WarehouseID), - projectFlockKandangID, - ) + pw, err := s.ProductWarehouseRepo.FindByProductWarehouseAndPfk(ctx, uint(req.ProductID), uint(req.WarehouseID), projectFlockKandangID) if err != nil { if !errors.Is(err, gorm.ErrRecordNotFound) { - s.Log.Errorf("Failed to find product warehouse: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse") } @@ -143,7 +138,6 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e } if err := s.ProductWarehouseRepo.CreateOne(ctx, newPW, nil); err != nil { - s.Log.Errorf("Failed to create product warehouse: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create product warehouse") } pw = newPW @@ -163,7 +157,6 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e return fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse") } - // Create StockLog for history tracking afterQuantity := productWarehouse.Quantity newLog := &entity.StockLog{ LoggableType: string(utils.StockLogTypeAdjustment), @@ -189,7 +182,6 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e return err } - // Create AdjustmentStock record for FIFO tracking adjustmentStock := &entity.AdjustmentStock{ StockLogId: newLog.Id, ProductWarehouseId: productWarehouse.Id, @@ -200,10 +192,10 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e } if transactionType == string(utils.StockLogTransactionTypeIncrease) { - // Adjustment INCREASE → Replenish stock (Stockable) + note := fmt.Sprintf("Stock Adjustment IN #%d", newLog.Id) _, err := s.FifoSvc.Replenish(ctx, common.StockReplenishRequest{ - StockableKey: "ADJUSTMENT_IN", + StockableKey: fifo.StockableKeyAdjustmentIn, StockableID: adjustmentStock.Id, ProductWarehouseID: uint(productWarehouse.Id), Quantity: req.Quantity, @@ -215,9 +207,8 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e } } else { - // Adjustment DECREASE → Consume stock (Usable) _, err := s.FifoSvc.Consume(ctx, common.StockConsumeRequest{ - UsableKey: "ADJUSTMENT_OUT", + UsableKey: fifo.UsableKeyAdjustmentOut, UsableID: adjustmentStock.Id, ProductWarehouseID: uint(productWarehouse.Id), Quantity: req.Quantity, @@ -230,6 +221,7 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e } // Update ProductWarehouse quantity (for backward compatibility/reporting) + productWarehouse.Quantity = afterQuantity if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(ctx, productWarehouse.Id, productWarehouse, nil); err != nil { s.Log.Errorf("Failed to update product warehouse quantity: %+v", err) 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 a8a44eb7..a7fe452b 100644 --- a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go +++ b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go @@ -23,6 +23,7 @@ type ProductWarehouseRepository interface { 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) + ListProductIDsByFlagPrefixes(ctx context.Context, prefixes []string, visibleStatus *bool) ([]uint, 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) @@ -380,3 +381,38 @@ func (r *ProductWarehouseRepositoryImpl) GetFirstProductByFlag(ctx context.Conte } return &product, nil } + +func (r *ProductWarehouseRepositoryImpl) ListProductIDsByFlagPrefixes(ctx context.Context, prefixes []string, visibleStatus *bool) ([]uint, error) { + if len(prefixes) == 0 { + return []uint{}, nil + } + + db := r.DB().WithContext(ctx). + Model(&entity.Product{}). + Distinct("products.id"). + Joins("JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = ?", entity.FlagableTypeProduct) + + applied := false + for _, prefix := range prefixes { + if prefix == "" { + continue + } + like := prefix + "%" + if !applied { + db = db.Where("flags.name ILIKE ?", like) + applied = true + continue + } + db = db.Or("flags.name ILIKE ?", like) + } + + if visibleStatus != nil { + db = db.Where("products.is_visible = ?", *visibleStatus) + } + + var ids []uint + if err := db.Pluck("products.id", &ids).Error; err != nil { + return nil, err + } + return ids, nil +} diff --git a/internal/modules/inventory/transfers/controllers/transfer.controller.go b/internal/modules/inventory/transfers/controllers/transfer.controller.go index 4f060dc2..530d70dc 100644 --- a/internal/modules/inventory/transfers/controllers/transfer.controller.go +++ b/internal/modules/inventory/transfers/controllers/transfer.controller.go @@ -75,6 +75,8 @@ func (u *TransferController) GetOne(c *fiber.Ctx) error { func (u *TransferController) CreateOne(c *fiber.Ctx) error { data := c.FormValue("data") + const maxFileSize = 5 * 1024 * 1024 + var req validation.TransferRequest if err := json.Unmarshal([]byte(data), &req); err != nil { return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") @@ -87,9 +89,11 @@ func (u *TransferController) CreateOne(c *fiber.Ctx) error { files := form.File["documents"] - if len(files) != len(req.Deliveries) { - return fiber.NewError(fiber.StatusBadRequest, - fiber.NewError(fiber.StatusBadRequest, "Jumlah dokumen harus sama dengan jumlah deliveries").Message) + for i, file := range files { + if file.Size > maxFileSize { + return fiber.NewError(fiber.StatusBadRequest, + "Dokumen ke-"+strconv.Itoa(i+1)+" melebihi ukuran maksimal 5MB") + } } result, err := u.TransferService.CreateOne(c, &req, files) diff --git a/internal/modules/inventory/transfers/dto/transfer.dto.go b/internal/modules/inventory/transfers/dto/transfer.dto.go index 8f075715..8fa4d158 100644 --- a/internal/modules/inventory/transfers/dto/transfer.dto.go +++ b/internal/modules/inventory/transfers/dto/transfer.dto.go @@ -71,9 +71,11 @@ type TransferDetailDTO struct { } type TransferDetailItemDTO struct { - Id uint64 `json:"id"` - Product ProductSimpleDTO `json:"product"` - Quantity float64 `json:"quantity"` + Id uint64 `json:"id"` + Product ProductSimpleDTO `json:"product"` + Quantity float64 `json:"quantity"` + TransportPerItem *float64 `json:"transport_per_item,omitempty"` // Biaya ekspedisi per item + ExpeditionVendor *SupplierSimpleDTO `json:"expedition_vendor,omitempty"` // Vendor ekspedisi } type TransferDeliveryDTO struct { @@ -153,14 +155,30 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { var details []TransferDetailItemDTO for _, d := range e.Details { - details = append(details, TransferDetailItemDTO{ + detailDTO := TransferDetailItemDTO{ Id: d.Id, Product: ProductSimpleDTO{ Id: d.Product.Id, Name: d.Product.Name, }, Quantity: d.UsageQty + d.PendingQty, // Total actual quantity allocated - }) + } + + + if d.ExpenseNonstock != nil { + priceCopy := d.ExpenseNonstock.Price + detailDTO.TransportPerItem = &priceCopy + + if d.ExpenseNonstock.Expense != nil && d.ExpenseNonstock.Expense.Supplier != nil && d.ExpenseNonstock.Expense.Supplier.Id != 0 { + exp := d.ExpenseNonstock.Expense + detailDTO.ExpeditionVendor = &SupplierSimpleDTO{ + Id: exp.Supplier.Id, + Name: exp.Supplier.Name, + } + } + } + + details = append(details, detailDTO) } var deliveries []TransferDeliveryDTO @@ -223,18 +241,43 @@ func ToTransferListDTOs(e []entity.StockTransfer) []TransferListDTO { func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { var details []TransferDetailItemDTO for _, d := range e.Details { - details = append(details, TransferDetailItemDTO{ + detailDTO := TransferDetailItemDTO{ Id: d.Id, Product: ProductSimpleDTO{ Id: d.Product.Id, Name: d.Product.Name, }, Quantity: d.UsageQty + d.PendingQty, // Total actual quantity allocated - }) + } + + + if d.ExpenseNonstock != nil { + priceCopy := d.ExpenseNonstock.Price + detailDTO.TransportPerItem = &priceCopy + + if d.ExpenseNonstock.Expense != nil && d.ExpenseNonstock.Expense.Supplier != nil && d.ExpenseNonstock.Expense.Supplier.Id != 0 { + exp := d.ExpenseNonstock.Expense + detailDTO.ExpeditionVendor = &SupplierSimpleDTO{ + Id: exp.Supplier.Id, + Name: exp.Supplier.Name, + } + } + } + + details = append(details, detailDTO) } var deliveries []TransferDeliveryDTO for _, del := range e.Deliveries { + var items []TransferDeliveryItemDTO + for _, item := range del.Items { + items = append(items, TransferDeliveryItemDTO{ + Id: item.Id, + StockTransferDetailId: item.StockTransferDetailId, + Quantity: item.Quantity, + }) + } + var document *DocumentDTO if len(del.Documents) > 0 { doc := del.Documents[0] // Take first document @@ -258,6 +301,7 @@ func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { DocumentNumber: del.DocumentNumber, ShippingCostItem: del.ShippingCostItem, ShippingCostTotal: del.ShippingCostTotal, + Items: items, Document: document, }) } diff --git a/internal/modules/inventory/transfers/module.go b/internal/modules/inventory/transfers/module.go index 60d1764a..fde5e55a 100644 --- a/internal/modules/inventory/transfers/module.go +++ b/internal/modules/inventory/transfers/module.go @@ -2,6 +2,7 @@ package transfers import ( "context" + "fmt" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -9,9 +10,13 @@ import ( commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" + expenseService "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/services" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rStockTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/repositories" sTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services" + rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" + rNonstock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/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" rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" @@ -35,20 +40,47 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate userRepo := rUser.NewUserRepository(db) warehouseRepo := rWarehouse.NewWarehouseRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) + kandangRepo := rKandang.NewKandangRepository(db) + nonstockRepo := rNonstock.NewNonstockRepository(db) documentRepo := commonRepo.NewDocumentRepository(db) stockAllocRepo := commonRepo.NewStockAllocationRepository(db) + expenseRepository := expenseRepo.NewExpenseRepository(db) + expenseRealizationRepo := expenseRepo.NewExpenseRealizationRepository(db) documentSvc, err := commonSvc.NewDocumentServiceFromConfig(context.Background(), documentRepo) if err != nil { panic(err) } - // Initialize FIFO Service - fifoService := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log) - // Register Transfer as Stockable (adds stock to destination warehouse) + approvalRepo := commonRepo.NewApprovalRepository(db) + approvalSvc := commonSvc.NewApprovalService(approvalRepo) + if err := approvalSvc.RegisterWorkflowSteps(utils.ApprovalWorkflowExpense, utils.ExpenseApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register expense approval workflow: %v", err)) + } + + expenseServiceInstance := expenseService.NewExpenseService( + expenseRepository, + supplierRepo, + nonstockRepo, + approvalSvc, + expenseRealizationRepo, + projectFlockKandangRepo, + documentSvc, + validate, + ) + + fifoService := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log) + expenseBridge := sTransfer.NewTransferExpenseBridge( + db, + stockTransferRepo, + projectFlockKandangRepo, + kandangRepo, + expenseServiceInstance, + ) + err = fifoService.RegisterStockable(fifo.StockableConfig{ - Key: fifo.StockableKey("STOCK_TRANSFER_IN"), + Key: fifo.StockableKeyStockTransferIn, Table: "stock_transfer_details", Columns: fifo.StockableColumns{ ID: "id", @@ -63,9 +95,8 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate panic(err) } - // Register Transfer as Usable (consumes stock from source warehouse) err = fifoService.RegisterUsable(fifo.UsableConfig{ - Key: fifo.UsableKey("STOCK_TRANSFER_OUT"), + Key: fifo.UsableKeyStockTransferOut, Table: "stock_transfer_details", Columns: fifo.UsableColumns{ ID: "id", @@ -80,7 +111,7 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate panic(err) } - transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo, warehouseRepo, projectFlockKandangRepo, documentSvc, fifoService) + transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo, warehouseRepo, projectFlockKandangRepo, documentSvc, fifoService, expenseBridge) userService := sUser.NewUserService(userRepo, validate) TransferRoutes(router, userService, transferService) diff --git a/internal/modules/inventory/transfers/repositories/stock_transfer.repository.go b/internal/modules/inventory/transfers/repositories/stock_transfer.repository.go index e79d6310..cd314901 100644 --- a/internal/modules/inventory/transfers/repositories/stock_transfer.repository.go +++ b/internal/modules/inventory/transfers/repositories/stock_transfer.repository.go @@ -2,6 +2,7 @@ package repositories import ( "context" + "fmt" "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -12,6 +13,7 @@ type StockTransferRepository interface { repository.BaseRepository[entity.StockTransfer] // get sequence for movement number GetNextMovementNumber(ctx context.Context) (int64, error) + GenerateMovementNumber(ctx context.Context) (string, error) } type StockTransferRepositoryImpl struct { @@ -32,3 +34,12 @@ func (r *StockTransferRepositoryImpl) GetNextMovementNumber(ctx context.Context) } return seq, nil } + +func (r *StockTransferRepositoryImpl) GenerateMovementNumber(ctx context.Context) (string, error) { + seq, err := r.GetNextMovementNumber(ctx) + if err != nil { + return "", err + } + movementNumber := fmt.Sprintf("ST-%05d", seq) + return movementNumber, nil +} diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index 1ca35a71..3f12b444 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -21,6 +21,7 @@ import ( projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "gorm.io/gorm" ) @@ -45,9 +46,10 @@ type transferService struct { ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository DocumentSvc commonSvc.DocumentService FifoSvc commonSvc.FifoService + ExpenseBridge TransferExpenseBridge } -func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, documentSvc commonSvc.DocumentService, fifoSvc commonSvc.FifoService) TransferService { +func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, documentSvc commonSvc.DocumentService, fifoSvc commonSvc.FifoService, expenseBridge TransferExpenseBridge) TransferService { return &transferService{ Log: utils.Log, Validate: validate, @@ -62,6 +64,7 @@ func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTr ProjectFlockKandangRepo: projectFlockKandangRepo, DocumentSvc: documentSvc, FifoSvc: fifoSvc, + ExpenseBridge: expenseBridge, } } @@ -76,6 +79,9 @@ func (s transferService) withRelations(db *gorm.DB) *gorm.DB { Preload("ToWarehouse.Area"). Preload("Details"). Preload("Details.Product"). + Preload("Details.ExpenseNonstock"). + Preload("Details.ExpenseNonstock.Expense"). + Preload("Details.ExpenseNonstock.Expense.Supplier"). Preload("Deliveries.Items"). Preload("Deliveries.Supplier"). Preload("Deliveries.Documents", func(db *gorm.DB) *gorm.DB { @@ -93,7 +99,7 @@ func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit transfers, total, err := s.StockTransferRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.Search != "" { - db = db.Where("movement_number LIKE ?", "%"+strings.TrimSpace(params.Search)+"%") + db = db.Where("movement_number ILIKE ?", "%"+strings.TrimSpace(params.Search)+"%") } return db.Order("created_at DESC").Order("updated_at DESC") }) @@ -122,7 +128,6 @@ func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, e func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferRequest, files []*multipart.FileHeader) (*entity.StockTransfer, error) { - // === VALIDASI SOURCE WAREHOUSE === pwIDs := make([]uint, 0, len(req.Products)) for _, product := range req.Products { @@ -154,14 +159,12 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return nil, err } - if s.ProjectFlockKandangRepo != nil { - projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), destPfkID) - if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock") - } - if projectFlockKandang.ClosedAt != nil { - return nil, fiber.NewError(fiber.StatusBadRequest, "Project flock tujuan sudah closing") - } + projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), destPfkID) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock") + } + if projectFlockKandang.ClosedAt != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Project flock tujuan sudah closing") } actorID, err := m.ActorIDFromContext(c) @@ -191,16 +194,16 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal cek data supplier") } - if supplier.Category != "BOP" { + if supplier.Category != string(utils.SupplierCategoryBOP) { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Supplier dengan ID %d bukan kategori BOP", delivery.SupplierID)) } } - seqNum, err := s.StockTransferRepo.GetNextMovementNumber(c.Context()) + movementNumber, err := s.StockTransferRepo.GenerateMovementNumber(c.Context()) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to generate movement number") } - movementNumber := fmt.Sprintf("PND-MBU-%05d", seqNum) + transferDate, _ := utils.ParseDateString(req.TransferDate) entityTransfer := &entity.StockTransfer{ @@ -212,19 +215,26 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques CreatedBy: uint64(actorID), } + expensePayloads := make([]TransferExpenseReceivingPayload, 0) + err = s.StockTransferRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { - if err := s.StockTransferRepo.WithTx(tx).CreateOne(c.Context(), entityTransfer, nil); err != nil { + stockTransferRepoTX := s.StockTransferRepo.WithTx(tx) + stockTransferDetailRepoTX := s.StockTransferDetailRepo.WithTx(tx) + stockTransferDeliveryRepoTX := s.StockTransferDeliveryRepo.WithTx(tx) + stockTransferDeliveryItemRepoTX := s.StockTransferDeliveryItemRepo.WithTx(tx) + productWarehouseRepoTX := rProductWarehouse.NewProductWarehouseRepository(tx) + + if err := stockTransferRepoTX.CreateOne(c.Context(), entityTransfer, nil); err != nil { return err } - // Prepare details and fetch product warehouses details := make([]*entity.StockTransferDetail, 0, len(req.Products)) detailMap := make(map[uint64]*entity.StockTransferDetail) for _, product := range req.Products { - // Get source product warehouse - sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID( + + sourcePW, err := productWarehouseRepoTX.GetProductWarehouseByProductAndWarehouseID( c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID), ) if err != nil { @@ -234,8 +244,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data product warehouse source") } - // Get or create destination product warehouse - destPW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID( + destPW, err := productWarehouseRepoTX.GetProductWarehouseByProductAndWarehouseID( c.Context(), uint(product.ProductID), uint(req.DestinationWarehouseID), ) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { @@ -253,7 +262,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques Quantity: 0, ProjectFlockKandangId: &projectFlockKandangID, } - if err := s.ProductWarehouseRepo.WithTx(tx).CreateOne(c.Context(), destPW, nil); err != nil { + if err := productWarehouseRepoTX.CreateOne(c.Context(), destPW, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat product warehouse destination") } } @@ -274,7 +283,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques detailMap[uint64(product.ProductID)] = detail } - if err := s.StockTransferDetailRepo.WithTx(tx).CreateMany(c.Context(), details, nil); err != nil { + if err := stockTransferDetailRepoTX.CreateMany(c.Context(), details, nil); err != nil { return err } @@ -289,7 +298,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques ShippingCostTotal: delivery.DeliveryCost, }) } - if err := s.StockTransferDeliveryRepo.WithTx(tx).CreateMany(c.Context(), deliveries, nil); err != nil { + if err := stockTransferDeliveryRepoTX.CreateMany(c.Context(), deliveries, nil); err != nil { return err } @@ -309,52 +318,63 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques }) } } - if err := s.StockTransferDeliveryItemRepo.WithTx(tx).CreateMany(c.Context(), deliveryItems, nil); err != nil { + if err := stockTransferDeliveryItemRepoTX.CreateMany(c.Context(), deliveryItems, nil); err != nil { return err } if s.DocumentSvc != nil && len(files) > 0 { - for idx, file := range files { + for deliveryIdx, delivery := range deliveries { + reqDelivery := req.Deliveries[deliveryIdx] + + if reqDelivery.DocumentIndex < 0 { + continue + } + + if reqDelivery.DocumentIndex >= len(files) { + return fiber.NewError(fiber.StatusBadRequest, + fmt.Sprintf("DocumentIndex %d untuk delivery %d melebihi jumlah file yang diupload (%d)", + reqDelivery.DocumentIndex, deliveryIdx+1, len(files))) + } + + file := files[reqDelivery.DocumentIndex] + documentFiles := []commonSvc.DocumentFile{ { File: file, Type: string(utils.DocumentTypeTransfer), - Index: &idx, + Index: &reqDelivery.DocumentIndex, }, } _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ DocumentableType: string(utils.DocumentableTypeTransfer), - DocumentableID: deliveries[idx].Id, + DocumentableID: delivery.Id, CreatedBy: &actorID, Files: documentFiles, }) if err != nil { s.Log.WithError(err).Errorf("Failed to upload document for delivery %d (delivery_id: %d, filename: %s)", - idx+1, deliveries[idx].Id, file.Filename) - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to upload document for delivery %d: %v", idx+1, err)) + deliveryIdx+1, delivery.Id, file.Filename) + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to upload document for delivery %d: %v", deliveryIdx+1, err)) } } } - // Execute FIFO operations for each product for _, product := range req.Products { detail := detailMap[uint64(product.ProductID)] - // Step 1: Consume stock from source warehouse (STOCK_TRANSFER_OUT) consumeResult, err := s.FifoSvc.Consume(c.Context(), commonSvc.StockConsumeRequest{ - UsableKey: "STOCK_TRANSFER_OUT", + UsableKey: fifo.UsableKeyStockTransferOut, UsableID: uint(detail.Id), ProductWarehouseID: uint(*detail.SourceProductWarehouseID), Quantity: product.ProductQty, - AllowPending: false, // Don't allow pending, must have actual stock + AllowPending: false, Tx: tx, }) if err != nil { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak cukup di gudang asal untuk produk %d: %v", product.ProductID, err)) } - // Update usage tracking fields for source warehouse if err := tx.Model(&entity.StockTransferDetail{}). Where("id = ?", detail.Id). Updates(map[string]interface{}{ @@ -364,10 +384,9 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return fmt.Errorf("gagal update usage tracking: %w", err) } - // Step 2: Replenish stock to destination warehouse (STOCK_TRANSFER_IN) note := fmt.Sprintf("Transfer #%s", entityTransfer.MovementNumber) replenishResult, err := s.FifoSvc.Replenish(c.Context(), commonSvc.StockReplenishRequest{ - StockableKey: "STOCK_TRANSFER_IN", + StockableKey: fifo.StockableKeyStockTransferIn, StockableID: uint(detail.Id), ProductWarehouseID: uint(*detail.DestProductWarehouseID), Quantity: product.ProductQty, @@ -378,7 +397,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal menambah stok di gudang tujuan untuk produk %d: %v", product.ProductID, err)) } - // Update total tracking fields for destination warehouse if err := tx.Model(&entity.StockTransferDetail{}). Where("id = ?", detail.Id). Updates(map[string]interface{}{ @@ -388,6 +406,32 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } } + if len(req.Deliveries) > 0 { + for _, delivery := range req.Deliveries { + for _, prod := range delivery.Products { + detail := detailMap[uint64(prod.ProductID)] + if detail == nil { + continue + } + + warehouseID := uint(req.DestinationWarehouseID) + supplierID := uint(delivery.SupplierID) + deliveredDate := transferDate + deliveredQty := prod.ProductQty + + payload := TransferExpenseReceivingPayload{ + TransferDetailID: detail.Id, + ProductID: uint64(prod.ProductID), + WarehouseID: uint64(warehouseID), + SupplierID: uint64(supplierID), + DeliveredQty: deliveredQty, + DeliveredDate: &deliveredDate, + } + expensePayloads = append(expensePayloads, payload) + } + } + } + return nil }) @@ -399,9 +443,31 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques if err != nil { return nil, err } + + if len(expensePayloads) > 0 { + if err := s.notifyExpenseItemsDelivered(c, entityTransfer.Id, expensePayloads); err != nil { + s.Log.Errorf("Failed to sync expense for transfer %d: %+v", entityTransfer.Id, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to sync expense: %v", err)) + } + } + return result, nil } +func (s *transferService) notifyExpenseItemsDelivered(c *fiber.Ctx, transferID uint64, payloads []TransferExpenseReceivingPayload) error { + if s.ExpenseBridge == nil || transferID == 0 || len(payloads) == 0 { + return nil + } + return s.ExpenseBridge.OnItemsDelivered(c, transferID, payloads) +} + +func (s *transferService) notifyExpenseDetailsDeleted(ctx context.Context, transferID uint64, items []entity.StockTransferDetail) error { + if s.ExpenseBridge == nil || transferID == 0 || len(items) == 0 { + return nil + } + return s.ExpenseBridge.OnItemsDeleted(ctx, transferID, items) +} + func (s *transferService) getActiveProjectFlockKandangID(ctx context.Context, warehouseID uint) (uint, error) { warehouse, err := s.WarehouseRepo.GetByID(ctx, warehouseID, nil) if err != nil { diff --git a/internal/modules/inventory/transfers/services/transfer_expense_bridge.go b/internal/modules/inventory/transfers/services/transfer_expense_bridge.go new file mode 100644 index 00000000..90350c18 --- /dev/null +++ b/internal/modules/inventory/transfers/services/transfer_expense_bridge.go @@ -0,0 +1,473 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "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" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + expenseDto "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/dto" + expenseSvc "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/services" + expenseValidation "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/validations" + rStockTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/repositories" + kandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" + projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +type TransferExpenseBridge interface { + OnItemsDeleted(ctx context.Context, transferID uint64, items []entity.StockTransferDetail) error + OnItemsDelivered(c *fiber.Ctx, transferID uint64, updates []TransferExpenseReceivingPayload) error +} + +type TransferExpenseReceivingPayload struct { + TransferDetailID uint64 + ProductID uint64 + WarehouseID uint64 + SupplierID uint64 + TransportPerItem *float64 + DeliveredQty float64 + DeliveredDate *time.Time +} + +type groupedTransferItem struct { + detail *entity.StockTransferDetail + payload TransferExpenseReceivingPayload + projectFK *uint + kandangID *uint + totalPrice float64 + shippingCostTotal float64 +} + +func groupingKey(supplierID uint, date time.Time, warehouseID uint) string { + return fmt.Sprintf("%d:%s:%d", supplierID, utils.FormatDate(date), warehouseID) +} + +type transferExpenseBridge struct { + db *gorm.DB + transferRepo rStockTransfer.StockTransferRepository + projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository + kandangRepo kandangRepo.KandangRepository + expenseSvc expenseSvc.ExpenseService +} + +func NewTransferExpenseBridge( + db *gorm.DB, + transferRepo rStockTransfer.StockTransferRepository, + projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, + kandangRepo kandangRepo.KandangRepository, + expenseSvc expenseSvc.ExpenseService, +) TransferExpenseBridge { + return &transferExpenseBridge{ + db: db, + transferRepo: transferRepo, + projectFlockKandangRepo: projectFlockKandangRepo, + kandangRepo: kandangRepo, + expenseSvc: expenseSvc, + } +} + +func (b *transferExpenseBridge) OnItemsDeleted(ctx context.Context, _ uint64, items []entity.StockTransferDetail) error { + if len(items) == 0 { + return nil + } + + return b.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + expenseIDs := make(map[uint64]struct{}) + expenseNonstockIDs := make([]uint64, 0) + + + for _, item := range items { + if item.ExpenseNonstockId != nil && *item.ExpenseNonstockId != 0 { + expenseNonstockIDs = append(expenseNonstockIDs, *item.ExpenseNonstockId) + } + } + + if len(expenseNonstockIDs) > 0 { + + for _, nsID := range expenseNonstockIDs { + var expenseID uint64 + if err := tx.Model(&entity.ExpenseNonstock{}). + Select("expense_id"). + Where("id = ?", nsID). + Scan(&expenseID).Error; err != nil { + return err + } + if expenseID != 0 { + expenseIDs[expenseID] = struct{}{} + } + } + + + if err := tx.Delete(&entity.ExpenseNonstock{}, expenseNonstockIDs).Error; err != nil { + return err + } + } + + + approvalRepoTx := commonRepo.NewApprovalRepository(tx) + for expenseID := range expenseIDs { + var count int64 + if err := tx.Model(&entity.ExpenseNonstock{}). + Where("expense_id = ?", expenseID). + Count(&count).Error; err != nil { + return err + } + + + if count == 0 { + if err := approvalRepoTx.DeleteByTarget(ctx, utils.ApprovalWorkflowExpense.String(), uint(expenseID)); err != nil { + return err + } + if err := tx.Delete(&entity.Expense{}, expenseID).Error; err != nil { + return err + } + } + } + + return nil + }) +} + +func (b *transferExpenseBridge) markExpensesUpdated(ctx context.Context, expenseIDs map[uint64]struct{}, actorID uint) error { + if len(expenseIDs) == 0 { + return nil + } + if actorID == 0 { + actorID = 1 + } + svc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(b.db)) + action := entity.ApprovalActionUpdated + for id := range expenseIDs { + if _, err := svc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(id), utils.ExpenseStepFinance, &action, actorID, nil); err != nil { + return err + } + } + return nil +} + +func (b *transferExpenseBridge) findExpeditionNonstockID(ctx context.Context, supplierID uint) (uint64, error) { + var id uint64 + err := b.db.WithContext(ctx). + Table("nonstocks AS ns"). + Select("ns.id"). + Joins("JOIN nonstock_suppliers nss ON nss.nonstock_id = ns.id"). + Joins("JOIN flags f ON f.flagable_id = ns.id AND f.flagable_type = ?", entity.FlagableTypeNonstock). + Where("UPPER(f.name) = ?", strings.ToUpper(string(utils.FlagEkspedisi))). + Where("nss.supplier_id = ?", supplierID). + Order("ns.id"). + Limit(1). + Scan(&id).Error + if err != nil { + return 0, err + } + if id == 0 { + return 0, fiber.NewError(fiber.StatusBadRequest, "supplier id tidak sesuai dengan expedisi") + } + return id, nil +} + +func (b *transferExpenseBridge) createExpenseViaService( + c *fiber.Ctx, + transfer *entity.StockTransfer, + items []groupedTransferItem, + expenseDate time.Time, + expeditionNonstockID uint64, + movementNumber string, + supplierID uint, +) (*expenseDto.ExpenseDetailDTO, error) { + ctx := c.Context() + if b.expenseSvc == nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "expense service not available") + } + if len(items) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "no items to create expense") + } + + kandangID := items[0].kandangID + var locationID uint64 + var expenseKandangID *uint64 + if kandangID != nil && *kandangID != 0 { + kandang, err := b.kandangRepo.GetByID(ctx, *kandangID, func(db *gorm.DB) *gorm.DB { + return db.Select("id, location_id") + }) + if err != nil { + return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Kandang not found: %d", *kandangID)) + } + if kandang == nil { + return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Kandang not found: %d", *kandangID)) + } + locationID = uint64(kandang.LocationId) + id := uint64(*kandangID) + expenseKandangID = &id + } else { + + if transfer.ToWarehouse == nil || transfer.ToWarehouse.LocationId == nil || *transfer.ToWarehouse.LocationId == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Destination warehouse location is required for expense") + } + locationID = uint64(*transfer.ToWarehouse.LocationId) + } + + costItems := make([]expenseValidation.CostItem, 0, len(items)) + for _, gi := range items { + note := fmt.Sprintf("stock_transfer_detail:%d", gi.detail.Id) + + + price := gi.shippingCostTotal + if gi.payload.TransportPerItem != nil { + price = *gi.payload.TransportPerItem * gi.payload.DeliveredQty + } + + costItems = append(costItems, expenseValidation.CostItem{ + NonstockID: expeditionNonstockID, + Quantity: 1, + Price: price, + Notes: note, + }) + } + + req := &expenseValidation.Create{ + PoNumber: "", + TransactionDate: utils.FormatDate(expenseDate), + Category: string(utils.ExpenseCategoryBOP), + SupplierID: uint64(supplierID), + LocationID: locationID, + ExpenseNonstocks: []expenseValidation.ExpenseNonstock{{ + KandangID: expenseKandangID, + CostItems: costItems, + }}, + } + + detail, err := b.expenseSvc.CreateOne(c, req) + if err != nil { + return nil, err + } + + + action := entity.ApprovalActionApproved + actorID := uint(transfer.CreatedBy) + if actorID == 0 { + actorID = 1 + } + approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(b.db)) + if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepManager, &action, actorID, nil); err != nil { + return nil, err + } + if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepFinance, &action, actorID, nil); err != nil { + return nil, err + } + + return detail, nil +} + +func (b *transferExpenseBridge) linkExpenseNonstocksToDetails(ctx context.Context, detail *expenseDto.ExpenseDetailDTO, items []groupedTransferItem) error { + if detail == nil || len(items) == 0 { + return nil + } + + noteToExpenseNonstock := mapExpenseNotesForTransfer(detail) + + if len(noteToExpenseNonstock) == 0 { + return nil + } + + for _, gi := range items { + expenseNonstockID, ok := noteToExpenseNonstock[gi.detail.Id] + if !ok { + continue + } + if err := b.db.WithContext(ctx). + Model(&entity.StockTransferDetail{}). + Where("id = ?", gi.detail.Id). + Update("expense_nonstock_id", expenseNonstockID).Error; err != nil { + return err + } + } + + return nil +} + +func mapExpenseNotesForTransfer(detail *expenseDto.ExpenseDetailDTO) map[uint64]uint64 { + result := make(map[uint64]uint64) + if detail == nil { + return result + } + for _, kandang := range detail.Kandangs { + for _, pengajuan := range kandang.Pengajuans { + note := strings.TrimSpace(pengajuan.Notes) + if note == "" { + continue + } + const prefix = "stock_transfer_detail:" + if !strings.HasPrefix(note, prefix) { + continue + } + idStr := strings.TrimPrefix(note, prefix) + var detailID uint64 + if _, err := fmt.Sscanf(idStr, "%d", &detailID); err != nil { + continue + } + result[detailID] = pengajuan.Id + } + } + return result +} + +func (b *transferExpenseBridge) OnItemsDelivered(c *fiber.Ctx, transferID uint64, updates []TransferExpenseReceivingPayload) error { + if transferID == 0 || len(updates) == 0 { + return nil + } + + ctx := c.Context() + + + transfer, err := b.transferRepo.GetByID(ctx, uint(transferID), func(db *gorm.DB) *gorm.DB { + return db. + Preload("Details"). + Preload("Details.Product"). + Preload("Details.DestProductWarehouse"). + Preload("Details.DeliveryItems"). + Preload("Details.DeliveryItems.StockTransferDelivery"). + Preload("ToWarehouse") + }) + if err != nil { + return err + } + + detailMap := make(map[uint64]*entity.StockTransferDetail, len(transfer.Details)) + shippingCostMap := make(map[uint64]float64) // detailID -> ShippingCostTotal + + for i := range transfer.Details { + detailMap[transfer.Details[i].Id] = &transfer.Details[i] + + + for _, deliveryItem := range transfer.Details[i].DeliveryItems { + if deliveryItem.StockTransferDelivery != nil { + shippingCostMap[transfer.Details[i].Id] = deliveryItem.StockTransferDelivery.ShippingCostTotal + break + } + } + } + + groups := make(map[string][]groupedTransferItem) + + for _, payload := range updates { + if payload.DeliveredDate == nil { + return fiber.NewError(fiber.StatusBadRequest, "delivered_date is required") + } + detail := detailMap[payload.TransferDetailID] + if detail == nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Transfer detail %d not found", payload.TransferDetailID)) + } + if payload.DeliveredQty <= 0 { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Delivered quantity for detail %d must be greater than 0", payload.TransferDetailID)) + } + + deliveredDate := payload.DeliveredDate.UTC().Truncate(24 * time.Hour) + supplierID := payload.SupplierID + if supplierID == 0 { + supplierID = 1 // Default supplier + } + + var kandangID *uint + var projectFK *uint + if detail.DestProductWarehouse.WarehouseId != 0 { + var kandangIDResult uint + if err := b.db.WithContext(ctx). + Table("warehouses"). + Select("kandang_id"). + Where("id = ?", detail.DestProductWarehouse.WarehouseId). + Scan(&kandangIDResult).Error; err == nil && kandangIDResult != 0 { + id := uint(kandangIDResult) + kandangID = &id + if project, err := b.projectFlockKandangRepo.GetActiveByKandangID(ctx, kandangIDResult); err == nil && project != nil { + pid := uint(project.Id) + projectFK = &pid + } + } + } + + + shippingCostTotal := shippingCostMap[detail.Id] + + + totalPrice := shippingCostTotal + if payload.TransportPerItem != nil { + + totalPrice = *payload.TransportPerItem * payload.DeliveredQty + } + + + warehouseID := uint(payload.WarehouseID) + if warehouseID == 0 && transfer.ToWarehouse != nil { + warehouseID = uint(transfer.ToWarehouse.Id) + } + if warehouseID == 0 && detail.DestProductWarehouse != nil { + warehouseID = uint(detail.DestProductWarehouse.WarehouseId) + } + + key := groupingKey(uint(supplierID), deliveredDate, warehouseID) + groups[key] = append(groups[key], groupedTransferItem{ + detail: detail, + payload: payload, + projectFK: projectFK, + kandangID: kandangID, + totalPrice: totalPrice, + shippingCostTotal: shippingCostTotal, + }) + } + + updatedExpenses := make(map[uint64]struct{}) + + for key, items := range groups { + if len(items) == 0 { + continue + } + parts := strings.Split(key, ":") + if len(parts) < 3 { + return errors.New("invalid expense grouping key") + } + expenseDate, err := utils.ParseDateString(parts[1]) + if err != nil { + return err + } + + supplierID, err := strconv.ParseUint(parts[0], 10, 64) + if err != nil { + return err + } + + expeditionNonstockID, err := b.findExpeditionNonstockID(ctx, uint(supplierID)) + if err != nil { + return err + } + + expenseDetail, err := b.createExpenseViaService(c, transfer, items, expenseDate, expeditionNonstockID, transfer.MovementNumber, uint(supplierID)) + if err != nil { + return err + } + if err := b.linkExpenseNonstocksToDetails(ctx, expenseDetail, items); err != nil { + return err + } + if expenseDetail != nil && expenseDetail.Id != 0 { + updatedExpenses[uint64(expenseDetail.Id)] = struct{}{} + } + } + + if len(updatedExpenses) > 0 { + actorID := uint(1) // Default actor + if err := b.markExpensesUpdated(ctx, updatedExpenses, actorID); err != nil { + return err + } + } + + return nil +} diff --git a/internal/modules/inventory/transfers/validations/transfer.validation.go b/internal/modules/inventory/transfers/validations/transfer.validation.go index c64077ff..785295e2 100644 --- a/internal/modules/inventory/transfers/validations/transfer.validation.go +++ b/internal/modules/inventory/transfers/validations/transfer.validation.go @@ -23,7 +23,7 @@ type TransferDeliveryProduct struct { type TransferDelivery struct { DeliveryCost float64 `json:"delivery_cost" validate:"required"` DeliveryCostPerItem float64 `json:"delivery_cost_per_item" validate:"required"` - DocumentIndex int `json:"document_index" validate:"min=0"` + DocumentIndex int `json:"document_index" validate:"omitempty,min=-1" default:"-1"` DriverName string `json:"driver_name" validate:"required"` VehiclePlate string `json:"vehicle_plate" validate:"required"` SupplierID uint `json:"supplier_id" validate:"required"` diff --git a/internal/modules/master/areas/services/area.service.go b/internal/modules/master/areas/services/area.service.go index 0a976567..e6f9205c 100644 --- a/internal/modules/master/areas/services/area.service.go +++ b/internal/modules/master/areas/services/area.service.go @@ -52,7 +52,7 @@ func (s areaService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Ar areas, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.Search != "" { - return db.Where("name LIKE ?", "%"+params.Search+"%") + return db.Where("name ILIKE ?", "%"+params.Search+"%") } return db.Order("created_at DESC").Order("updated_at DESC") }) diff --git a/internal/modules/master/banks/services/bank.service.go b/internal/modules/master/banks/services/bank.service.go index 83d3029d..bc4abb72 100644 --- a/internal/modules/master/banks/services/bank.service.go +++ b/internal/modules/master/banks/services/bank.service.go @@ -51,7 +51,7 @@ func (s bankService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Ba banks, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.Search != "" { - return db.Where("name LIKE ?", "%"+params.Search+"%") + return db.Where("name ILIKE ?", "%"+params.Search+"%") } return db.Order("created_at DESC").Order("updated_at DESC") }) diff --git a/internal/modules/master/config-checklists/controllers/config-checklist.controller.go b/internal/modules/master/config-checklists/controllers/config-checklist.controller.go new file mode 100644 index 00000000..362f1aaa --- /dev/null +++ b/internal/modules/master/config-checklists/controllers/config-checklist.controller.go @@ -0,0 +1,144 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/master/config-checklists/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/master/config-checklists/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/config-checklists/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type ConfigChecklistController struct { + ConfigChecklistService service.ConfigChecklistService +} + +func NewConfigChecklistController(configChecklistService service.ConfigChecklistService) *ConfigChecklistController { + return &ConfigChecklistController{ + ConfigChecklistService: configChecklistService, + } +} + +func (u *ConfigChecklistController) 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.ConfigChecklistService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.ConfigChecklistListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all configChecklists successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToConfigChecklistListDTOs(result), + }) +} + +func (u *ConfigChecklistController) 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.ConfigChecklistService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get configChecklist successfully", + Data: dto.ToConfigChecklistListDTO(*result), + }) +} + +func (u *ConfigChecklistController) 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.ConfigChecklistService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create configChecklist successfully", + Data: dto.ToConfigChecklistListDTO(*result), + }) +} + +func (u *ConfigChecklistController) 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.ConfigChecklistService.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 configChecklist successfully", + Data: dto.ToConfigChecklistListDTO(*result), + }) +} + +func (u *ConfigChecklistController) 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.ConfigChecklistService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete configChecklist successfully", + }) +} diff --git a/internal/modules/master/config-checklists/dto/config-checklist.dto.go b/internal/modules/master/config-checklists/dto/config-checklist.dto.go new file mode 100644 index 00000000..d6af71aa --- /dev/null +++ b/internal/modules/master/config-checklists/dto/config-checklist.dto.go @@ -0,0 +1,61 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" +) + +// === DTO Structs === + +type ConfigChecklistRelationDTO struct { + Id uint `json:"id"` + Date time.Time `json:"date"` +} + +type ConfigChecklistListDTO struct { + Id uint `json:"id"` + Date time.Time `json:"date"` + PercentageThresholdBad int `json:"percentage_threshold_bad"` + PercentageThresholdEnough int `json:"percentage_threshold_enough"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ConfigChecklistDetailDTO struct { + ConfigChecklistListDTO +} + +// === Mapper Functions === + +func ToConfigChecklistRelationDTO(e entity.ConfigChecklist) ConfigChecklistRelationDTO { + return ConfigChecklistRelationDTO{ + Id: e.Id, + Date: e.Date, + } +} + +func ToConfigChecklistListDTO(e entity.ConfigChecklist) ConfigChecklistListDTO { + return ConfigChecklistListDTO{ + Id: e.Id, + Date: e.Date, + PercentageThresholdBad: e.PercentageThresholdBad, + PercentageThresholdEnough: e.PercentageThresholdEnough, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + } +} + +func ToConfigChecklistListDTOs(e []entity.ConfigChecklist) []ConfigChecklistListDTO { + result := make([]ConfigChecklistListDTO, len(e)) + for i, r := range e { + result[i] = ToConfigChecklistListDTO(r) + } + return result +} + +func ToConfigChecklistDetailDTO(e entity.ConfigChecklist) ConfigChecklistDetailDTO { + return ConfigChecklistDetailDTO{ + ConfigChecklistListDTO: ToConfigChecklistListDTO(e), + } +} diff --git a/internal/modules/master/config-checklists/module.go b/internal/modules/master/config-checklists/module.go new file mode 100644 index 00000000..711a91f3 --- /dev/null +++ b/internal/modules/master/config-checklists/module.go @@ -0,0 +1,25 @@ +package configChecklists + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + rConfigChecklist "gitlab.com/mbugroup/lti-api.git/internal/modules/master/config-checklists/repositories" + sConfigChecklist "gitlab.com/mbugroup/lti-api.git/internal/modules/master/config-checklists/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type ConfigChecklistModule struct{} + +func (ConfigChecklistModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + configChecklistRepo := rConfigChecklist.NewConfigChecklistRepository(db) + userRepo := rUser.NewUserRepository(db) + + configChecklistService := sConfigChecklist.NewConfigChecklistService(configChecklistRepo, validate) + userService := sUser.NewUserService(userRepo, validate) + + ConfigChecklistRoutes(router, userService, configChecklistService) +} diff --git a/internal/modules/master/config-checklists/repositories/config-checklist.repository.go b/internal/modules/master/config-checklists/repositories/config-checklist.repository.go new file mode 100644 index 00000000..5bbf75ca --- /dev/null +++ b/internal/modules/master/config-checklists/repositories/config-checklist.repository.go @@ -0,0 +1,21 @@ +package repository + +import ( + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + "gorm.io/gorm" +) + +type ConfigChecklistRepository interface { + repository.BaseRepository[entity.ConfigChecklist] +} + +type ConfigChecklistRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.ConfigChecklist] +} + +func NewConfigChecklistRepository(db *gorm.DB) ConfigChecklistRepository { + return &ConfigChecklistRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.ConfigChecklist](db), + } +} diff --git a/internal/modules/master/config-checklists/route.go b/internal/modules/master/config-checklists/route.go new file mode 100644 index 00000000..1b590067 --- /dev/null +++ b/internal/modules/master/config-checklists/route.go @@ -0,0 +1,23 @@ +package configChecklists + +import ( + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/config-checklists/controllers" + configChecklist "gitlab.com/mbugroup/lti-api.git/internal/modules/master/config-checklists/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func ConfigChecklistRoutes(v1 fiber.Router, u user.UserService, s configChecklist.ConfigChecklistService) { + ctrl := controller.NewConfigChecklistController(s) + + route := v1.Group("/config-checklists") + route.Use(m.Auth(u)) + + route.Get("/", ctrl.GetAll) + route.Post("/", ctrl.CreateOne) + route.Get("/:id", ctrl.GetOne) + route.Patch("/:id", ctrl.UpdateOne) + route.Delete("/:id", ctrl.DeleteOne) +} diff --git a/internal/modules/master/config-checklists/services/config-checklist.service.go b/internal/modules/master/config-checklists/services/config-checklist.service.go new file mode 100644 index 00000000..0c96e3d5 --- /dev/null +++ b/internal/modules/master/config-checklists/services/config-checklist.service.go @@ -0,0 +1,146 @@ +package service + +import ( + "errors" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/config-checklists/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/config-checklists/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 ConfigChecklistService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ConfigChecklist, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.ConfigChecklist, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.ConfigChecklist, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.ConfigChecklist, error) + DeleteOne(ctx *fiber.Ctx, id uint) error +} + +type configChecklistService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.ConfigChecklistRepository +} + +func NewConfigChecklistService(repo repository.ConfigChecklistRepository, validate *validator.Validate) ConfigChecklistService { + return &configChecklistService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + } +} + +func (s configChecklistService) withRelations(db *gorm.DB) *gorm.DB { + return db +} + +func (s configChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ConfigChecklist, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + configChecklists, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + return db.Order("date DESC").Order("created_at DESC") + }) + + if err != nil { + s.Log.Errorf("Failed to get configChecklists: %+v", err) + return nil, 0, err + } + return configChecklists, total, nil +} + +func (s configChecklistService) GetOne(c *fiber.Ctx, id uint) (*entity.ConfigChecklist, error) { + configChecklist, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "ConfigChecklist not found") + } + if err != nil { + s.Log.Errorf("Failed get configChecklist by id: %+v", err) + return nil, err + } + return configChecklist, nil +} + +func (s *configChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.ConfigChecklist, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + date, err := time.Parse("2006-01-02", req.Date) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "invalid date format, use YYYY-MM-DD") + } + + createBody := &entity.ConfigChecklist{ + Date: date, + PercentageThresholdBad: req.PercentageThresholdBad, + PercentageThresholdEnough: req.PercentageThresholdEnough, + } + + if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { + s.Log.Errorf("Failed to create configChecklist: %+v", err) + return nil, err + } + + return s.GetOne(c, createBody.Id) +} + +func (s configChecklistService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.ConfigChecklist, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + updateBody := make(map[string]any) + + if req.Date != nil { + date, err := time.Parse("2006-01-02", *req.Date) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "invalid date format, use YYYY-MM-DD") + } + updateBody["date"] = date + } + + if req.PercentageThresholdBad != nil { + updateBody["percentage_threshold_bad"] = *req.PercentageThresholdBad + } + + if req.PercentageThresholdEnough != nil { + updateBody["percentage_threshold_enough"] = *req.PercentageThresholdEnough + } + + 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, "ConfigChecklist not found") + } + s.Log.Errorf("Failed to update configChecklist: %+v", err) + return nil, err + } + + return s.GetOne(c, id) +} + +func (s configChecklistService) DeleteOne(c *fiber.Ctx, id uint) error { + if err := s.Repository.DeleteOne(c.Context(), id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "ConfigChecklist not found") + } + s.Log.Errorf("Failed to delete configChecklist: %+v", err) + return err + } + return nil +} diff --git a/internal/modules/master/config-checklists/validations/config-checklist.validation.go b/internal/modules/master/config-checklists/validations/config-checklist.validation.go new file mode 100644 index 00000000..10f477b7 --- /dev/null +++ b/internal/modules/master/config-checklists/validations/config-checklist.validation.go @@ -0,0 +1,19 @@ +package validation + +type Create struct { + Date string `json:"date" validate:"required"` + PercentageThresholdBad int `json:"percentage_threshold_bad" validate:"required"` + PercentageThresholdEnough int `json:"percentage_threshold_enough" validate:"required"` +} + +type Update struct { + Date *string `json:"date,omitempty" validate:"omitempty"` + PercentageThresholdBad *int `json:"percentage_threshold_bad,omitempty" validate:"omitempty"` + PercentageThresholdEnough *int `json:"percentage_threshold_enough,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/master/customers/services/customer.service.go b/internal/modules/master/customers/services/customer.service.go index 12a31441..fe4cb41e 100644 --- a/internal/modules/master/customers/services/customer.service.go +++ b/internal/modules/master/customers/services/customer.service.go @@ -53,7 +53,7 @@ func (s customerService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit customers, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.Search != "" { - return db.Where("name LIKE ?", "%"+params.Search+"%") + return db.Where("name ILIKE ?", "%"+params.Search+"%") } return db.Order("created_at DESC").Order("updated_at DESC") }) diff --git a/internal/modules/master/employees/services/employees.service.go b/internal/modules/master/employees/services/employees.service.go index 4998eaec..b3673eaf 100644 --- a/internal/modules/master/employees/services/employees.service.go +++ b/internal/modules/master/employees/services/employees.service.go @@ -53,7 +53,7 @@ func (s employeesService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti employeess, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.Search != "" { - db = db.Where("employees.name LIKE ?", "%"+params.Search+"%") + db = db.Where("employees.name ILIKE ?", "%"+params.Search+"%") } if params.KandangId != nil { db = db.Joins("JOIN employee_kandangs ek ON ek.employee_id = employees.id"). diff --git a/internal/modules/master/employees/validations/employees.validation.go b/internal/modules/master/employees/validations/employees.validation.go index 2e2cc879..83608071 100644 --- a/internal/modules/master/employees/validations/employees.validation.go +++ b/internal/modules/master/employees/validations/employees.validation.go @@ -14,7 +14,7 @@ type Update struct { type Query struct { Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=500,gt=0"` Search string `query:"search" validate:"omitempty,max=50"` KandangId *uint `query:"kandang_id" validate:"omitempty"` IsActive *bool `query:"is_active" validate:"omitempty"` diff --git a/internal/modules/master/fcrs/services/fcr.service.go b/internal/modules/master/fcrs/services/fcr.service.go index f4125374..a9414e05 100644 --- a/internal/modules/master/fcrs/services/fcr.service.go +++ b/internal/modules/master/fcrs/services/fcr.service.go @@ -55,7 +55,7 @@ func (s fcrService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Fcr fcrs, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.Search != "" { - return db.Where("name LIKE ?", "%"+params.Search+"%") + return db.Where("name ILIKE ?", "%"+params.Search+"%") } return db.Order("created_at DESC").Order("updated_at DESC") }) diff --git a/internal/modules/master/flocks/services/flock.service.go b/internal/modules/master/flocks/services/flock.service.go index ad086920..2eaaa85d 100644 --- a/internal/modules/master/flocks/services/flock.service.go +++ b/internal/modules/master/flocks/services/flock.service.go @@ -52,7 +52,7 @@ func (s flockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.F flocks, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.Search != "" { - return db.Where("name LIKE ?", "%"+params.Search+"%") + return db.Where("name ILIKE ?", "%"+params.Search+"%") } return db.Order("created_at DESC").Order("updated_at DESC") }) diff --git a/internal/modules/master/kandangs/services/kandang.service.go b/internal/modules/master/kandangs/services/kandang.service.go index 35fe2c30..9f83f0ce 100644 --- a/internal/modules/master/kandangs/services/kandang.service.go +++ b/internal/modules/master/kandangs/services/kandang.service.go @@ -54,7 +54,7 @@ func (s kandangService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity kandangs, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.Search != "" { - return db.Where("name LIKE ?", "%"+params.Search+"%") + return db.Where("name ILIKE ?", "%"+params.Search+"%") } if params.LocationId != 0 { db = db.Where("location_id = ?", params.LocationId) diff --git a/internal/modules/master/locations/services/location.service.go b/internal/modules/master/locations/services/location.service.go index 19894d10..3a1d1e23 100644 --- a/internal/modules/master/locations/services/location.service.go +++ b/internal/modules/master/locations/services/location.service.go @@ -52,7 +52,7 @@ func (s locationService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit locations, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.Search != "" { - db = db.Where("name LIKE ?", "%"+params.Search+"%") + db = db.Where("name ILIKE ?", "%"+params.Search+"%") } if params.AreaId != 0 { db = db.Where("area_id = ?", params.AreaId) diff --git a/internal/modules/master/nonstocks/services/nonstock.service.go b/internal/modules/master/nonstocks/services/nonstock.service.go index 876d4c1e..ad044b08 100644 --- a/internal/modules/master/nonstocks/services/nonstock.service.go +++ b/internal/modules/master/nonstocks/services/nonstock.service.go @@ -68,7 +68,7 @@ func (s nonstockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit db = s.withRelations(db) if params.Search != "" { - return db.Where("name LIKE ?", "%"+params.Search+"%") + return db.Where("name ILIKE ?", "%"+params.Search+"%") } return db.Order("created_at DESC").Order("updated_at DESC") }) diff --git a/internal/modules/master/phase-activities/controllers/phase-activity.controller.go b/internal/modules/master/phase-activities/controllers/phase-activity.controller.go index 455ff1e4..3cbc68f2 100644 --- a/internal/modules/master/phase-activities/controllers/phase-activity.controller.go +++ b/internal/modules/master/phase-activities/controllers/phase-activity.controller.go @@ -28,20 +28,12 @@ func (u *PhaseActivityController) GetAll(c *fiber.Ctx) error { Limit: c.QueryInt("limit", 10), Search: c.Query("search", ""), } + query.PhaseIDs = c.Query("phase_ids", "") if query.Page < 1 || query.Limit < 1 { return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") } - if phaseParam := c.Query("phase_id", ""); phaseParam != "" { - id, err := strconv.Atoi(phaseParam) - if err != nil || id <= 0 { - return fiber.NewError(fiber.StatusBadRequest, "invalid phase_id") - } - temp := uint(id) - query.PhaseId = &temp - } - result, totalResults, err := u.PhaseActivityService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/phase-activities/services/phase-activity.service.go b/internal/modules/master/phase-activities/services/phase-activity.service.go index 3426eab4..1c6b15ce 100644 --- a/internal/modules/master/phase-activities/services/phase-activity.service.go +++ b/internal/modules/master/phase-activities/services/phase-activity.service.go @@ -2,6 +2,7 @@ package service import ( "errors" + "strconv" "strings" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -41,7 +42,8 @@ func NewPhaseActivityService(repo repository.PhaseActivityRepository, phaseRepo } func (s phaseActivityService) withRelations(db *gorm.DB) *gorm.DB { - return db + return db.Joins("JOIN phases ON phases.id = phase_activities.phase_id"). + Where("phases.deleted_at IS NULL") } func (s phaseActivityService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.PhaseActivity, int64, error) { @@ -54,10 +56,13 @@ func (s phaseActivityService) GetAll(c *fiber.Ctx, params *validation.Query) ([] phaseActivitys, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.Search != "" { - db = db.Where("name LIKE ?", "%"+params.Search+"%") + db = db.Where("name ILIKE ?", "%"+params.Search+"%") } - if params.PhaseId != nil { - db = db.Where("phase_id = ?", *params.PhaseId) + if params.PhaseIDs != "" { + ids := parseIDs(params.PhaseIDs) + if len(ids) > 0 { + db = db.Where("phase_id IN ?", ids) + } } return db.Order("created_at DESC").Order("updated_at DESC") }) @@ -165,3 +170,18 @@ func (s phaseActivityService) DeleteOne(c *fiber.Ctx, id uint) error { } return nil } + +func parseIDs(raw string) []uint { + parts := strings.Split(raw, ",") + results := make([]uint, 0, len(parts)) + for _, part := range parts { + value := strings.TrimSpace(part) + if value == "" { + continue + } + if n, err := strconv.ParseUint(value, 10, 64); err == nil { + results = append(results, uint(n)) + } + } + return results +} diff --git a/internal/modules/master/phase-activities/validations/phase-activity.validation.go b/internal/modules/master/phase-activities/validations/phase-activity.validation.go index a2ab8e1b..54186315 100644 --- a/internal/modules/master/phase-activities/validations/phase-activity.validation.go +++ b/internal/modules/master/phase-activities/validations/phase-activity.validation.go @@ -14,8 +14,8 @@ type Update struct { } type Query struct { - Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` - Search string `query:"search" validate:"omitempty,max=50"` - PhaseId *uint `query:"phase_id" validate:"omitempty"` + 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"` + PhaseIDs string `query:"phase_ids" validate:"omitempty"` } diff --git a/internal/modules/master/phasess/dto/phases.dto.go b/internal/modules/master/phasess/dto/phases.dto.go index 51724556..79a2db72 100644 --- a/internal/modules/master/phasess/dto/phases.dto.go +++ b/internal/modules/master/phasess/dto/phases.dto.go @@ -15,12 +15,13 @@ type PhasesRelationDTO struct { } type PhasesListDTO struct { - Id uint `json:"id"` - Name string `json:"name"` - Category string `json:"category"` - IsActive bool `json:"is_active"` - CreatedUser *userDTO.UserRelationDTO `json:"created_user"` - CreatedAt time.Time `json:"created_at"` + Id uint `json:"id"` + Name string `json:"name"` + Category string `json:"category"` + IsActive bool `json:"is_active"` + ActivityCount int `json:"activity_count"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` } type PhasesDetailDTO struct { @@ -44,12 +45,13 @@ func ToPhasesListDTO(e entity.Phases) PhasesListDTO { // } return PhasesListDTO{ - Id: e.Id, - Name: e.Name, - Category: e.Category, - IsActive: e.IsActive, - CreatedAt: e.CreatedAt, - CreatedUser: createdUser, + Id: e.Id, + Name: e.Name, + Category: e.Category, + IsActive: e.IsActive, + ActivityCount: e.ActivityCount, + CreatedAt: e.CreatedAt, + CreatedUser: createdUser, } } diff --git a/internal/modules/master/phasess/services/phases.service.go b/internal/modules/master/phasess/services/phases.service.go index 98e73bef..bd5cf08f 100644 --- a/internal/modules/master/phasess/services/phases.service.go +++ b/internal/modules/master/phasess/services/phases.service.go @@ -51,7 +51,7 @@ func (s phasesService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity. phasess, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.Search != "" { - return db.Where("name LIKE ?", "%"+params.Search+"%") + return db.Where("name ILIKE ?", "%"+params.Search+"%") } if params.Category != nil { db = db.Where("category = ?", *params.Category) @@ -63,6 +63,40 @@ func (s phasesService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity. s.Log.Errorf("Failed to get phasess: %+v", err) return nil, 0, err } + + if len(phasess) > 0 { + ids := make([]uint, 0, len(phasess)) + for _, phase := range phasess { + ids = append(ids, phase.Id) + } + + type activityCountRow struct { + PhaseID uint + Count int64 + } + + var rows []activityCountRow + if err := s.Repository.DB().WithContext(c.Context()). + Table("phase_activities"). + Select("phase_id, COUNT(*) AS count"). + Where("phase_id IN ? AND deleted_at IS NULL", ids). + Group("phase_id"). + Scan(&rows).Error; err != nil { + s.Log.Errorf("Failed to count phase activities: %+v", err) + return nil, 0, err + } + + countMap := make(map[uint]int64, len(rows)) + for _, row := range rows { + countMap[row.PhaseID] = row.Count + } + + for i := range phasess { + if count, ok := countMap[phasess[i].Id]; ok { + phasess[i].ActivityCount = int(count) + } + } + } return phasess, total, nil } diff --git a/internal/modules/master/product-categories/services/product-category.service.go b/internal/modules/master/product-categories/services/product-category.service.go index 90936d7b..ae1577f1 100644 --- a/internal/modules/master/product-categories/services/product-category.service.go +++ b/internal/modules/master/product-categories/services/product-category.service.go @@ -52,7 +52,7 @@ func (s productCategoryService) GetAll(c *fiber.Ctx, params *validation.Query) ( productCategories, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.Search != "" { - return db.Where("name LIKE ?", "%"+params.Search+"%") + return db.Where("name ILIKE ?", "%"+params.Search+"%") } return db.Order("created_at DESC").Order("updated_at DESC") }) diff --git a/internal/modules/master/production-standards/services/production-standard.service.go b/internal/modules/master/production-standards/services/production-standard.service.go index 4005b014..e1470170 100644 --- a/internal/modules/master/production-standards/services/production-standard.service.go +++ b/internal/modules/master/production-standards/services/production-standard.service.go @@ -63,7 +63,7 @@ func (s productionStandardService) GetAll(c *fiber.Ctx, params *validation.Query productionStandards, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { if params.Search != "" { - return db.Where("name LIKE ?", "%"+params.Search+"%") + return db.Where("name ILIKE ?", "%"+params.Search+"%") } if params.ProjectCategory != "" { return db.Where("project_category = ?", params.ProjectCategory) diff --git a/internal/modules/master/products/dto/product.dto.go b/internal/modules/master/products/dto/product.dto.go index dfd4c86f..59f57034 100644 --- a/internal/modules/master/products/dto/product.dto.go +++ b/internal/modules/master/products/dto/product.dto.go @@ -5,6 +5,7 @@ import ( entity "gitlab.com/mbugroup/lti-api.git/internal/entities" productCategoryDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/product-categories/dto" + supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto" uomDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) @@ -19,6 +20,7 @@ type ProductRelationDTO struct { Uom *uomDTO.UomRelationDTO `json:"uom,omitempty"` Flags *[]string `json:"flags,omitempty"` ProductCategory *productCategoryDTO.ProductCategoryRelationDTO `json:"product_category,omitempty"` + Suppliers []supplierDTO.SupplierRelationDTO `json:"suppliers"` } type ProductListDTO struct { @@ -33,6 +35,7 @@ type ProductListDTO struct { Flags []string `json:"flags"` Uom *uomDTO.UomRelationDTO `json:"uom,omitempty"` ProductCategory *productCategoryDTO.ProductCategoryRelationDTO `json:"product_category,omitempty"` + Suppliers []supplierDTO.SupplierRelationDTO `json:"suppliers"` CreatedUser *userDTO.UserRelationDTO `json:"created_user"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -70,6 +73,7 @@ func ToProductRelationDTO(e entity.Product) ProductRelationDTO { Flags: &flags, Uom: uomRef, ProductCategory: categoryRef, + Suppliers: toProductSupplierDTOs(e.ProductSuppliers), } } @@ -112,6 +116,7 @@ func ToProductListDTO(e entity.Product) ProductListDTO { UpdatedAt: e.UpdatedAt, CreatedUser: createdUser, ProductCategory: categoryRef, + Suppliers: toProductSupplierDTOs(e.ProductSuppliers), } } @@ -128,3 +133,23 @@ func ToProductDetailDTO(e entity.Product) ProductDetailDTO { ProductListDTO: ToProductListDTO(e), } } + +func toProductSupplierDTOs(relations []entity.ProductSupplier) []supplierDTO.SupplierRelationDTO { + if len(relations) == 0 { + return make([]supplierDTO.SupplierRelationDTO, 0) + } + + result := make([]supplierDTO.SupplierRelationDTO, 0, len(relations)) + for _, relation := range relations { + if relation.Supplier.Id == 0 { + continue + } + result = append(result, supplierDTO.ToSupplierRelationDTO(relation.Supplier)) + } + + if len(result) == 0 { + return make([]supplierDTO.SupplierRelationDTO, 0) + } + + return result +} diff --git a/internal/modules/master/products/services/product.service.go b/internal/modules/master/products/services/product.service.go index f40d92be..e63b462b 100644 --- a/internal/modules/master/products/services/product.service.go +++ b/internal/modules/master/products/services/product.service.go @@ -72,7 +72,7 @@ func (s productService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity db = s.withRelations(db) db = db.Where("is_visible = ?", true) if params.Search != "" { - return db.Where("name LIKE ?", "%"+params.Search+"%") + return db.Where("name ILIKE ?", "%"+params.Search+"%") } if params.ProductCategoryID != 0 { return db.Where("product_category_id = ?", params.ProductCategoryID) @@ -176,6 +176,7 @@ func (s *productService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit SellingPrice: req.SellingPrice, Tax: req.Tax, ExpiryPeriod: req.ExpiryPeriod, + IsVisible: true, CreatedBy: 1, } diff --git a/internal/modules/master/route.go b/internal/modules/master/route.go index f9bc7b13..06ba1ae3 100644 --- a/internal/modules/master/route.go +++ b/internal/modules/master/route.go @@ -24,6 +24,7 @@ import ( suppliers "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers" uoms "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms" warehouses "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses" + configChecklists "gitlab.com/mbugroup/lti-api.git/internal/modules/master/config-checklists" // MODULE IMPORTS ) @@ -48,6 +49,7 @@ func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Valida employeess.EmployeesModule{}, phasess.PhasesModule{}, phaseActivitys.PhaseActivityModule{}, + configChecklists.ConfigChecklistModule{}, // MODULE REGISTRY } diff --git a/internal/modules/master/suppliers/services/supplier.service.go b/internal/modules/master/suppliers/services/supplier.service.go index 75d8fa04..c331647d 100644 --- a/internal/modules/master/suppliers/services/supplier.service.go +++ b/internal/modules/master/suppliers/services/supplier.service.go @@ -65,11 +65,11 @@ func (s supplierService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit suppliers, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.Search != "" { - return db.Where("name LIKE ?", "%"+params.Search+"%") + return db.Where("name ILIKE ?", "%"+params.Search+"%") } if params.Category != "" { - db = db.Where("category LIKE ?", "%"+params.Category+"%") + db = db.Where("category ILIKE ?", "%"+params.Category+"%") } return db.Order("created_at DESC").Order("updated_at DESC") diff --git a/internal/modules/master/uoms/route.go b/internal/modules/master/uoms/route.go index 8ffbcb62..ff5e2bd5 100644 --- a/internal/modules/master/uoms/route.go +++ b/internal/modules/master/uoms/route.go @@ -15,15 +15,9 @@ func UomRoutes(v1 fiber.Router, u user.UserService, s uom.UomService) { route := v1.Group("/uoms") route.Use(m.Auth(u)) - route.Get("/", ctrl.GetAll) - route.Post("/", ctrl.CreateOne) - route.Get("/:id", ctrl.GetOne) - route.Patch("/:id", ctrl.UpdateOne) - route.Delete("/:id", ctrl.DeleteOne) - - route.Get("/",m.RequirePermissions(m.P_AreaGetAll), ctrl.GetAll) - route.Post("/",m.RequirePermissions(m.P_AreaCreateOne), ctrl.CreateOne) - route.Get("/:id",m.RequirePermissions(m.P_AreaGetOne), ctrl.GetOne) - route.Patch("/:id",m.RequirePermissions(m.P_AreaUpdateOne), ctrl.UpdateOne) - route.Delete("/:id",m.RequirePermissions(m.P_AreaDeleteOne), ctrl.DeleteOne) + route.Get("/", m.RequirePermissions(m.P_UomsGetAll), ctrl.GetAll) + route.Post("/", m.RequirePermissions(m.P_UomsCreateOne), ctrl.CreateOne) + route.Get("/:id", m.RequirePermissions(m.P_UomsGetOne), ctrl.GetOne) + route.Patch("/:id", m.RequirePermissions(m.P_UomsUpdateOne), ctrl.UpdateOne) + route.Delete("/:id", m.RequirePermissions(m.P_UomsDeleteOne), ctrl.DeleteOne) } diff --git a/internal/modules/master/uoms/services/uom.service.go b/internal/modules/master/uoms/services/uom.service.go index 5396849b..8ec0742f 100644 --- a/internal/modules/master/uoms/services/uom.service.go +++ b/internal/modules/master/uoms/services/uom.service.go @@ -51,7 +51,7 @@ func (s uomService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Uom uoms, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.Search != "" { - return db.Where("name LIKE ?", "%"+params.Search+"%") + return db.Where("name ILIKE ?", "%"+params.Search+"%") } return db.Order("created_at DESC").Order("updated_at DESC") }) diff --git a/internal/modules/master/warehouses/services/warehouse.service.go b/internal/modules/master/warehouses/services/warehouse.service.go index 79c41284..7eeaad3d 100644 --- a/internal/modules/master/warehouses/services/warehouse.service.go +++ b/internal/modules/master/warehouses/services/warehouse.service.go @@ -53,7 +53,7 @@ func (s warehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti warehouses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.Search != "" { - db = db.Where("warehouses.name LIKE ?", "%"+params.Search+"%") + db = db.Where("warehouses.name ILIKE ?", "%"+params.Search+"%") } if params.AreaId != 0 { db = db.Where("area_id = ?", params.AreaId) diff --git a/internal/modules/production/chickins/module.go b/internal/modules/production/chickins/module.go index 6c9b8984..143ebad2 100644 --- a/internal/modules/production/chickins/module.go +++ b/internal/modules/production/chickins/module.go @@ -52,12 +52,31 @@ func (ChickinModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * PendingQuantity: "pending_usage_qty", CreatedAt: "created_at", }, + + ExcludedStockables: []fifo.StockableKey{fifo.StockableKeyProjectFlockPopulation}, }); err != nil { if !strings.Contains(strings.ToLower(err.Error()), "already registered") { panic(fmt.Sprintf("failed to register chickin usable workflow: %v", err)) } } + if err := fifoService.RegisterStockable(fifo.StockableConfig{ + Key: fifo.StockableKeyProjectFlockPopulation, + Table: "project_flock_populations", + Columns: fifo.StockableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + TotalQuantity: "total_qty", + TotalUsedQuantity: "total_used_qty", + CreatedAt: "created_at", + }, + OrderBy: []string{"created_at ASC", "id ASC"}, + }); err != nil { + if !strings.Contains(strings.ToLower(err.Error()), "already registered") { + panic(fmt.Sprintf("failed to register project flock population stockable workflow: %v", err)) + } + } + approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowChickin, utils.ChickinApprovalSteps); err != nil { diff --git a/internal/modules/production/chickins/repositories/project_chickin.repository.go b/internal/modules/production/chickins/repositories/project_chickin.repository.go index 43cafaac..7f56a261 100644 --- a/internal/modules/production/chickins/repositories/project_chickin.repository.go +++ b/internal/modules/production/chickins/repositories/project_chickin.repository.go @@ -16,6 +16,8 @@ type ProjectChickinRepository interface { GetPendingByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) ([]entity.ProjectChickin, error) GetTotalPendingUsageQtyByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) GetTotalChickinQtyByProjectFlockID(ctx context.Context, projectFlockID uint) (float64, error) + GetByProjectFlockKandangIDForUpdate(ctx context.Context, projectFlockKandangID uint) ([]entity.ProjectChickin, error) + UpdateUsageFields(ctx context.Context, tx *gorm.DB, chickinID uint, usageQty, pendingUsageQty float64) error } type ChickinRepositoryImpl struct { @@ -64,6 +66,26 @@ func (r *ChickinRepositoryImpl) GetByProjectFlockKandangID(ctx context.Context, return chickins, nil } +// GetByProjectFlockKandangIDForUpdate locks chickin rows to prevent race condition +func (r *ChickinRepositoryImpl) GetByProjectFlockKandangIDForUpdate(ctx context.Context, projectFlockKandangID uint) ([]entity.ProjectChickin, error) { + var chickins []entity.ProjectChickin + // CRITICAL: Use FOR UPDATE to lock rows and prevent concurrent chickin requests + // This ensures that simultaneous requests wait for each other and read consistent pending_qty + err := r.db.WithContext(ctx). + Raw(` + SELECT * FROM project_chickins + WHERE project_flock_kandang_id = ? + AND deleted_at IS NULL + ORDER BY created_at DESC + FOR UPDATE + `, projectFlockKandangID). + Scan(&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). @@ -102,3 +124,13 @@ func (r *ChickinRepositoryImpl) GetTotalChickinQtyByProjectFlockID(ctx context.C Scan(&result).Error return result, err } + +func (r *ChickinRepositoryImpl) UpdateUsageFields(ctx context.Context, tx *gorm.DB, chickinID uint, usageQty, pendingUsageQty float64) error { + return tx.WithContext(ctx). + Model(&entity.ProjectChickin{}). + Where("id = ?", chickinID). + Updates(map[string]interface{}{ + "usage_qty": usageQty, + "pending_usage_qty": pendingUsageQty, + }).Error +} diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 871c8fce..de49bb1e 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -112,7 +112,6 @@ func (s chickinService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectChickin, e return nil, fiber.NewError(fiber.StatusNotFound, "Chickin not found") } if err != nil { - s.Log.Errorf("Failed get chickin by id: %+v", err) return nil, err } return chickin, nil @@ -137,12 +136,15 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti if err != nil { return nil, err } + newChikins := make([]*entity.ProjectChickin, 0) chickinQtyMap := make(map[uint]float64) for idx, chickinReq := range req.ChickinRequests { - productWarehouse, err := s.ProductWarehouseRepo.GetByID(c.Context(), chickinReq.ProductWarehouseId, nil) + productWarehouse, err := s.ProductWarehouseRepo.GetByID(c.Context(), chickinReq.ProductWarehouseId, func(db *gorm.DB) *gorm.DB { + return db.Preload("Product.Flags") + }) if err != nil { return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Product warehouse %d not found", chickinReq.ProductWarehouseId)) } @@ -151,8 +153,32 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Product warehouse %d is not bound to kandang's warehouse", chickinReq.ProductWarehouseId)) } - if productWarehouse.ProjectFlockKandangId == nil || *productWarehouse.ProjectFlockKandangId != req.ProjectFlockKandangId { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Product warehouse %d is not attached to project_flock_kandang %d. Only product warehouses with matching project_flock_kandang_id can be chickin-ed", chickinReq.ProductWarehouseId, req.ProjectFlockKandangId)) + if productWarehouse.ProjectFlockKandangId != nil && *productWarehouse.ProjectFlockKandangId != req.ProjectFlockKandangId { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Product warehouse %d belongs to different flock. Only product warehouses with project_flock_kandang_id = NULL or = %d can be used", chickinReq.ProductWarehouseId, req.ProjectFlockKandangId)) + } + + if productWarehouse.Product.Id != 0 { + + var requiredFlag utils.FlagType + if projectFlockKandang.ProjectFlock.Category == string(utils.ProjectFlockCategoryGrowing) { + requiredFlag = utils.FlagDOC + } else if projectFlockKandang.ProjectFlock.Category == string(utils.ProjectFlockCategoryLaying) { + requiredFlag = utils.FlagPullet + } else { + return nil, fmt.Errorf("invalid flock category for chickin") + } + + hasRequiredFlag := false + for _, flag := range productWarehouse.Product.Flags { + if utils.FlagType(flag.Name) == requiredFlag { + hasRequiredFlag = true + break + } + } + + if !hasRequiredFlag { + return nil, fmt.Errorf("product warehouse %d cannot be used for %s chickin. Product must have %s flag (product ID: %d, warehouse ID: %d)", chickinReq.ProductWarehouseId, projectFlockKandang.ProjectFlock.Category, requiredFlag, productWarehouse.Product.Id, productWarehouse.Id) + } } chickinDate, err := utils.ParseDateString(chickinReq.ChickInDate) @@ -160,11 +186,6 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid ChickInDate format for product warehouse %d", chickinReq.ProductWarehouseId)) } - availableQty := productWarehouse.Quantity - if availableQty <= 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("No available stock in product warehouse %d for chickin", chickinReq.ProductWarehouseId)) - } - newChickin := &entity.ProjectChickin{ ProjectFlockKandangId: req.ProjectFlockKandangId, ChickInDate: chickinDate, @@ -176,6 +197,17 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti } newChikins = append(newChikins, newChickin) + + totalPopulationQty, err := s.ProjectflockPopulationRepo.GetTotalQtyByProjectFlockKandangID(c.Context(), req.ProjectFlockKandangId) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to get total population quantity for project_flock_kandang %d", req.ProjectFlockKandangId)) + } + + availableQty := productWarehouse.Quantity - totalPopulationQty + if availableQty < 0 { + availableQty = 0 + } + chickinQtyMap[uint(idx)] = availableQty } @@ -183,15 +215,40 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti 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 { + repositoryTx := repository.NewChickinRepository(dbTransaction) + existingChikins, err := repositoryTx.GetByProjectFlockKandangIDForUpdate(c.Context(), req.ProjectFlockKandangId) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to check existing chickins") + } + + isFirstTime := len(existingChikins) == 0 + + pendingQtyMap := make(map[uint]float64) + for _, existingChickin := range existingChikins { + if existingChickin.PendingUsageQty > 0 { + pendingQtyMap[existingChickin.ProductWarehouseId] += existingChickin.PendingUsageQty + } + } + for idx, chickin := range newChikins { + pendingQty := pendingQtyMap[chickin.ProductWarehouseId] + desiredQty := chickinQtyMap[uint(idx)] + + availableQty := desiredQty - pendingQty + if availableQty < 0 { + availableQty = 0 + } + + if availableQty <= 0 { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("No available stock in product warehouse %d for chickin. Warehouse: %.0f, Pending: %.0f, Available: %.0f", chickin.ProductWarehouseId, desiredQty, pendingQty, availableQty)) + } + + chickinQtyMap[uint(idx)] = availableQty + + pendingQtyMap[chickin.ProductWarehouseId] += availableQty + } + approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) if err := s.Repository.WithTx(dbTransaction).CreateMany(c.Context(), newChikins, nil); err != nil { @@ -313,12 +370,15 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { } if chickin.UsageQty > 0 { + + currentUsageQty := chickin.UsageQty + if err := s.ReleaseChickinStocks(c.Context(), s.Repository.DB(), chickin, actorID); err != nil { return err } warehouseDeltas := make(map[uint]float64) - warehouseDeltas[chickin.ProductWarehouseId] += chickin.UsageQty + warehouseDeltas[chickin.ProductWarehouseId] += currentUsageQty if err := s.adjustProductWarehouseQuantities(c.Context(), s.Repository.DB(), warehouseDeltas); err != nil { s.Log.Errorf("Failed to adjust product warehouses for deleted chickin %d: %+v", chickin.Id, err) return err @@ -552,6 +612,7 @@ func (s *chickinService) convertChickinsToTarget(ctx *fiber.Ctx, chickins []enti } ProjectFlockPopulationRepotx := s.ProjectflockPopulationRepo.WithTx(dbTransaction) + chickinRepoTx := s.Repository.WithTx(dbTransaction) var totalQuantityAdded float64 @@ -572,7 +633,7 @@ func (s *chickinService) convertChickinsToTarget(ctx *fiber.Ctx, chickins []enti population := &entity.ProjectFlockPopulation{ ProjectChickinId: chickin.Id, ProductWarehouseId: targetPW.Id, - TotalQty: quantityToConvert, + TotalQty: 0, // Will be set by FIFO Replenish TotalUsedQty: 0, Notes: chickin.Notes, CreatedBy: actorID, @@ -581,18 +642,29 @@ func (s *chickinService) convertChickinsToTarget(ctx *fiber.Ctx, chickins []enti return err } + // Reset PendingUsageQty to 0 since population has been created + if err := chickinRepoTx.PatchOne(ctx.Context(), chickin.Id, map[string]any{ + "pending_usage_qty": 0, + }, nil); err != nil { + return fmt.Errorf("failed to reset pending usage qty for chickin %d: %w", chickin.Id, err) + } + + // Replenish stock to target ProductWarehouse based on source flag + // StockableKey is PROJECT_CHICKIN but StockableID refers to Population ID + if err := s.ReplenishChickinStocks(ctx.Context(), dbTransaction, &chickin, targetPW, population, actorID); err != nil { + s.Log.Errorf("Failed to replenish stock for chickin %d: %+v", chickin.Id, err) + return err + } + totalQuantityAdded += quantityToConvert } - if totalQuantityAdded > 0 { - if err := s.ProductWarehouseRepo.AdjustQuantities(ctx.Context(), map[uint]float64{ - targetPW.Id: totalQuantityAdded, - }, func(db *gorm.DB) *gorm.DB { - return dbTransaction - }); err != nil { - return fmt.Errorf("failed to update target product warehouse quantity: %w", err) - } - } + // NOTE: ProductWarehouse target sudah ditambah melalui ReplenishChickinStocks + // yang dipanggil di atas untuk setiap chickin berdasarkan flag source: + // - DOC → replenish ke PULLET + // - PULLET → replenish ke LAYER + // - LAYER → tidak perlu replenish (sudah final) + // - DOC+PULLET+LAYER → replenish ke dirinya sendiri return nil } @@ -621,10 +693,7 @@ func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, s.Log.Infof("ConsumeChickinStocks result: usage_qty=%.3f, pending_qty=%.3f, allocated_allocations=%d", result.UsageQuantity, result.PendingQuantity, len(result.AddedAllocations)) - if err := tx.Model(&entity.ProjectChickin{}).Where("id = ?", chickin.Id).Updates(map[string]interface{}{ - "usage_qty": result.UsageQuantity, - "pending_usage_qty": result.PendingQuantity, - }).Error; err != nil { + if err := s.Repository.UpdateUsageFields(ctx, tx, chickin.Id, result.UsageQuantity, result.PendingQuantity); err != nil { return err } @@ -646,6 +715,101 @@ func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, return nil } +func (s *chickinService) ReplenishChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin, targetPW *entity.ProductWarehouse, population *entity.ProjectFlockPopulation, actorID uint) error { + if chickin == nil || targetPW == nil || population == nil || s.FifoSvc == nil { + return nil + } + + sourcePW, err := s.ProductWarehouseRepo.GetByID(ctx, chickin.ProductWarehouseId, func(db *gorm.DB) *gorm.DB { + return db.Preload("Product.Flags") + }) + if err != nil { + + return err + } + if sourcePW == nil || sourcePW.Product.Id == 0 { + return fmt.Errorf("source product warehouse or product not found for chickin %d", chickin.Id) + } + + sourceFlags := sourcePW.Product.Flags + if len(sourceFlags) == 0 { + s.Log.Warnf("Source product %d has no flags, skipping replenish for chickin %d", sourcePW.Product.Id, chickin.Id) + return nil + } + + hasDoc := false + hasPullet := false + hasLayer := false + for _, flag := range sourceFlags { + flagName := utils.FlagType(flag.Name) + if flagName == utils.FlagDOC { + hasDoc = true + } else if flagName == utils.FlagPullet { + hasPullet = true + } else if flagName == utils.FlagLayer { + hasLayer = true + } + } + + if hasDoc && hasPullet && hasLayer { + s.Log.Infof("Chickin %d has mixed flags (DOC+PULLET+LAYER), replenishing to source PW %d", chickin.Id, sourcePW.Id) + _, err = s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ + StockableKey: fifo.StockableKeyProjectFlockPopulation, + StockableID: population.Id, + ProductWarehouseID: sourcePW.Id, + Quantity: chickin.UsageQty, + Tx: tx, + }) + if err != nil { + s.Log.Errorf("Failed to replenish stock to source PW for chickin %d: %+v", chickin.Id, err) + return err + } + return nil + } + + // LAYER only - no replenish needed + if hasLayer && !hasDoc && !hasPullet { + s.Log.Infof("Chickin %d has LAYER flag only, skipping replenish", chickin.Id) + return nil + } + + if hasDoc && !hasPullet && !hasLayer { + s.Log.Infof("Chickin %d has DOC flag, replenishing to PULLET PW %d", chickin.Id, targetPW.Id) + _, err = s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ + StockableKey: fifo.StockableKeyProjectFlockPopulation, + StockableID: population.Id, + ProductWarehouseID: targetPW.Id, + Quantity: chickin.UsageQty, + Tx: tx, + }) + if err != nil { + s.Log.Errorf("Failed to replenish stock to PULLET PW for chickin %d: %+v", chickin.Id, err) + return err + } + return nil + } + + if hasPullet && !hasDoc && !hasLayer { + s.Log.Infof("Chickin %d has PULLET flag, replenishing to LAYER PW %d", chickin.Id, targetPW.Id) + _, err = s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ + StockableKey: fifo.StockableKeyProjectFlockPopulation, + StockableID: population.Id, + ProductWarehouseID: targetPW.Id, + Quantity: chickin.UsageQty, + Tx: tx, + }) + if err != nil { + s.Log.Errorf("Failed to replenish stock to LAYER PW for chickin %d: %+v", chickin.Id, err) + return err + } + return nil + } + + // Other combinations (e.g., DOC + PULLET without LAYER) - skip for now + s.Log.Warnf("Chickin %d has unsupported flag combination, skipping replenish", chickin.Id) + return nil +} + func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin, actorID uint) error { if chickin == nil || s.FifoSvc == nil { return nil @@ -653,8 +817,7 @@ func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, var currentUsage float64 if err := tx.Model(&entity.ProjectChickin{}).Where("id = ?", chickin.Id).Select("usage_qty").Scan(¤tUsage).Error; err != nil { - s.Log.Warnf("Failed to get current usage for chickin %d: %+v", chickin.Id, err) - currentUsage = 0 + } if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ @@ -666,14 +829,10 @@ func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, return err } - if err := tx.Model(&entity.ProjectChickin{}).Where("id = ?", chickin.Id).Updates(map[string]interface{}{ - "usage_qty": 0, - "pending_usage_qty": 0, - }).Error; err != nil { + if err := s.Repository.UpdateUsageFields(ctx, tx, chickin.Id, 0, 0); err != nil { return err } - // Create stock log for the restoration if currentUsage > 0 { increaseLog := &entity.StockLog{ Increase: currentUsage, @@ -684,8 +843,7 @@ func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, Notes: fmt.Sprintf("Chickin #%d - Stock released", chickin.Id), } if err := s.StockLogRepo.CreateOne(ctx, increaseLog, nil); err != nil { - s.Log.Errorf("Failed to create stock log for chickin %d: %+v", chickin.Id, err) - // Don't return error here, stock already released + s.Log.Errorf("Failed to create stock log for released chickin %d: %+v", chickin.Id, err) } } 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 index a2ba8ad2..452cc7b3 100644 --- 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 @@ -28,14 +28,14 @@ type ProjectFlockKandangRelationDTO struct { type ProjectFlockDTO struct { projectFlockDTO.ProjectFlockRelationDTO - Area *areaDTO.AreaRelationDTO `json:"area,omitempty"` - Category string `json:"category"` - Fcr *fcrDTO.FcrRelationDTO `json:"fcr,omitempty"` + Area *areaDTO.AreaRelationDTO `json:"area,omitempty"` + Category string `json:"category"` + Fcr *fcrDTO.FcrRelationDTO `json:"fcr,omitempty"` ProductionStandard *productionStandardDTO.ProductionStandardRelationDTO `json:"production_standard,omitempty"` - Location *locationDTO.LocationRelationDTO `json:"location,omitempty"` - CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + Location *locationDTO.LocationRelationDTO `json:"location,omitempty"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type ProductWarehouseDTO struct { @@ -51,11 +51,12 @@ type AvailableQtyDTO struct { type ProjectFlockKandangListDTO struct { ProjectFlockKandangRelationDTO - ProjectFlock *ProjectFlockDTO `json:"project_flock,omitempty"` - Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"` - CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` - CreatedAt time.Time `json:"created_at"` - Approval *approvalDTO.ApprovalRelationDTO `json:"approval,omitempty"` + ProjectFlock *ProjectFlockDTO `json:"project_flock,omitempty"` + Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` + CreatedAt time.Time `json:"created_at"` + Approval *approvalDTO.ApprovalRelationDTO `json:"approval,omitempty"` + ChickinApproval *approvalDTO.ApprovalRelationDTO `json:"chickin_approval,omitempty"` } type ProjectFlockKandangDetailDTO struct { @@ -105,7 +106,8 @@ func ToProjectFlockKandangDetailDTOWithAvailableQty(e entity.ProjectFlockKandang Kandang: toKandangRelation(e.Kandang), CreatedAt: e.CreatedAt, CreatedUser: toCreatedUserDTO(e.ProjectFlock), - Approval: toApprovalDTO(e), + Approval: toApprovalDTOSelector(e, func(x entity.ProjectFlockKandang) *entity.Approval { return x.LatestProjectFlockApproval }), + ChickinApproval: toApprovalDTOSelector(e, func(x entity.ProjectFlockKandang) *entity.Approval { return x.LatestChickinApproval }), } return ProjectFlockKandangDetailDTO{ @@ -124,9 +126,11 @@ func toKandangRelation(kandang entity.Kandang) *kandangDTO.KandangRelationDTO { return &mapped } -func toApprovalDTO(e entity.ProjectFlockKandang) *approvalDTO.ApprovalRelationDTO { - if e.LatestApproval != nil { - mapped := approvalDTO.ToApprovalDTO(*e.LatestApproval) +func toApprovalDTOSelector( + e entity.ProjectFlockKandang, selector func(entity.ProjectFlockKandang) *entity.Approval) *approvalDTO.ApprovalRelationDTO { + approval := selector(e) + if approval != nil { + mapped := approvalDTO.ToApprovalDTO(*approval) return &mapped } return nil @@ -145,18 +149,11 @@ func ToProjectFlockKandangListDTO(e entity.ProjectFlockKandang) ProjectFlockKand Kandang: toKandangRelation(e.Kandang), CreatedAt: e.CreatedAt, CreatedUser: toCreatedUserDTO(e.ProjectFlock), - Approval: toApprovalDTO(e), + Approval: toApprovalDTOSelector(e, func(x entity.ProjectFlockKandang) *entity.Approval { return x.LatestProjectFlockApproval }), + ChickinApproval: toApprovalDTOSelector(e, func(x entity.ProjectFlockKandang) *entity.Approval { return x.LatestChickinApproval }), } } -func ToProjectFlockKandangListDTOs(e []entity.ProjectFlockKandang) []ProjectFlockKandangListDTO { - result := make([]ProjectFlockKandangListDTO, len(e)) - for i, r := range e { - result[i] = ToProjectFlockKandangListDTO(r) - } - return result -} - func toCreatedUserDTO(pf entity.ProjectFlock) *userDTO.UserRelationDTO { if pf.CreatedUser.Id != 0 { mapped := userDTO.ToUserRelationDTO(pf.CreatedUser) @@ -187,7 +184,6 @@ func toAvailableQtyDTOsFromMap(chickins []entity.ProjectChickin, availableQtyMap return nil } - // First, build map from chickins pwMap := make(map[uint]*entity.ProductWarehouse) for _, chickin := range chickins { if chickin.ProductWarehouse != nil && chickin.ProductWarehouse.Id != 0 { @@ -195,7 +191,6 @@ func toAvailableQtyDTOsFromMap(chickins []entity.ProjectChickin, availableQtyMap } } - // Then, add productWarehouses that are not in chickins yet for i := range productWarehouses { if _, exists := pwMap[productWarehouses[i].Id]; !exists { pwMap[productWarehouses[i].Id] = &productWarehouses[i] @@ -204,6 +199,11 @@ func toAvailableQtyDTOsFromMap(chickins []entity.ProjectChickin, availableQtyMap result := make([]AvailableQtyDTO, 0, len(availableQtyMap)) for pwId, availableQty := range availableQtyMap { + + if availableQty <= 0 { + continue + } + pw, exists := pwMap[pwId] if !exists || pw == nil { continue diff --git a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go index 66fee8ce..6f019ffa 100644 --- a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go +++ b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go @@ -98,23 +98,8 @@ func (s projectFlockKandangService) GetAll(c *fiber.Ctx, params *validation.Quer } if s.ApprovalSvc != nil { - projectFlockKandangIDs := make([]uint, len(projectFlockKandangs)) - for i, pfk := range projectFlockKandangs { - projectFlockKandangIDs[i] = pfk.Id - } - - approvalMap, err := s.ApprovalSvc.LatestByTargets(c.Context(), utils.ApprovalWorkflowProjectFlockKandang, projectFlockKandangIDs, func(db *gorm.DB) *gorm.DB { - return db.Preload("ActionUser") - }) - if err != nil { - s.Log.Warnf("Failed to fetch approvals for projectFlockKandangs: %+v", err) - } else { - for i := range projectFlockKandangs { - if approval, ok := approvalMap[projectFlockKandangs[i].Id]; ok { - projectFlockKandangs[i].LatestApproval = approval - } - } - } + s.fetchProjectFlockApprovals(c, projectFlockKandangs) + s.fetchChickinApprovals(c, projectFlockKandangs) } return projectFlockKandangs, total, nil @@ -130,14 +115,8 @@ func (s projectFlockKandangService) GetOne(c *fiber.Ctx, id uint) (*entity.Proje } 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 - } + s.fetchProjectFlockApproval(c, projectFlockKandang) + s.fetchChickinApproval(c, projectFlockKandang) } availableQtyMap, err := s.getAvailableQuantities(c, projectFlockKandang) @@ -164,6 +143,68 @@ func (s projectFlockKandangService) GetOne(c *fiber.Ctx, id uint) (*entity.Proje return projectFlockKandang, availableQtyMap, productWarehouses, nil } +func (s projectFlockKandangService) fetchProjectFlockApprovals(c *fiber.Ctx, projectFlockKandangs []entity.ProjectFlockKandang) { + projectFlockKandangIDs := make([]uint, len(projectFlockKandangs)) + for i, pfk := range projectFlockKandangs { + projectFlockKandangIDs[i] = pfk.Id + } + + approvalMap, err := s.ApprovalSvc.LatestByTargets(c.Context(), utils.ApprovalWorkflowProjectFlockKandang, projectFlockKandangIDs, func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + }) + if err != nil { + s.Log.Warnf("Failed to fetch approvals for projectFlockKandangs: %+v", err) + } else { + for i := range projectFlockKandangs { + if approval, ok := approvalMap[projectFlockKandangs[i].Id]; ok { + projectFlockKandangs[i].LatestProjectFlockApproval = approval + } + } + } +} + +func (s projectFlockKandangService) fetchChickinApprovals(c *fiber.Ctx, projectFlockKandangs []entity.ProjectFlockKandang) { + projectFlockKandangIDs := make([]uint, len(projectFlockKandangs)) + for i, pfk := range projectFlockKandangs { + projectFlockKandangIDs[i] = pfk.Id + } + + chickinApprovalMap, err := s.ApprovalSvc.LatestByTargets(c.Context(), utils.ApprovalWorkflowChickin, projectFlockKandangIDs, func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + }) + if err != nil { + s.Log.Warnf("Failed to fetch chickin approvals for projectFlockKandangs: %+v", err) + } else { + for i := range projectFlockKandangs { + if approval, ok := chickinApprovalMap[projectFlockKandangs[i].Id]; ok { + projectFlockKandangs[i].LatestChickinApproval = approval + } + } + } +} + +func (s projectFlockKandangService) fetchProjectFlockApproval(c *fiber.Ctx, projectFlockKandang *entity.ProjectFlockKandang) { + 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.LatestProjectFlockApproval = latest + } +} + +func (s projectFlockKandangService) fetchChickinApproval(c *fiber.Ctx, projectFlockKandang *entity.ProjectFlockKandang) { + latestChickin, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowChickin, projectFlockKandang.Id, nil) + if err != nil { + s.Log.Errorf("Failed to fetch latest chickin approval for projectFlockKandang %d: %+v", projectFlockKandang.Id, err) + } + + if latestChickin != nil { + projectFlockKandang.LatestChickinApproval = latestChickin + } +} + func (s projectFlockKandangService) getAvailableQuantities(c *fiber.Ctx, projectFlockKandang *entity.ProjectFlockKandang) (map[uint]float64, error) { if projectFlockKandang.Kandang.Id == 0 || s.WarehouseRepo == nil || s.ProductWarehouseRepo == nil { return nil, nil @@ -191,7 +232,7 @@ func (s projectFlockKandangService) getAvailableQuantities(c *fiber.Ctx, project result := make(map[uint]float64) for _, pw := range products { - if pw.ProjectFlockKandangId != nil && *pw.ProjectFlockKandangId == projectFlockKandang.Id { + if pw.ProjectFlockKandangId == nil || *pw.ProjectFlockKandangId == projectFlockKandang.Id { 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) @@ -552,6 +593,7 @@ func (s projectFlockKandangService) Closing(c *fiber.Ctx, id uint, req *validati } 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) { @@ -564,7 +606,17 @@ func (s projectFlockKandangService) calculateAvailableQuantityForProductWarehous } } - availableQty = productWarehouse.Quantity - totalPendingQty + totalPopulationQty := 0.0 + if s.PopulationRepo != nil { + popQty, err := s.PopulationRepo.GetTotalQtyByProductWarehouseID(c.Context(), productWarehouse.Id) + if err != nil { + s.Log.Errorf("Failed to get population qty for PW %d: %+v", productWarehouse.Id, err) + } else { + totalPopulationQty = popQty + } + } + + availableQty = productWarehouse.Quantity - totalPendingQty - totalPopulationQty if availableQty < 0 { availableQty = 0 } @@ -578,7 +630,17 @@ func (s projectFlockKandangService) calculateAvailableQuantityForProductWarehous } } - availableQty = productWarehouse.Quantity - totalPendingQty + totalPopulationQty := 0.0 + if s.PopulationRepo != nil { + popQty, err := s.PopulationRepo.GetTotalQtyByProductWarehouseID(c.Context(), productWarehouse.Id) + if err != nil { + s.Log.Errorf("Failed to get population qty for PW %d: %+v", productWarehouse.Id, err) + } else { + totalPopulationQty = popQty + } + } + + availableQty = productWarehouse.Quantity - totalPendingQty - totalPopulationQty if availableQty < 0 { availableQty = 0 } 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 fd263b27..022da6a3 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,19 +9,17 @@ import ( ) type ProjectFlockPopulationRepository interface { - // domain-specific GetByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) ([]entity.ProjectFlockPopulation, error) ExistsByProjectChickinID(ctx context.Context, projectChickinID uint) (bool, error) GetByProjectChickinIDAndProductWarehouseID(ctx context.Context, projectChickinID uint, productWarehouseID uint) ([]entity.ProjectFlockPopulation, error) GetByProjectFlockKandangIDAndProductWarehouseID(ctx context.Context, projectFlockKandangID uint, productWarehouseID uint) ([]entity.ProjectFlockPopulation, error) GetTotalQtyByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) + GetTotalQtyByProductWarehouseID(ctx context.Context, productWarehouseID uint) (float64, error) GetAvailableQtyByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) - // subset of base repository methods used by services CreateOne(ctx context.Context, entity *entity.ProjectFlockPopulation, modifier func(*gorm.DB) *gorm.DB) error PatchOne(ctx context.Context, id uint, updates map[string]any, modifier func(*gorm.DB) *gorm.DB) error - // transaction helpers WithTx(tx *gorm.DB) ProjectFlockPopulationRepository DB() *gorm.DB } @@ -98,9 +96,22 @@ func (r *projectFlockPopulationRepositoryImpl) GetTotalQtyByProjectFlockKandangI var total float64 err := r.DB().WithContext(ctx). Table("project_flock_populations"). - Select("COALESCE(SUM(total_qty), 0) AS total_qty"). - Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id"). - Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangID). + Select("COALESCE(SUM(total_qty - total_used_qty), 0) AS available_qty"). + Joins("JOIN product_warehouses pw ON project_flock_populations.product_warehouse_id = pw.id"). + Where("pw.project_flock_kandang_id = ?", projectFlockKandangID). + Scan(&total).Error + if err != nil { + return 0, err + } + return total, nil +} + +func (r *projectFlockPopulationRepositoryImpl) GetTotalQtyByProductWarehouseID(ctx context.Context, productWarehouseID uint) (float64, error) { + var total float64 + err := r.DB().WithContext(ctx). + Model(&entity.ProjectFlockPopulation{}). + Where("product_warehouse_id = ?", productWarehouseID). + Select("COALESCE(SUM(total_qty), 0)"). Scan(&total).Error if err != nil { return 0, err diff --git a/internal/modules/production/project_flocks/repositories/projectflock.repository.go b/internal/modules/production/project_flocks/repositories/projectflock.repository.go index 6cd98a8f..e65dfb4a 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock.repository.go @@ -20,6 +20,7 @@ type ProjectflockRepository interface { GetCurrentProjectPeriod(ctx context.Context, projectFlockID uint) (int, error) GetKandangPeriodSummaryRows(ctx context.Context, locationID uint) ([]KandangPeriodRow, error) GetActiveByLocationID(ctx context.Context, locationID uint64) ([]entity.ProjectFlock, error) + IdExists(ctx context.Context, id uint) (bool, error) AreaExists(ctx context.Context, id uint) (bool, error) FcrExists(ctx context.Context, id uint) (bool, error) ProductionStandardExists(ctx context.Context, id uint) (bool, error) @@ -161,6 +162,10 @@ func (r *ProjectflockRepositoryImpl) applySearchFilters(db *gorm.DB, rawSearch s ) } +func (r *ProjectflockRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.ProjectFlock](ctx, r.DB(), id) +} + func (r *ProjectflockRepositoryImpl) AreaExists(ctx context.Context, id uint) (bool, error) { return repository.Exists[entity.Area](ctx, r.DB(), id) } 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 42dcafd9..474a53c2 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go @@ -28,6 +28,7 @@ type ProjectFlockKandangRepository interface { ProjectPeriodsByProjectIDs(ctx context.Context, projectIDs []uint) (map[uint]int, error) HasOpenNewerPeriod(ctx context.Context, kandangID uint, currentPeriod int, excludeID *uint) (bool, error) ListIDsByProjectAndKandang(ctx context.Context, projectFlockID uint, kandangIDs []uint) ([]uint, error) + GetTotalPendingChickinQtyByProductWarehouseID(ctx context.Context, productWarehouseID uint) (float64, error) WithTx(tx *gorm.DB) ProjectFlockKandangRepository DB() *gorm.DB IdExists(ctx context.Context, id uint) (bool, error) @@ -206,6 +207,19 @@ func (r *projectFlockKandangRepositoryImpl) IdExists(ctx context.Context, id uin return repository.Exists[entity.ProjectFlockKandang](ctx, r.db, id) } +func (r *projectFlockKandangRepositoryImpl) GetTotalPendingChickinQtyByProductWarehouseID(ctx context.Context, productWarehouseID uint) (float64, error) { + var total float64 + err := r.db.WithContext(ctx). + Table("project_chickins"). + Select("COALESCE(SUM(pending_usage_qty), 0)"). + Where("product_warehouse_id = ?", productWarehouseID). + Scan(&total).Error + if err != nil { + return 0, err + } + return total, nil +} + func (r *projectFlockKandangRepositoryImpl) GetByID(ctx context.Context, id uint) (*entity.ProjectFlockKandang, error) { record := new(entity.ProjectFlockKandang) if err := r.db.WithContext(ctx). diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index 5f643dee..3dbe3f4b 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -23,6 +23,7 @@ import ( validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations" recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" uniformityRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/repositories" + purchaseRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" utils "gitlab.com/mbugroup/lti-api.git/internal/utils" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" @@ -308,12 +309,12 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* } createBody := &entity.ProjectFlock{ - AreaId: req.AreaId, - Category: cat, - FcrId: req.FcrId, + AreaId: req.AreaId, + Category: cat, + FcrId: req.FcrId, ProductionStandardId: req.ProductionStandardId, - LocationId: req.LocationId, - CreatedBy: actorID, + LocationId: req.LocationId, + CreatedBy: actorID, } err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { @@ -823,22 +824,7 @@ func (s projectflockService) detachKandangs(ctx context.Context, dbTransaction * return nil } - blocked, err := s.pivotRepoWithTx(dbTransaction).FindKandangsWithRecordings(ctx, projectFlockID, kandangIDs) - if err != nil { - s.Log.Errorf("Failed to check recordings before detaching kandangs: %+v", err) - return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate kandang detachment") - } - if len(blocked) > 0 { - names := make([]string, 0, len(blocked)) - for _, item := range blocked { - label := fmt.Sprintf("ID %d", item.Id) - if strings.TrimSpace(item.Name) != "" { - label = fmt.Sprintf("%s (%s)", label, item.Name) - } - names = append(names, label) - } - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Tidak dapat melepas kandang karena sudah memiliki recording: %s", strings.Join(names, ", "))) - } + // NOTE: Recording constraints are enforced via FK cascade; allow detachment even if recordings exist. pfkIDs, err := s.pivotRepoWithTx(dbTransaction).ListIDsByProjectAndKandang(ctx, projectFlockID, kandangIDs) if err != nil { @@ -854,6 +840,14 @@ func (s projectflockService) detachKandangs(ctx context.Context, dbTransaction * return fiber.NewError(fiber.StatusInternalServerError, "Failed to remove uniformity data for project flock kandang") } + db := s.Repository.DB() + if dbTransaction != nil { + db = dbTransaction + } + purchaseRepo := purchaseRepository.NewPurchaseRepository(db) + if err := purchaseRepo.SoftDeleteByProjectFlockKandangIDs(ctx, pfkIDs); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to soft delete purchases for project flock kandang") + } pwRepo := s.ProductWarehouseRepo if dbTransaction != nil { pwRepo = productWarehouseRepository.NewProductWarehouseRepository(dbTransaction) @@ -906,6 +900,11 @@ func (s projectflockService) ensureProjectFlockKandangProductWarehouses(ctx cont return nil } + projectFlockID := records[0].ProjectFlockId + if projectFlockID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Project flock id tidak ditemukan") + } + pwRepo := s.ProductWarehouseRepo if dbTransaction != nil { pwRepo = productWarehouseRepository.NewProductWarehouseRepository(dbTransaction) @@ -920,24 +919,34 @@ func (s projectflockService) ensureProjectFlockKandangProductWarehouses(ctx cont warehouseRepo = warehouseRepository.NewWarehouseRepository(s.Repository.DB()) } - flags := []utils.FlagType{ - utils.FlagAyamAfkir, - utils.FlagAyamCulling, - utils.FlagAyamMati, - utils.FlagTelurPecah, - utils.FlagTelurUtuh, + db := s.Repository.DB() + if dbTransaction != nil { + db = dbTransaction + } + var category string + if err := db.WithContext(ctx). + Model(&entity.ProjectFlock{}). + Select("category"). + Where("id = ?", projectFlockID). + Scan(&category).Error; err != nil { + return err + } + if strings.TrimSpace(category) == "" { + return fiber.NewError(fiber.StatusBadRequest, "Project flock category tidak ditemukan") } - productIDs := make(map[utils.FlagType]uint, len(flags)) - for _, flag := range flags { - product, err := pwRepo.GetFirstProductByFlag(ctx, string(flag)) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Product untuk flag %s tidak ditemukan", flag)) - } - return err - } - productIDs[flag] = product.Id + prefixes := []string{"AYAM-"} + if strings.EqualFold(category, string(utils.ProjectFlockCategoryLaying)) { + prefixes = append(prefixes, "TELUR") + } + + invisibleOnly := false + productIDs, err := pwRepo.ListProductIDsByFlagPrefixes(ctx, prefixes, &invisibleOnly) + if err != nil { + return err + } + if len(productIDs) == 0 { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Product dengan flag %s tidak ditemukan", strings.Join(prefixes, ", "))) } for _, record := range records { @@ -953,8 +962,7 @@ func (s projectflockService) ensureProjectFlockKandangProductWarehouses(ctx cont return err } - for _, flag := range flags { - productID := productIDs[flag] + for _, productID := range productIDs { if _, err := pwRepo.GetByProductWarehouseAndProjectFlockKandang(ctx, productID, warehouse.Id, record.Id); err == nil { continue } else if !errors.Is(err, gorm.ErrRecordNotFound) { diff --git a/internal/modules/production/recordings/dto/recording.dto.go b/internal/modules/production/recordings/dto/recording.dto.go index c34651ba..f5a04821 100644 --- a/internal/modules/production/recordings/dto/recording.dto.go +++ b/internal/modules/production/recordings/dto/recording.dto.go @@ -25,16 +25,16 @@ type RecordingRelationDTO struct { CumIntake int `json:"cum_intake"` FcrValue float64 `json:"fcr_value"` TotalChickQty float64 `json:"total_chick_qty"` - HandDay float64 `json:"hand_day"` - HandHouse float64 `json:"hand_house"` + HenDay float64 `json:"hen_day"` + HenHouse float64 `json:"hen_house"` FeedIntake float64 `json:"feed_intake"` - EggMesh float64 `json:"egg_mesh"` + EggMass float64 `json:"egg_mass"` EggWeight float64 `json:"egg_weight"` - StandardHandDay *float64 `json:"hand_day_std,omitempty"` - StandardHandHouse *float64 `json:"hand_house_std,omitempty"` + StandardHenDay *float64 `json:"hen_day_std,omitempty"` + StandardHenHouse *float64 `json:"hen_house_std,omitempty"` StandardFeedIntake *float64 `json:"feed_intake_std,omitempty"` StandardMaxDepletion *float64 `json:"max_depletion_std,omitempty"` - StandardEggMesh *float64 `json:"egg_mesh_std,omitempty"` + StandardEggMass *float64 `json:"egg_mass_std,omitempty"` StandardEggWeight *float64 `json:"egg_weight_std,omitempty"` StandardFcr *float64 `json:"fcr_std,omitempty"` Approval approvalDTO.ApprovalRelationDTO `json:"approval"` @@ -94,10 +94,10 @@ func ToRecordingRelationDTO(e entity.Recording) RecordingRelationDTO { cumIntake int fcrValue float64 totalChickQty float64 - handDay float64 - handHouse float64 + henDay float64 + henHouse float64 feedIntake float64 - eggMesh float64 + eggMass float64 eggWeight float64 ) @@ -119,17 +119,17 @@ func ToRecordingRelationDTO(e entity.Recording) RecordingRelationDTO { if e.TotalChickQty != nil { totalChickQty = *e.TotalChickQty } - if e.HandDay != nil { - handDay = *e.HandDay + if e.HenDay != nil { + henDay = *e.HenDay } - if e.HandHouse != nil { - handHouse = *e.HandHouse + if e.HenHouse != nil { + henHouse = *e.HenHouse } if e.FeedIntake != nil { feedIntake = *e.FeedIntake } - if e.EggMesh != nil { - eggMesh = *e.EggMesh + if e.EggMass != nil { + eggMass = *e.EggMass } if e.EggWeight != nil { eggWeight = *e.EggWeight @@ -157,16 +157,16 @@ func ToRecordingRelationDTO(e entity.Recording) RecordingRelationDTO { CumIntake: cumIntake, FcrValue: fcrValue, TotalChickQty: totalChickQty, - HandDay: handDay, - HandHouse: handHouse, + HenDay: henDay, + HenHouse: henHouse, FeedIntake: feedIntake, - EggMesh: eggMesh, + EggMass: eggMass, EggWeight: eggWeight, - StandardHandDay: e.StandardHandDay, - StandardHandHouse: e.StandardHandHouse, + StandardHenDay: e.StandardHenDay, + StandardHenHouse: e.StandardHenHouse, StandardFeedIntake: e.StandardFeedIntake, StandardMaxDepletion: e.StandardMaxDepletion, - StandardEggMesh: e.StandardEggMesh, + StandardEggMass: e.StandardEggMass, StandardEggWeight: e.StandardEggWeight, StandardFcr: e.StandardFcr, Approval: latestApproval, diff --git a/internal/modules/production/recordings/route.go b/internal/modules/production/recordings/route.go index f05d054d..e7f1b081 100644 --- a/internal/modules/production/recordings/route.go +++ b/internal/modules/production/recordings/route.go @@ -16,10 +16,10 @@ func RecordingRoutes(v1 fiber.Router, u user.UserService, s recording.RecordingS route.Use(m.Auth(u)) route.Get("/",m.RequirePermissions(m.P_RecordingGetAll), ctrl.GetAll) + route.Get("/next-day",m.RequirePermissions(m.P_RecordingNextDay), ctrl.GetNextDay) route.Get("/:id",m.RequirePermissions(m.P_RecordingGetOne), ctrl.GetOne) route.Post("/",m.RequirePermissions(m.P_RecordingCreateOne), ctrl.CreateOne) route.Patch("/:id",m.RequirePermissions(m.P_RecordingUpdateOne), ctrl.UpdateOne) route.Delete("/:id",m.RequirePermissions(m.P_RecordingDeleteOne), ctrl.DeleteOne) - route.Get("/next-day",m.RequirePermissions(m.P_RecordingNextDay), ctrl.GetNextDay) route.Post("/approvals",m.RequirePermissions(m.P_RecordingApproval), ctrl.Approve) } diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 54052518..819552dc 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -901,47 +901,6 @@ type eggTotals struct { Weight float64 } -type stockTotals struct { - Usage float64 - Pending float64 - Total float64 -} - -func summarizeExistingStocks(stocks []entity.RecordingStock) map[uint]stockTotals { - totals := make(map[uint]stockTotals) - for _, stock := range stocks { - var usage float64 - var pending float64 - if stock.UsageQty != nil { - usage = *stock.UsageQty - } - if stock.PendingQty != nil { - pending = *stock.PendingQty - } - current := totals[stock.ProductWarehouseId] - current.Usage += usage - current.Pending += pending - current.Total += usage + pending - totals[stock.ProductWarehouseId] = current - } - return totals -} - -func summarizeIncomingStocks(stocks []validation.Stock) map[uint]stockTotals { - totals := make(map[uint]stockTotals) - for _, stock := range stocks { - var pending float64 - if stock.PendingQty != nil { - pending = *stock.PendingQty - } - current := totals[stock.ProductWarehouseId] - current.Usage += stock.Qty - current.Pending += pending - current.Total += stock.Qty + pending - totals[stock.ProductWarehouseId] = current - } - return totals -} func stocksMatch(existing []entity.RecordingStock, incoming []validation.Stock) bool { hasPending := false @@ -1156,34 +1115,34 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm recording.FeedIntake = nil } - var handDay float64 + var henDay float64 if remainingChick > 0 && totalEggQty >= 0 { - handDay = (totalEggQty / remainingChick) * 100 - updates["hand_day"] = handDay - recording.HandDay = &handDay + henDay = (totalEggQty / remainingChick) * 100 + updates["hen_day"] = henDay + recording.HenDay = &henDay } else { - updates["hand_day"] = gorm.Expr("NULL") - recording.HandDay = nil + updates["hen_day"] = gorm.Expr("NULL") + recording.HenDay = nil } - var handHouse float64 + var henHouse float64 if initialChickin > 0 && cumulativeEggQty >= 0 { - handHouse = cumulativeEggQty / initialChickin - updates["hand_house"] = handHouse - recording.HandHouse = &handHouse + henHouse = cumulativeEggQty / initialChickin + updates["hen_house"] = henHouse + recording.HenHouse = &henHouse } else { - updates["hand_house"] = gorm.Expr("NULL") - recording.HandHouse = nil + updates["hen_house"] = gorm.Expr("NULL") + recording.HenHouse = nil } - var eggMesh float64 + var eggMass float64 if remainingChick > 0 && totalEggWeightGrams > 0 { - eggMesh = (totalEggWeightGrams / remainingChick) * 1000 - updates["egg_mesh"] = eggMesh - recording.EggMesh = &eggMesh + eggMass = (totalEggWeightGrams / remainingChick) * 1000 + updates["egg_mass"] = eggMass + recording.EggMass = &eggMass } else { - updates["egg_mesh"] = gorm.Expr("NULL") - recording.EggMesh = nil + updates["egg_mass"] = gorm.Expr("NULL") + recording.EggMass = nil } var eggWeight float64 @@ -1334,11 +1293,11 @@ func (s *recordingService) attachLatestApproval(ctx context.Context, item *entit } type productionStandardValues struct { - HandDay *float64 - HandHouse *float64 + HenDay *float64 + HenHouse *float64 FeedIntake *float64 MaxDepletion *float64 - EggMesh *float64 + EggMass *float64 EggWeight *float64 } @@ -1389,10 +1348,10 @@ func (s *recordingService) attachProductionStandard(ctx context.Context, item *e return err } if detail != nil { - standard.HandDay = detail.TargetHenDayProduction - standard.HandHouse = detail.TargetHenHouseProduction + standard.HenDay = detail.TargetHenDayProduction + standard.HenHouse = detail.TargetHenHouseProduction standard.EggWeight = detail.TargetEggWeight - standard.EggMesh = detail.TargetEggMass + standard.EggMass = detail.TargetEggMass } } @@ -1420,11 +1379,11 @@ func (s *recordingService) attachProductionStandard(ctx context.Context, item *e } } - item.StandardHandDay = standard.HandDay - item.StandardHandHouse = standard.HandHouse + item.StandardHenDay = standard.HenDay + item.StandardHenHouse = standard.HenHouse item.StandardFeedIntake = standard.FeedIntake item.StandardMaxDepletion = standard.MaxDepletion - item.StandardEggMesh = standard.EggMesh + item.StandardEggMass = standard.EggMass item.StandardEggWeight = standard.EggWeight item.StandardFcr = standardFcr diff --git a/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go b/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go index c69f4ff5..d2ab6d0a 100644 --- a/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go +++ b/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go @@ -84,7 +84,7 @@ 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") + return fiber.NewError(fiber.StatusBadRequest, "Format permintaan tidak valid") } result, err := u.TransferLayingService.CreateOne(c, req) @@ -96,7 +96,7 @@ func (u *TransferLayingController) CreateOne(c *fiber.Ctx) error { JSON(response.Success{ Code: fiber.StatusCreated, Status: "success", - Message: "Create transferLaying successfully", + Message: "Berhasil membuat transfer laying", Data: dto.ToTransferLayingListDTO(*result), }) } diff --git a/internal/modules/production/transfer_layings/dto/transfer_laying.dto.go b/internal/modules/production/transfer_layings/dto/transfer_laying.dto.go index aeb12e5e..e81d6cc5 100644 --- a/internal/modules/production/transfer_layings/dto/transfer_laying.dto.go +++ b/internal/modules/production/transfer_layings/dto/transfer_laying.dto.go @@ -67,8 +67,6 @@ type TransferLayingListDTO struct { TransferLayingRelationDTO 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.UserRelationDTO `json:"created_user,omitempty"` CreatedAt time.Time `json:"created_at"` @@ -166,7 +164,7 @@ func ToProductWarehouseSummaryDTO(pw *entity.ProductWarehouse) *ProductWarehouse func ToLayingTransferSourceDTO(source entity.LayingTransferSource) LayingTransferSourceDTO { return LayingTransferSourceDTO{ SourceProjectFlockKandang: ToProjectFlockKandangSummaryDTO(source.SourceProjectFlockKandang), - Qty: source.Qty, + Qty: source.UsageQty, // Ambil dari UsageQty (FIFO consumed quantity) ProductWarehouse: ToProductWarehouseSummaryDTO(source.ProductWarehouse), Note: source.Note, } @@ -186,7 +184,7 @@ func ToLayingTransferSourceDTOs(sources []entity.LayingTransferSource) []LayingT func ToLayingTransferTargetDTO(target entity.LayingTransferTarget) LayingTransferTargetDTO { return LayingTransferTargetDTO{ TargetProjectFlockKandang: ToProjectFlockKandangSummaryDTO(target.TargetProjectFlockKandang), - Qty: target.Qty, + Qty: target.TotalQty, // Ambil dari TotalQty (FIFO replenished quantity) ProductWarehouse: ToProductWarehouseSummaryDTO(target.ProductWarehouse), Note: target.Note, } @@ -223,8 +221,6 @@ func ToTransferLayingListDTO(e entity.LayingTransfer) TransferLayingListDTO { TransferLayingRelationDTO: ToTransferLayingRelationDTO(e), FromProjectFlock: ToProjectFlockSummaryDTO(e.FromProjectFlock), ToProjectFlock: ToProjectFlockSummaryDTO(e.ToProjectFlock), - PendingUsageQty: e.PendingUsageQty, - UsageQty: e.UsageQty, CreatedBy: e.CreatedBy, CreatedUser: createdUser, CreatedAt: e.CreatedAt, diff --git a/internal/modules/production/transfer_layings/module.go b/internal/modules/production/transfer_layings/module.go index 27851b71..dfe2ad44 100644 --- a/internal/modules/production/transfer_layings/module.go +++ b/internal/modules/production/transfer_layings/module.go @@ -2,6 +2,7 @@ package transfer_layings import ( "fmt" + "strings" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -13,6 +14,7 @@ import ( 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" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" 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" @@ -24,6 +26,8 @@ type TransferLayingModule struct{} func (TransferLayingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { transferLayingRepo := rTransferLaying.NewTransferLayingRepository(db) + layingTransferSourceRepo := rTransferLaying.NewLayingTransferSourceRepository(db) + layingTransferTargetRepo := rTransferLaying.NewLayingTransferTargetRepository(db) userRepo := rUser.NewUserRepository(db) projectFlockRepo := rProjectFlock.NewProjectflockRepository(db) projectFlockKandangRepo := rProjectFlock.NewProjectFlockKandangRepository(db) @@ -31,6 +35,45 @@ func (TransferLayingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, val productWarehouseRepo := rInventory.NewProductWarehouseRepository(db) warehouseRepo := rWarehouse.NewWarehouseRepository(db) + stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) + fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) + + // daftarin jadi stockable + if err := fifoService.RegisterStockable(fifo.StockableConfig{ + Key: fifo.StockableKeyTransferToLayingIn, + Table: "laying_transfer_targets", + Columns: fifo.StockableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + TotalQuantity: "total_qty", + TotalUsedQuantity: "total_used", + CreatedAt: "created_at", + }, + OrderBy: []string{"created_at ASC", "id ASC"}, + }); err != nil { + if !strings.Contains(strings.ToLower(err.Error()), "already registered") { + panic(fmt.Sprintf("failed to register transfer to laying stockable workflow: %v", err)) + } + } + + // daftarin jadi usable + if err := fifoService.RegisterUsable(fifo.UsableConfig{ + Key: fifo.UsableKeyTransferToLayingOut, + Table: "laying_transfer_sources", + Columns: fifo.UsableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + UsageQuantity: "usage_qty", + PendingQuantity: "pending_usage_qty", + CreatedAt: "created_at", + }, + OrderBy: []string{"created_at ASC", "id ASC"}, + }); err != nil { + if !strings.Contains(strings.ToLower(err.Error()), "already registered") { + panic(fmt.Sprintf("failed to register transfer to laying usable workflow: %v", err)) + } + } + approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowTransferToLaying, utils.TransferToLayingApprovalSteps); err != nil { @@ -39,12 +82,15 @@ func (TransferLayingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, val transferLayingService := sTransferLaying.NewTransferLayingService( transferLayingRepo, + layingTransferSourceRepo, + layingTransferTargetRepo, projectFlockRepo, projectFlockKandangRepo, projectFlockPopulationRepo, productWarehouseRepo, warehouseRepo, approvalService, + fifoService, validate, ) userService := sUser.NewUserService(userRepo, validate) diff --git a/internal/modules/production/transfer_layings/services/transfer_laying.service.go b/internal/modules/production/transfer_layings/services/transfer_laying.service.go index bf2c2ae3..9732ad75 100644 --- a/internal/modules/production/transfer_layings/services/transfer_laying.service.go +++ b/internal/modules/production/transfer_layings/services/transfer_laying.service.go @@ -16,7 +16,9 @@ import ( 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" + rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -39,34 +41,45 @@ type transferLayingService struct { Log *logrus.Logger Validate *validator.Validate Repository repository.TransferLayingRepository + LayingTransferSourceRepo repository.LayingTransferSourceRepository + LayingTransferTargetRepo repository.LayingTransferTargetRepository ProjectFlockRepo ProjectFlockRepository.ProjectflockRepository ProjectFlockKandangRepo ProjectFlockRepository.ProjectFlockKandangRepository ProjectFlockPopulationRepo ProjectFlockRepository.ProjectFlockPopulationRepository ProductWarehouseRepo rInventory.ProductWarehouseRepository WarehouseRepo rWarehouse.WarehouseRepository + StockLogRepo rStockLogs.StockLogRepository ApprovalService commonSvc.ApprovalService + FifoSvc commonSvc.FifoService } func NewTransferLayingService( repo repository.TransferLayingRepository, + layingTransferSourceRepo repository.LayingTransferSourceRepository, + layingTransferTargetRepo repository.LayingTransferTargetRepository, projectFlockRepo ProjectFlockRepository.ProjectflockRepository, projectFlockKandangRepo ProjectFlockRepository.ProjectFlockKandangRepository, projectFlockPopulationRepo ProjectFlockRepository.ProjectFlockPopulationRepository, productWarehouseRepo rInventory.ProductWarehouseRepository, warehouseRepo rWarehouse.WarehouseRepository, approvalService commonSvc.ApprovalService, + fifoSvc commonSvc.FifoService, validate *validator.Validate, ) TransferLayingService { return &transferLayingService{ Log: utils.Log, Validate: validate, Repository: repo, + LayingTransferSourceRepo: layingTransferSourceRepo, + LayingTransferTargetRepo: layingTransferTargetRepo, ProjectFlockRepo: projectFlockRepo, ProjectFlockKandangRepo: projectFlockKandangRepo, ProjectFlockPopulationRepo: projectFlockPopulationRepo, ProductWarehouseRepo: productWarehouseRepo, WarehouseRepo: warehouseRepo, + StockLogRepo: rStockLogs.NewStockLogRepository(repo.DB()), ApprovalService: approvalService, + FifoSvc: fifoSvc, } } @@ -160,55 +173,42 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) 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 := commonSvc.EnsureRelations(c.Context(), + commonSvc.RelationCheck{Name: "Source Project Flock", ID: &req.SourceProjectFlockId, Exists: s.ProjectFlockRepo.IdExists}, + commonSvc.RelationCheck{Name: "Target Project Flock", ID: &req.TargetProjectFlockId, Exists: s.ProjectFlockRepo.IdExists}, + ); err != nil { + return nil, err } - 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") + sourceKandangIDs := make([]uint, len(req.SourceKandangs)) + for i, detail := range req.SourceKandangs { + sourceKandangIDs[i] = detail.ProjectFlockKandangId } - 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)) - } + if err := s.validateKandangOwnership( + c.Context(), + req.SourceProjectFlockId, + sourceKandangIDs, + ); err != nil { + return nil, err } - 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 - } + targetKandangIDs := make([]uint, len(req.TargetKandangs)) + for i, detail := range req.TargetKandangs { + targetKandangIDs[i] = detail.ProjectFlockKandangId + } - 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)) - } + if err := s.validateKandangOwnership( + c.Context(), + req.TargetProjectFlockId, + targetKandangIDs, + ); err != nil { + return nil, err } transferDate, err := utils.ParseDateString(req.TransferDate) if err != nil { - return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transfer date format") + return nil, fiber.NewError(fiber.StatusBadRequest, "Format tanggal transfer tidak valid") } var totalSourceQty, totalTargetQty float64 @@ -216,7 +216,7 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) for _, sourceDetail := range req.SourceKandangs { if sourceDetail.Quantity <= 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Source kandang quantity must be greater than 0") + return nil, fiber.NewError(fiber.StatusBadRequest, "Jumlah kandang sumber harus lebih dari 0") } totalSourceQty += sourceDetail.Quantity @@ -235,11 +235,11 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) } if totalPopulation == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Source kandang %d has no population available for transfer", sourceDetail.ProjectFlockKandangId)) + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Kandang sumber %d tidak memiliki populasi untuk ditransfer", 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)) + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Kandang sumber %d jumlah tidak mencukupi. Tersedia: %.0f, Diminta: %.0f", sourceDetail.ProjectFlockKandangId, totalPopulation, sourceDetail.Quantity)) } sourceWarehouseMap[sourceDetail.ProjectFlockKandangId] = productWarehouseId @@ -247,13 +247,13 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) for _, targetDetail := range req.TargetKandangs { if targetDetail.Quantity <= 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Target kandang quantity must be greater than 0") + return nil, fiber.NewError(fiber.StatusBadRequest, "Jumlah kandang tujuan harus lebih dari 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)) + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Jumlah total sumber (%.0f) harus sama dengan jumlah total tujuan (%.0f)", totalSourceQty, totalTargetQty)) } transferNumber := fmt.Sprintf("TL-%d", time.Now().UnixNano()) @@ -264,18 +264,19 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) FromProjectFlockId: req.SourceProjectFlockId, ToProjectFlockId: req.TargetProjectFlockId, TransferDate: transferDate, - PendingUsageQty: &totalSourceQty, CreatedBy: actorID, } 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") - } + repoTx := s.Repository.WithTx(dbTransaction) + sourceRepoTx := repository.NewLayingTransferSourceRepository(dbTransaction) + targetRepoTx := repository.NewLayingTransferTargetRepository(dbTransaction) + pwRepoTx := rInventory.NewProductWarehouseRepository(dbTransaction) - productWarehouseRepoTx := s.ProductWarehouseRepo.WithTx(dbTransaction) - projectFlockPopulationRepoTx := s.ProjectFlockPopulationRepo.WithTx(dbTransaction) + if err := repoTx.CreateOne(c.Context(), createBody, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat record transfer laying") + } for _, sourceDetail := range req.SourceKandangs { productWarehouseId := sourceWarehouseMap[sourceDetail.ProjectFlockKandangId] @@ -283,63 +284,94 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) source := entity.LayingTransferSource{ LayingTransferId: createBody.Id, SourceProjectFlockKandangId: sourceDetail.ProjectFlockKandangId, - Qty: sourceDetail.Quantity, + UsageQty: 0, + PendingUsageQty: 0, // Di-set 0, biarkan FIFO Consume yang handle saat Approval ProductWarehouseId: &productWarehouseId, } - if err := dbTransaction.Create(&source).Error; err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to create transfer source") + if err := sourceRepoTx.CreateOne(c.Context(), &source, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat sumber transfer") } - 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) + targetprojectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), targetDetail.ProjectFlockKandangId) if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get target project flock kandang") + return fiber.NewError(fiber.StatusInternalServerError, "Gagal mendapatkan project flock kandang tujuan") } - targetWarehouse, err := s.WarehouseRepo.GetLatestByKandangID(c.Context(), targetPFK.KandangId) + targetWarehouse, err := s.WarehouseRepo.GetLatestByKandangID(c.Context(), targetprojectFlockKandang.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.StatusBadRequest, fmt.Sprintf("Warehouse tidak ditemukan untuk kandang tujuan %d", targetDetail.ProjectFlockKandangId)) + } + return fiber.NewError(fiber.StatusInternalServerError, "Gagal mendapatkan warehouse tujuan") + } + + // Ambil product ID dari salah satu source warehouse (harusnya semua sources product-nya sama) + var sourceProductID uint + for _, sourceDetail := range req.SourceKandangs { + if pwID, ok := sourceWarehouseMap[sourceDetail.ProjectFlockKandangId]; ok { + // Get product warehouse untuk ambil product ID + sourcePW, err := pwRepoTx.GetByID(c.Context(), pwID, nil) + if err == nil { + sourceProductID = sourcePW.ProductId + break + } + } + } + + if sourceProductID == 0 { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal mendapatkan product dari source warehouse") + } + + // Cari product warehouse di target berdasarkan: warehouse + project_flock_kandang + PRODUCT + targetPW, err := pwRepoTx.FindByProductWarehouseAndPfk(c.Context(), sourceProductID, targetWarehouse.Id, &targetDetail.ProjectFlockKandangId) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + newTargetPW := entity.ProductWarehouse{ + ProductId: sourceProductID, + WarehouseId: targetWarehouse.Id, + ProjectFlockKandangId: &targetDetail.ProjectFlockKandangId, + Quantity: 0, + } + if err := pwRepoTx.CreateOne(c.Context(), &newTargetPW, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal membuat product warehouse untuk kandang tujuan %d: %v", targetDetail.ProjectFlockKandangId, err)) + } + targetPW = &newTargetPW + } else { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mendapatkan product warehouse untuk kandang tujuan %d: %v", targetDetail.ProjectFlockKandangId, err)) } - 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, + TotalQty: targetDetail.Quantity, + TotalUsed: 0, + ProductWarehouseId: &targetPW.Id, } - if err := dbTransaction.Create(&target).Error; err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to create transfer target") + if err := targetRepoTx.CreateOne(c.Context(), &target, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat target transfer") } } if err := createApprovalTransferLaying(c.Context(), dbTransaction, createBody.Id, createBody.CreatedBy); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to create transfer approval") + return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat approval transfer") } return nil }) if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create transfer laying") + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat transfer laying") } return s.GetOne(c, createBody.Id) } -func (s transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.LayingTransfer, error) { +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 } @@ -381,53 +413,33 @@ func (s transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, i } err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { - projectFlockPopulationRepoTx := s.ProjectFlockPopulationRepo.WithTx(dbTransaction) - productWarehouseRepoTx := s.ProductWarehouseRepo.WithTx(dbTransaction) + repoTx := s.Repository.WithTx(dbTransaction) + sourceRepo := s.LayingTransferSourceRepo.WithTx(dbTransaction) + targetRepo := s.LayingTransferTargetRepo.WithTx(dbTransaction) + // Hapus old sources dan targets for _, oldSource := range existingTransfer.Sources { - if oldSource.ProductWarehouseId != nil && oldSource.Qty > 0 { - - if err := productWarehouseRepoTx.PatchOne(c.Context(), *oldSource.ProductWarehouseId, map[string]any{ - "quantity": gorm.Expr("quantity + ?", oldSource.Qty), - }, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to restore warehouse quantity") - } - - if err := s.restoreProjectFlockPopulation(c.Context(), projectFlockPopulationRepoTx, oldSource.SourceProjectFlockKandangId, oldSource.Qty); err != nil { - return err - } - } - } - - for _, oldSource := range existingTransfer.Sources { - if err := dbTransaction.Delete(&oldSource).Error; err != nil { + if err := sourceRepo.DeleteOne(c.Context(), oldSource.Id); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete old source") } } for _, oldTarget := range existingTransfer.Targets { - if err := dbTransaction.Delete(&oldTarget).Error; err != nil { + if err := targetRepo.DeleteOne(c.Context(), oldTarget.Id); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete old target") } } - totalSourceQty := 0.0 - for _, source := range req.SourceKandangs { - totalSourceQty += source.Quantity - } - - if err := s.Repository.WithTx(dbTransaction).PatchOne(c.Context(), id, map[string]any{ - "transfer_date": transferDate, - "notes": req.Reason, - "pending_usage_qty": &totalSourceQty, + if err := repoTx.PatchOne(c.Context(), id, map[string]any{ + "transfer_date": transferDate, + "notes": req.Reason, }, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to update transfer header") } - sourceWarehouseMap := make(map[uint]uint) + // Create new sources dengan pending quantity for _, sourceDetail := range req.SourceKandangs { - - populations, err := projectFlockPopulationRepoTx.GetByProjectFlockKandangID(c.Context(), sourceDetail.ProjectFlockKandangId) + populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(c.Context(), sourceDetail.ProjectFlockKandangId) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get populations") } @@ -436,48 +448,39 @@ func (s transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, i return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Source kandang %d has no population available", sourceDetail.ProjectFlockKandangId)) } - var totalPopulation float64 var productWarehouseId uint - for _, pop := range populations { - totalPopulation += pop.TotalQty if pop.ProductWarehouseId > 0 { productWarehouseId = pop.ProductWarehouseId + break } } - if totalPopulation < sourceDetail.Quantity { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Source kandang %d has insufficient quantity. Available: %.0f, Requested: %.0f", sourceDetail.ProjectFlockKandangId, totalPopulation, sourceDetail.Quantity)) + if productWarehouseId == 0 { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Source kandang %d has no product warehouse", sourceDetail.ProjectFlockKandangId)) } - sourceWarehouseMap[sourceDetail.ProjectFlockKandangId] = productWarehouseId - source := entity.LayingTransferSource{ LayingTransferId: id, SourceProjectFlockKandangId: sourceDetail.ProjectFlockKandangId, - Qty: sourceDetail.Quantity, + UsageQty: 0, + PendingUsageQty: sourceDetail.Quantity, ProductWarehouseId: &productWarehouseId, } - if err := dbTransaction.Create(&source).Error; err != nil { + if err := sourceRepo.CreateOne(c.Context(), &source, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create transfer source") } - - if err := s.reduceProjectFlockPopulation(c.Context(), projectFlockPopulationRepoTx, sourceDetail.ProjectFlockKandangId, sourceDetail.Quantity); err != nil { - return err - } - - if err := productWarehouseRepoTx.PatchOne(c.Context(), productWarehouseId, map[string]any{"quantity": gorm.Expr("quantity - ?", sourceDetail.Quantity)}, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to update source warehouse quantity") - } } + pwRepo := rInventory.NewProductWarehouseRepository(dbTransaction) + for _, targetDetail := range req.TargetKandangs { - targetPFK, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), targetDetail.ProjectFlockKandangId) + targetprojectFlockKandang, 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) + targetWarehouse, err := s.WarehouseRepo.GetLatestByKandangID(c.Context(), targetprojectFlockKandang.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)) @@ -485,13 +488,50 @@ func (s transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, i return fiber.NewError(fiber.StatusInternalServerError, "Failed to get target warehouse") } + // Ambil product ID dari source yang pertama (semua sources seharusnya product-nya sama) + var sourceProductID uint + if len(req.SourceKandangs) > 0 { + firstSourceKandangID := req.SourceKandangs[0].ProjectFlockKandangId + populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(c.Context(), firstSourceKandangID) + if err == nil && len(populations) > 0 && populations[0].ProductWarehouseId > 0 { + sourcePW, err := pwRepo.GetByID(c.Context(), populations[0].ProductWarehouseId, nil) + if err == nil { + sourceProductID = sourcePW.ProductId + } + } + } + + if sourceProductID == 0 { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get product from source warehouse") + } + + targetPW, err := pwRepo.FindByProductWarehouseAndPfk(c.Context(), sourceProductID, targetWarehouse.Id, &targetDetail.ProjectFlockKandangId) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + + newTargetPW := entity.ProductWarehouse{ + ProductId: sourceProductID, + WarehouseId: targetWarehouse.Id, + ProjectFlockKandangId: &targetDetail.ProjectFlockKandangId, + Quantity: 0, + } + if err := pwRepo.CreateOne(c.Context(), &newTargetPW, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to create product warehouse for target kandang %d: %v", targetDetail.ProjectFlockKandangId, err)) + } + targetPW = &newTargetPW + } else { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to get product warehouse for target kandang %d: %v", targetDetail.ProjectFlockKandangId, err)) + } + } + target := entity.LayingTransferTarget{ LayingTransferId: id, TargetProjectFlockKandangId: targetDetail.ProjectFlockKandangId, - Qty: targetDetail.Quantity, - ProductWarehouseId: &targetWarehouse.Id, + TotalQty: targetDetail.Quantity, + TotalUsed: 0, + ProductWarehouseId: &targetPW.Id, } - if err := dbTransaction.Create(&target).Error; err != nil { + if err := targetRepo.CreateOne(c.Context(), &target, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create transfer target") } } @@ -519,6 +559,7 @@ func (s transferLayingService) DeleteOne(c *fiber.Ctx, id uint) error { } 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") @@ -531,51 +572,9 @@ func (s transferLayingService) DeleteOne(c *fiber.Ctx, id uint) error { } } err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + repoTx := s.Repository.WithTx(dbTransaction) - productWarehouseRepoTx := s.ProductWarehouseRepo.WithTx(dbTransaction) - - sourceRepoTx := repository.NewLayingTransferSourceRepository(dbTransaction) - sources, err := sourceRepoTx.GetByLayingTransferId(c.Context(), id) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get transfer sources") - } - - for _, source := range sources { - if source.ProductWarehouseId != nil && source.Qty > 0 { - - if err := productWarehouseRepoTx.PatchOne(c.Context(), *source.ProductWarehouseId, map[string]any{ - "quantity": gorm.Expr("quantity + ?", source.Qty), - }, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to restore source warehouse quantity") - } - } - } - - projectFlockPopulationRepoTx := s.ProjectFlockPopulationRepo.WithTx(dbTransaction) - for _, source := range sources { - populations, err := projectFlockPopulationRepoTx.GetByProjectFlockKandangID(c.Context(), source.SourceProjectFlockKandangId) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get populations for restoration") - } - - remainingToRestore := source.Qty - for i := len(populations) - 1; i >= 0 && remainingToRestore > 0; i-- { - pop := populations[i] - restoreAmount := remainingToRestore - if pop.TotalQty < remainingToRestore { - restoreAmount = pop.TotalQty - } - - newQty := pop.TotalQty + restoreAmount - if err := projectFlockPopulationRepoTx.PatchOne(c.Context(), pop.Id, map[string]any{"total_qty": newQty}, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to restore population quantity") - } - - remainingToRestore -= restoreAmount - } - } - - if err := s.Repository.WithTx(dbTransaction).DeleteOne(c.Context(), id); err != nil { + if err := repoTx.DeleteOne(c.Context(), id); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete transfer laying") } @@ -624,14 +623,15 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( } err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { - + repoTx := s.Repository.WithTx(dbTransaction) approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) + + // Gunakan repo baru untuk transaction scope agar bisa akses method custom sourceRepoTx := repository.NewLayingTransferSourceRepository(dbTransaction) targetRepoTx := repository.NewLayingTransferTargetRepository(dbTransaction) - productWarehouseRepoTx := s.ProductWarehouseRepo.WithTx(dbTransaction) for _, approvableID := range approvableIDs { - transfer, err := s.Repository.GetByID(c.Context(), approvableID, nil) + transfer, err := repoTx.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)) @@ -651,67 +651,73 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( return fiber.NewError(fiber.StatusInternalServerError, "Failed to record approval") } - if action == entity.ApprovalActionApproved && transfer.PendingUsageQty != nil && *transfer.PendingUsageQty > 0 { + if action == entity.ApprovalActionApproved { sources, err := sourceRepoTx.GetByLayingTransferId(c.Context(), approvableID) if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get transfer sources") + return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil sources transfer") } targets, err := targetRepoTx.GetByLayingTransferId(c.Context(), approvableID) if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get transfer targets") + return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil targets transfer") } - 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)) + // Hitung total quantity dari targets untuk di-consume dari sources + totalTargetQty := 0.0 + for _, target := range targets { + totalTargetQty += target.TotalQty + } + + // Consume dari laying_transfer_sources (Usable) - akan consume dari ProjectFlockPopulation (Stockable) + for _, source := range sources { + if source.ProductWarehouseId == nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Source product warehouse tidak ditemukan untuk transfer %d", approvableID)) } - sourceWarehouse, err := productWarehouseRepoTx.GetByID(c.Context(), *firstSource.ProductWarehouseId, nil) + consumeResult, err := s.FifoSvc.Consume(c.Context(), commonSvc.StockConsumeRequest{ + UsableKey: fifo.UsableKeyTransferToLayingOut, + UsableID: source.Id, + ProductWarehouseID: *source.ProductWarehouseId, + Quantity: totalTargetQty, + AllowPending: false, + Tx: dbTransaction, + }) if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get source warehouse") + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal consume FIFO stock: %v", err)) } - 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") - } + if err := sourceRepoTx.PatchOne(c.Context(), source.Id, map[string]interface{}{ + "usage_qty": source.UsageQty + consumeResult.UsageQuantity, + "pending_usage_qty": consumeResult.PendingQuantity, + }, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal update source usage qty") } } - 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") + for _, target := range targets { + if target.ProductWarehouseId == nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Target product warehouse tidak ditemukan untuk transfer %d", approvableID)) + } + + note := fmt.Sprintf("Transfer to Laying #%s - Target Kandang", transfer.TransferNumber) + replenishResult, err := s.FifoSvc.Replenish(c.Context(), commonSvc.StockReplenishRequest{ + StockableKey: fifo.StockableKeyTransferToLayingIn, + StockableID: target.Id, + ProductWarehouseID: *target.ProductWarehouseId, + Quantity: target.TotalQty, + Note: ¬e, + Tx: dbTransaction, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal replenish stock ke target warehouse: %v", err)) + } + + if err := targetRepoTx.PatchOne(c.Context(), target.Id, map[string]interface{}{ + "total_qty": replenishResult.AddedQuantity, + }, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal update target total qty") + } } } } @@ -758,14 +764,14 @@ func createApprovalTransferLaying(ctx context.Context, tx *gorm.DB, transferLayi return err } -func (s *transferLayingService) getOrCreateProductWarehouse(ctx context.Context, tx *gorm.DB, productID uint, warehouseID uint, quantity float64, actorID uint) (*entity.ProductWarehouse, error) { +func (s *transferLayingService) getOrCreateProductWarehouse(ctx context.Context, tx *gorm.DB, productID uint, warehouseID uint, quantity float64, actorID uint, projectFlockKandangId *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 { + if err := productWarehouseRepoTx.PatchOne(ctx, existing.Id, map[string]any{"qty": gorm.Expr("qty + ?", quantity)}, nil); err != nil { return nil, err } return existing, nil @@ -775,10 +781,10 @@ func (s *transferLayingService) getOrCreateProductWarehouse(ctx context.Context, } newWarehouse := &entity.ProductWarehouse{ - ProductId: productID, - WarehouseId: warehouseID, - Quantity: quantity, - // CreatedBy: actorID, + ProductId: productID, + WarehouseId: warehouseID, + ProjectFlockKandangId: projectFlockKandangId, + Quantity: quantity, } if err := productWarehouseRepoTx.CreateOne(ctx, newWarehouse, nil); err != nil { @@ -788,66 +794,6 @@ func (s *transferLayingService) getOrCreateProductWarehouse(ctx context.Context, return newWarehouse, nil } -func (s *transferLayingService) reduceProjectFlockPopulation(ctx context.Context, populationRepo ProjectFlockRepository.ProjectFlockPopulationRepository, projectFlockKandangID uint, quantityToReduce float64) error { - - populations, err := populationRepo.GetByProjectFlockKandangID(ctx, projectFlockKandangID) - if err != nil { - return err - } - - if len(populations) == 0 { - return fiber.NewError(fiber.StatusBadRequest, "No populations found for reduction") - } - - remainingToReduce := quantityToReduce - - for i := len(populations) - 1; i >= 0; i-- { - if remainingToReduce <= 0 { - break - } - - pop := populations[i] - reductionAmount := remainingToReduce - if pop.TotalQty < remainingToReduce { - reductionAmount = pop.TotalQty - } - - newQty := pop.TotalQty - reductionAmount - if err := populationRepo.PatchOne(ctx, pop.Id, map[string]any{"total_qty": newQty}, nil); err != nil { - return err - } - - remainingToReduce -= reductionAmount - } - - if remainingToReduce > 0 { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient population to reduce. Still need to reduce: %.0f", remainingToReduce)) - } - - return nil -} - -func (s *transferLayingService) restoreProjectFlockPopulation(ctx context.Context, populationRepo ProjectFlockRepository.ProjectFlockPopulationRepository, projectFlockKandangID uint, quantityToRestore float64) error { - populations, err := populationRepo.GetByProjectFlockKandangID(ctx, projectFlockKandangID) - if err != nil { - return err - } - - if len(populations) == 0 { - return fiber.NewError(fiber.StatusBadRequest, "No populations found for restoration") - } - - if len(populations) > 0 { - lastPop := populations[len(populations)-1] - newQty := lastPop.TotalQty + quantityToRestore - if err := populationRepo.PatchOne(ctx, lastPop.Id, map[string]any{"total_qty": newQty}, nil); err != nil { - return err - } - } - - return nil -} - func (s transferLayingService) GetAvailableQtyPerKandang(ctx *fiber.Ctx, projectFlockID uint) (*entity.ProjectFlock, map[uint]float64, error) { pf, err := s.ProjectFlockRepo.GetByID(ctx.Context(), projectFlockID, func(db *gorm.DB) *gorm.DB { @@ -881,3 +827,27 @@ func (s transferLayingService) GetAvailableQtyPerKandang(ctx *fiber.Ctx, project return pf, kandangAvailableQty, nil } + +func (s *transferLayingService) validateKandangOwnership( + ctx context.Context, + projectFlockID uint, + kandangIDs []uint, +) error { + + for _, kandangID := range kandangIDs { + + projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(ctx, kandangID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Kandang %d tidak ditemukan", kandangID)) + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get project flock kandang") + } + + if projectFlockKandang.ProjectFlockId != projectFlockID { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Kandang %d tidak terhubung ke project flock %d", kandangID, projectFlockID)) + } + } + + return nil +} diff --git a/internal/modules/production/uniformities/controllers/uniformity.controller.go b/internal/modules/production/uniformities/controllers/uniformity.controller.go index e18e7dce..70372ece 100644 --- a/internal/modules/production/uniformities/controllers/uniformity.controller.go +++ b/internal/modules/production/uniformities/controllers/uniformity.controller.go @@ -5,6 +5,7 @@ import ( "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/dto" service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/services" + utypes "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/types" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/validations" "gitlab.com/mbugroup/lti-api.git/internal/response" @@ -40,6 +41,13 @@ func (u *UniformityController) GetAll(c *fiber.Ctx) error { if err != nil { return err } + var charts map[uint]utypes.UniformityChartData + if query.WithChart { + charts, err = u.UniformityService.MapCharts(c, result) + if err != nil { + return err + } + } return c.Status(fiber.StatusOK). JSON(response.SuccessWithPaginate[dto.UniformityListDTO]{ @@ -51,13 +59,9 @@ func (u *UniformityController) GetAll(c *fiber.Ctx) error { Limit: query.Limit, TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), TotalResults: totalResults, - Filters: fiber.Map{ - "location_id": "", - "project_flock_id": "", - "status": "Pengajuan", - }, + Filters: dto.BuildUniformityFilters(query), }, - Data: dto.ToUniformityListDTOsWithStandard(result, standards, documents), + Data: dto.ToUniformityListDTOsWithStandard(result, standards, documents, charts), }) } @@ -73,7 +77,7 @@ func (u *UniformityController) GetOne(c *fiber.Ctx) error { } withDetails := c.QueryBool("with_details", false) - calculation := service.UniformityCalculation{} + calculation := utypes.UniformityCalculation{} var document *entity.Document var documentURL string var meanWeight float64 @@ -87,7 +91,7 @@ func (u *UniformityController) GetOne(c *fiber.Ctx) error { return err } } else { - calculation = service.UniformityCalculation{ + calculation = utypes.UniformityCalculation{ ChickQtyOfWeight: result.ChickQtyOfWeight, MeanWeight: meanWeight, MeanDown: result.MeanDown, @@ -229,7 +233,7 @@ func (u *UniformityController) UpdateOne(c *fiber.Ctx) error { } } - calculation := service.UniformityCalculation{ + calculation := utypes.UniformityCalculation{ ChickQtyOfWeight: result.ChickQtyOfWeight, MeanWeight: math.Round(result.MeanUp / 1.10), MeanDown: result.MeanDown, diff --git a/internal/modules/production/uniformities/dto/uniformity.dto.go b/internal/modules/production/uniformities/dto/uniformity.dto.go index af401a54..3db819bf 100644 --- a/internal/modules/production/uniformities/dto/uniformity.dto.go +++ b/internal/modules/production/uniformities/dto/uniformity.dto.go @@ -5,7 +5,11 @@ import ( entity "gitlab.com/mbugroup/lti-api.git/internal/entities" approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" - service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/services" + utypes "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/types" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "github.com/gofiber/fiber/v2" ) type UniformitySamplingDTO struct { @@ -49,13 +53,13 @@ type UniformityInfoDTO struct { } type UniformityDetailDTO struct { - Id uint `json:"id"` - InfoUmum UniformityInfoDTO `json:"info_umum"` - Sampling UniformitySamplingDTO `json:"sampling"` - Result UniformityResultDTO `json:"result"` - Standard *UniformityStandardDTO `json:"standard"` + Id uint `json:"id"` + InfoUmum UniformityInfoDTO `json:"info_umum"` + Sampling UniformitySamplingDTO `json:"sampling"` + Result UniformityResultDTO `json:"result"` + Standard *UniformityStandardDTO `json:"standard"` LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"` - UniformityDetails []UniformityDetailItemDTO `json:"uniformity_details"` + UniformityDetails []UniformityDetailItemDTO `json:"uniformity_details"` } type UniformityListDTO struct { @@ -76,6 +80,7 @@ type UniformityListDTO struct { MeanDown float64 `json:"mean_down"` StandardMeanWeight *float64 `json:"standard_mean_weight"` StandardUniformity *float64 `json:"standard_uniformity"` + ChartData *utypes.UniformityChartData `json:"chart_data,omitempty"` CreatedBy uint `json:"created_by"` LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"` } @@ -87,7 +92,7 @@ func NewDocumentForResponse(name string) *entity.Document { return &entity.Document{Name: name} } -func ToUniformityVerificationDTO(calc service.UniformityCalculation) UniformityVerificationDTO { +func ToUniformityVerificationDTO(calc utypes.UniformityCalculation) UniformityVerificationDTO { return UniformityVerificationDTO{ Sampling: toUniformitySamplingDTO(calc), Result: toUniformityResultDTO(calc), @@ -97,7 +102,7 @@ func ToUniformityVerificationDTO(calc service.UniformityCalculation) UniformityV func ToUniformityDetailDTO( entityData entity.ProjectFlockKandangUniformity, - calc service.UniformityCalculation, + calc utypes.UniformityCalculation, document *entity.Document, documentURL string, standard *UniformityStandardDTO, @@ -171,8 +176,9 @@ func ToUniformityListDTOs(items []entity.ProjectFlockKandangUniformity) []Unifor func ToUniformityListDTOsWithStandard( items []entity.ProjectFlockKandangUniformity, - standards map[uint]service.UniformityStandard, + standards map[uint]utypes.UniformityStandard, documentNames map[uint]string, + charts map[uint]utypes.UniformityChartData, ) []UniformityListDTO { result := ToUniformityListDTOs(items) if len(result) == 0 || len(standards) == 0 { @@ -180,6 +186,10 @@ func ToUniformityListDTOsWithStandard( if name, ok := documentNames[result[i].Id]; ok { result[i].FileName = name } + if chart, ok := charts[result[i].Id]; ok { + chartCopy := chart + result[i].ChartData = &chartCopy + } } return result } @@ -192,11 +202,15 @@ func ToUniformityListDTOsWithStandard( if name, ok := documentNames[result[i].Id]; ok { result[i].FileName = name } + if chart, ok := charts[result[i].Id]; ok { + chartCopy := chart + result[i].ChartData = &chartCopy + } } return result } -func toUniformitySamplingDTO(calc service.UniformityCalculation) UniformitySamplingDTO { +func toUniformitySamplingDTO(calc utypes.UniformityCalculation) UniformitySamplingDTO { return UniformitySamplingDTO{ ChickQtyOfWeight: calc.ChickQtyOfWeight, MeanWeight: calc.MeanWeight, @@ -205,7 +219,7 @@ func toUniformitySamplingDTO(calc service.UniformityCalculation) UniformitySampl } } -func toUniformityResultDTO(calc service.UniformityCalculation) UniformityResultDTO { +func toUniformityResultDTO(calc utypes.UniformityCalculation) UniformityResultDTO { return UniformityResultDTO{ UniformQty: calc.UniformQty, OutsideQty: calc.OutsideQty, @@ -214,7 +228,7 @@ func toUniformityResultDTO(calc service.UniformityCalculation) UniformityResultD } } -func toUniformityDetailItemsDTO(calc service.UniformityCalculation) []UniformityDetailItemDTO { +func toUniformityDetailItemsDTO(calc utypes.UniformityCalculation) []UniformityDetailItemDTO { result := make([]UniformityDetailItemDTO, len(calc.Details)) for i, item := range calc.Details { result[i] = UniformityDetailItemDTO{ @@ -254,5 +268,18 @@ func formatUniformityDate(date *time.Time) string { if date == nil || date.IsZero() { return "" } - return date.Format("2006-01-02") + return utils.FormatDate(*date) +} + +func BuildUniformityFilters(query *validation.Query) fiber.Map { + if query == nil { + return fiber.Map{} + } + return fiber.Map{ + "project_flock_kandang_id": query.ProjectFlockKandangId, + "start_date": query.StartDate, + "end_date": query.EndDate, + "with_chart": query.WithChart, + "status": "Pengajuan", + } } diff --git a/internal/modules/production/uniformities/repositories/uniformity.repository.go b/internal/modules/production/uniformities/repositories/uniformity.repository.go index 241dea49..9641c650 100644 --- a/internal/modules/production/uniformities/repositories/uniformity.repository.go +++ b/internal/modules/production/uniformities/repositories/uniformity.repository.go @@ -2,14 +2,18 @@ package repository import ( "context" + "time" "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/validations" "gorm.io/gorm" ) type UniformityRepository interface { repository.BaseRepository[entity.ProjectFlockKandangUniformity] + GetAllWithFilters(ctx context.Context, offset, limit int, params *validation.Query) ([]entity.ProjectFlockKandangUniformity, int64, error) + WithDefaultRelations() func(*gorm.DB) *gorm.DB DeleteByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) error } @@ -23,6 +27,46 @@ func NewUniformityRepository(db *gorm.DB) UniformityRepository { } } +func (r *UniformityRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, params *validation.Query) ([]entity.ProjectFlockKandangUniformity, int64, error) { + return r.GetAll(ctx, offset, limit, func(db *gorm.DB) *gorm.DB { + return r.applyQueryFilters(r.WithDefaultRelations()(db), params) + }) +} + +func (r *UniformityRepositoryImpl) WithDefaultRelations() func(*gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + return db. + Preload("ProjectFlockKandang.ProjectFlock.Location"). + Preload("ProjectFlockKandang.Kandang.Location") + } +} + +func (r *UniformityRepositoryImpl) applyQueryFilters(db *gorm.DB, params *validation.Query) *gorm.DB { + if params == nil { + return db + } + + if params.ProjectFlockKandangId != 0 { + db = db.Where("project_flock_kandang_id = ?", params.ProjectFlockKandangId) + } + if params.Week != 0 { + db = db.Where("week = ?", params.Week) + } + + startDateValue, endDateValue, err := validation.ParseDateRange(params.StartDate, params.EndDate) + if err == nil { + if startDateValue != nil && endDateValue != nil { + db = db.Where("uniform_date >= ? AND uniform_date < ?", *startDateValue, endDateValue.Add(24*time.Hour)) + } else if startDateValue != nil { + db = db.Where("uniform_date >= ?", *startDateValue) + } else if endDateValue != nil { + db = db.Where("uniform_date < ?", endDateValue.Add(24*time.Hour)) + } + } + + return db.Order("uniform_date DESC").Order("id DESC") +} + func (r *UniformityRepositoryImpl) DeleteByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) error { if len(projectFlockKandangIDs) == 0 { return nil diff --git a/internal/modules/production/uniformities/services/uniformity.body_weight_excel.go b/internal/modules/production/uniformities/services/uniformity.body_weight_excel.go deleted file mode 100644 index 4e87f0cc..00000000 --- a/internal/modules/production/uniformities/services/uniformity.body_weight_excel.go +++ /dev/null @@ -1,200 +0,0 @@ -package service - -import ( - "io" - "mime/multipart" - "strconv" - "strings" - - "github.com/gofiber/fiber/v2" - "github.com/xuri/excelize/v2" -) - -type BodyWeightExcelRow struct { - No int `json:"no"` - Weight float64 `json:"weight"` - Range string `json:"range,omitempty"` -} - -func (s uniformityService) ParseBodyWeightExcel(_ *fiber.Ctx, file *multipart.FileHeader) ([]BodyWeightExcelRow, error) { - if file == nil { - return nil, fiber.NewError(fiber.StatusBadRequest, "file is required") - } - - reader, err := file.Open() - if err != nil { - return nil, fiber.NewError(fiber.StatusBadRequest, "failed to open file") - } - defer reader.Close() - - rows, err := parseBodyWeightExcelReader(reader) - if err != nil { - return nil, err - } - - return rows, nil -} - -func parseBodyWeightExcelReader(reader io.Reader) ([]BodyWeightExcelRow, error) { - xlsx, err := excelize.OpenReader(reader) - if err != nil { - return nil, fiber.NewError(fiber.StatusBadRequest, "failed to read excel file") - } - defer func() { - _ = xlsx.Close() - }() - - sheets := xlsx.GetSheetList() - if len(sheets) == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "no sheets found in file") - } - - sheetName := sheets[0] - if len(sheets) > 1 { - sheetName = sheets[1] - } - - rows, err := xlsx.GetRows(sheetName, excelize.Options{RawCellValue: true}) - if err != nil { - return nil, fiber.NewError(fiber.StatusBadRequest, "failed to read sheet rows") - } - - return parseBodyWeightRows(rows) -} - -func parseBodyWeightRows(rows [][]string) ([]BodyWeightExcelRow, error) { - headerRowIdx, noCol, bwCol, rangeCol := findBodyWeightHeader(rows) - if headerRowIdx < 0 || bwCol < 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "header BW not found") - } - - result := make([]BodyWeightExcelRow, 0) - lastNo := 0 - - for i := headerRowIdx + 1; i < len(rows); i++ { - row := rows[i] - weightStr := cellAt(row, bwCol) - weightVal, ok := parseNumber(weightStr) - if !ok { - continue - } - - noVal := 0 - if noCol >= 0 { - if parsed, ok := parseNumber(cellAt(row, noCol)); ok { - noVal = int(parsed) - } - } - if noVal <= 0 { - noVal = lastNo + 1 - } - if noVal > lastNo { - lastNo = noVal - } - - rangeVal := "" - if rangeCol >= 0 { - rangeVal = strings.TrimSpace(cellAt(row, rangeCol)) - } - - rowPayload := BodyWeightExcelRow{ - No: noVal, - Weight: weightVal, - Range: rangeVal, - } - if rowPayload.No <= 0 || rowPayload.Weight <= 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "invalid body weight row data") - } - - result = append(result, rowPayload) - } - - if len(result) == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "no body weight data found") - } - - return result, nil -} - -func findBodyWeightHeader(rows [][]string) (rowIdx int, noCol int, bwCol int, rangeCol int) { - rowIdx = -1 - noCol = -1 - bwCol = -1 - rangeCol = -1 - - for i, row := range rows { - tempNo := -1 - tempBW := -1 - tempRange := -1 - for j, cell := range row { - label := normalizeHeader(cell) - switch label { - case "no": - tempNo = j - case "bw": - tempBW = j - case "outsiderange": - tempRange = j - default: - if strings.HasPrefix(label, "bw") { - tempBW = j - } else if strings.HasPrefix(label, "no") { - tempNo = j - } else if strings.Contains(label, "range") { - tempRange = j - } - } - } - if tempBW >= 0 { - rowIdx = i - bwCol = tempBW - noCol = tempNo - rangeCol = tempRange - break - } - } - - return rowIdx, noCol, bwCol, rangeCol -} - -func cellAt(row []string, idx int) string { - if idx < 0 || idx >= len(row) { - return "" - } - return strings.TrimSpace(row[idx]) -} - -func normalizeHeader(value string) string { - trimmed := strings.ToLower(strings.TrimSpace(value)) - if trimmed == "" { - return "" - } - var b strings.Builder - for _, r := range trimmed { - if r >= 'a' && r <= 'z' { - b.WriteRune(r) - } - } - return b.String() -} - -func parseNumber(value string) (float64, bool) { - trimmed := strings.TrimSpace(value) - if trimmed == "" { - return 0, false - } - - if strings.Contains(trimmed, ",") { - if strings.Contains(trimmed, ".") { - trimmed = strings.ReplaceAll(trimmed, ",", "") - } else { - trimmed = strings.ReplaceAll(trimmed, ",", ".") - } - } - - parsed, err := strconv.ParseFloat(trimmed, 64) - if err != nil { - return 0, false - } - return parsed, true -} diff --git a/internal/modules/production/uniformities/services/uniformity.calculate.go b/internal/modules/production/uniformities/services/uniformity.calculate.go new file mode 100644 index 00000000..24eb107a --- /dev/null +++ b/internal/modules/production/uniformities/services/uniformity.calculate.go @@ -0,0 +1,433 @@ +package service + +import ( + "fmt" + "io" + "math" + "mime/multipart" + "strconv" + "strings" + + utypes "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/types" + + "github.com/gofiber/fiber/v2" + "github.com/xuri/excelize/v2" +) + +type BodyWeightExcelRow struct { + No int `json:"no"` + Weight float64 `json:"weight"` + Range string `json:"range,omitempty"` +} + +func (s uniformityService) ParseBodyWeightExcel(_ *fiber.Ctx, file *multipart.FileHeader) ([]BodyWeightExcelRow, error) { + if file == nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "file is required") + } + + reader, err := file.Open() + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "failed to open file") + } + defer reader.Close() + + rows, err := parseBodyWeightExcelReader(reader) + if err != nil { + return nil, err + } + + return rows, nil +} + +func parseBodyWeightExcelReader(reader io.Reader) ([]BodyWeightExcelRow, error) { + xlsx, err := excelize.OpenReader(reader) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "failed to read excel file") + } + defer func() { + _ = xlsx.Close() + }() + + sheets := xlsx.GetSheetList() + if len(sheets) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "no sheets found in file") + } + + sheetName := sheets[0] + if len(sheets) > 1 { + sheetName = sheets[1] + } + + rows, err := xlsx.GetRows(sheetName, excelize.Options{RawCellValue: true}) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "failed to read sheet rows") + } + + return parseBodyWeightRows(rows) +} + +func parseBodyWeightRows(rows [][]string) ([]BodyWeightExcelRow, error) { + headerRowIdx, noCol, bwCol, rangeCol := findBodyWeightHeader(rows) + if headerRowIdx < 0 || bwCol < 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "header BW not found") + } + + result := make([]BodyWeightExcelRow, 0) + lastNo := 0 + + for i := headerRowIdx + 1; i < len(rows); i++ { + row := rows[i] + weightStr := cellAt(row, bwCol) + weightVal, ok := parseNumber(weightStr) + if !ok { + continue + } + + noVal := 0 + if noCol >= 0 { + if parsed, ok := parseNumber(cellAt(row, noCol)); ok { + noVal = int(parsed) + } + } + if noVal <= 0 { + noVal = lastNo + 1 + } + if noVal > lastNo { + lastNo = noVal + } + + rangeVal := "" + if rangeCol >= 0 { + rangeVal = strings.TrimSpace(cellAt(row, rangeCol)) + } + + rowPayload := BodyWeightExcelRow{ + No: noVal, + Weight: weightVal, + Range: rangeVal, + } + if rowPayload.No <= 0 || rowPayload.Weight <= 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "invalid body weight row data") + } + + result = append(result, rowPayload) + } + + if len(result) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "no body weight data found") + } + + return result, nil +} + +func findBodyWeightHeader(rows [][]string) (rowIdx int, noCol int, bwCol int, rangeCol int) { + rowIdx = -1 + noCol = -1 + bwCol = -1 + rangeCol = -1 + + for i, row := range rows { + tempNo := -1 + tempBW := -1 + tempRange := -1 + for j, cell := range row { + label := normalizeHeader(cell) + switch label { + case "no": + tempNo = j + case "bw": + tempBW = j + case "outsiderange": + tempRange = j + default: + if strings.HasPrefix(label, "bw") { + tempBW = j + } else if strings.HasPrefix(label, "no") { + tempNo = j + } else if strings.Contains(label, "range") { + tempRange = j + } + } + } + if tempBW >= 0 { + rowIdx = i + bwCol = tempBW + noCol = tempNo + rangeCol = tempRange + break + } + } + + return rowIdx, noCol, bwCol, rangeCol +} + +func cellAt(row []string, idx int) string { + if idx < 0 || idx >= len(row) { + return "" + } + return strings.TrimSpace(row[idx]) +} + +func normalizeHeader(value string) string { + trimmed := strings.ToLower(strings.TrimSpace(value)) + if trimmed == "" { + return "" + } + var b strings.Builder + for _, r := range trimmed { + if r >= 'a' && r <= 'z' { + b.WriteRune(r) + } + } + return b.String() +} + +func parseNumber(value string) (float64, bool) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return 0, false + } + + if strings.Contains(trimmed, ",") { + if strings.Contains(trimmed, ".") { + trimmed = strings.ReplaceAll(trimmed, ",", "") + } else { + trimmed = strings.ReplaceAll(trimmed, ",", ".") + } + } + + parsed, err := strconv.ParseFloat(trimmed, 64) + if err != nil { + return 0, false + } + return parsed, true +} + +func computeUniformity(rows []BodyWeightExcelRow) (utypes.UniformityCalculation, error) { + weights := make([]float64, 0, len(rows)) + details := make([]utypes.UniformityDetailItem, 0, len(rows)) + hasRangeLabels := false + for idx, row := range rows { + if row.Weight <= 0 { + continue + } + id := row.No + if id <= 0 { + id = idx + 1 + } + weights = append(weights, row.Weight) + rangeLabel := strings.TrimSpace(row.Range) + if rangeLabel != "" { + upper := strings.ToUpper(rangeLabel) + if upper == "HIGH" || upper == "LOW" { + hasRangeLabels = true + } + rangeLabel = upper + } + details = append(details, utypes.UniformityDetailItem{ + Id: id, + Weight: row.Weight, + Range: rangeLabel, + }) + } + + total := float64(len(weights)) + if total == 0 { + return utypes.UniformityCalculation{}, fiber.NewError(fiber.StatusBadRequest, "no body weight data found") + } + + var sum float64 + for _, w := range weights { + sum += w + } + mean := sum / total + meanUpThreshold := roundToPrecision(mean*1.10, 3) + meanDownThreshold := roundToPrecision(mean*0.90, 3) + + var uniformCount float64 + for i := range details { + if hasRangeLabels { + if details[i].Range == "HIGH" || details[i].Range == "LOW" { + details[i].Range = "Outside" + continue + } + details[i].Range = "Ideal" + uniformCount++ + continue + } + + if details[i].Weight > meanUpThreshold || details[i].Weight < meanDownThreshold { + details[i].Range = "Outside" + continue + } + details[i].Range = "Ideal" + uniformCount++ + } + + var deviationSum float64 + for _, w := range weights { + deviation := w - mean + deviationSum += deviation * deviation + } + stdDev := math.Sqrt(deviationSum / total) + + cv := 0.0 + if mean != 0 { + cv = (stdDev / mean) * 100 + } + + outsideCount := total - uniformCount + uniformity := 0.0 + if total > 0 { + uniformity = (uniformCount / total) * 100 + } + + return utypes.UniformityCalculation{ + ChickQtyOfWeight: total, + MeanWeight: roundToPrecision(mean, 0), + MeanDown: roundToPrecision(meanDownThreshold, 0), + MeanUp: roundToPrecision(meanUpThreshold, 0), + UniformQty: uniformCount, + OutsideQty: outsideCount, + Uniformity: roundToPrecision(uniformity, 0), + Cv: roundToPrecision(cv, 1), + Details: details, + }, nil +} + +func extractWeights(rows []BodyWeightExcelRow) []float64 { + weights := make([]float64, 0, len(rows)) + for _, row := range rows { + if row.Weight <= 0 { + continue + } + weights = append(weights, row.Weight) + } + return weights +} + +func buildChartWeekSummary(weights []float64) utypes.UniformityChartWeek { + if len(weights) == 0 { + return utypes.UniformityChartWeek{ + HasData: false, + WeightDistribution: []utypes.UniformityChartRange{}, + } + } + + minWeight := weights[0] + maxWeight := weights[0] + var sum float64 + for _, w := range weights { + sum += w + if w < minWeight { + minWeight = w + } + if w > maxWeight { + maxWeight = w + } + } + mean := sum / float64(len(weights)) + + idealMin := roundToPrecision(mean*0.90, 0) + idealMax := roundToPrecision(mean*1.10, 0) + idealCount := 0.0 + for _, w := range weights { + if w >= idealMin && w <= idealMax { + idealCount++ + } + } + + const bucketSize = 5.0 + start := math.Floor(minWeight/bucketSize) * bucketSize + end := math.Floor(maxWeight/bucketSize) * bucketSize + + distribution := make([]utypes.UniformityChartRange, 0) + for bucket := start; bucket <= end; bucket += bucketSize { + minBucket := bucket + maxBucket := bucket + bucketSize - 1 + count := 0.0 + bucketWeights := make([]float64, 0) + idealWeights := make([]float64, 0) + outsideWeights := make([]float64, 0) + for _, w := range weights { + if w >= minBucket && w < minBucket+bucketSize { + count++ + bucketWeights = append(bucketWeights, w) + if w >= idealMin && w <= idealMax { + idealWeights = append(idealWeights, w) + } else { + outsideWeights = append(outsideWeights, w) + } + } + } + idealRangeLabel := rangeFromValues(idealWeights) + outsideRangeLabel := rangeFromValues(outsideWeights) + isIdealRange := idealRangeLabel != "" + distribution = append(distribution, utypes.UniformityChartRange{ + Range: fmt.Sprintf("%d-%d", int(minBucket), int(maxBucket)), + MinWeight: minBucket, + MaxWeight: maxBucket, + BirdCount: count, + IsIdealRange: isIdealRange, + IdealRange: idealRangeLabel, + OutsideRange: outsideRangeLabel, + }) + } + + statistics := &utypes.UniformityChartStatistics{ + MinWeight: roundToPrecision(minWeight, 0), + MaxWeight: roundToPrecision(maxWeight, 0), + AverageWeight: roundToPrecision(mean, 1), + TotalBirdsMeasured: float64(len(weights)), + } + + return utypes.UniformityChartWeek{ + HasData: true, + WeightDistribution: distribution, + IdealRange: &utypes.UniformityChartIdealRange{ + MinWeight: idealMin, + MaxWeight: idealMax, + TotalIdealBirds: idealCount, + }, + Statistics: statistics, + } +} + +func roundToPrecision(value float64, precision int) float64 { + if precision < 0 { + return value + } + scale := math.Pow10(precision) + scaled := value * scale + fraction := scaled - math.Floor(scaled) + if fraction >= 0.5 { + return math.Ceil(scaled) / scale + } + return math.Floor(scaled) / scale +} + +func rangeFromValues(values []float64) string { + if len(values) == 0 { + return "" + } + minValue := values[0] + maxValue := values[0] + for _, v := range values[1:] { + if v < minValue { + minValue = v + } + if v > maxValue { + maxValue = v + } + } + return formatRange(minValue, maxValue) +} + +func formatRange(minValue, maxValue float64) string { + minInt := int(math.Round(minValue)) + maxInt := int(math.Round(maxValue)) + if minInt == maxInt { + return fmt.Sprintf("%d", minInt) + } + return fmt.Sprintf("%d-%d", minInt, maxInt) +} \ No newline at end of file diff --git a/internal/modules/production/uniformities/services/uniformity.service.go b/internal/modules/production/uniformities/services/uniformity.service.go index 747eb965..92db84a3 100644 --- a/internal/modules/production/uniformities/services/uniformity.service.go +++ b/internal/modules/production/uniformities/services/uniformity.service.go @@ -2,11 +2,12 @@ package service import ( "context" + "encoding/json" "errors" "fmt" - "math" "mime/multipart" "net/http" + "sort" "strings" "time" @@ -17,6 +18,7 @@ import ( rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/repositories" + utypes "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/types" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" @@ -31,17 +33,18 @@ type UniformityService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlockKandangUniformity, int64, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectFlockKandangUniformity, error) GetSummary(ctx *fiber.Ctx, id uint) (*entity.ProjectFlockKandangUniformity, error) - GetStandard(ctx *fiber.Ctx, uniformity *entity.ProjectFlockKandangUniformity) (*UniformityStandard, error) - MapStandards(ctx *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]UniformityStandard, error) + GetStandard(ctx *fiber.Ctx, uniformity *entity.ProjectFlockKandangUniformity) (*utypes.UniformityStandard, error) + MapStandards(ctx *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]utypes.UniformityStandard, error) + MapCharts(ctx *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]utypes.UniformityChartData, error) MapDocuments(ctx *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]string, error) CreateOne(ctx *fiber.Ctx, req *validation.Create, file *multipart.FileHeader, rows []BodyWeightExcelRow) (*entity.ProjectFlockKandangUniformity, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint, file *multipart.FileHeader, rows []BodyWeightExcelRow) (*entity.ProjectFlockKandangUniformity, error) DeleteOne(ctx *fiber.Ctx, id uint) error Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlockKandangUniformity, error) ParseBodyWeightExcel(ctx *fiber.Ctx, file *multipart.FileHeader) ([]BodyWeightExcelRow, error) - ComputeUniformity(rows []BodyWeightExcelRow) (UniformityCalculation, error) + ComputeUniformity(rows []BodyWeightExcelRow) (utypes.UniformityCalculation, error) GetDocumentInfo(ctx *fiber.Ctx, uniformityID uint) (*entity.Document, string, error) - CalculateUniformityFromDocument(ctx *fiber.Ctx, uniformityID uint) (UniformityCalculation, *entity.Document, string, error) + CalculateUniformityFromDocument(ctx *fiber.Ctx, uniformityID uint) (utypes.UniformityCalculation, *entity.Document, string, error) } type uniformityService struct { @@ -79,29 +82,13 @@ func NewUniformityService( } } -func (s uniformityService) withRelations(db *gorm.DB) *gorm.DB { - return db. - Preload("ProjectFlockKandang.ProjectFlock.Location"). - Preload("ProjectFlockKandang.Kandang.Location") -} - func (s uniformityService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlockKandangUniformity, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err } offset := (params.Page - 1) * params.Limit - - uniformitys, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { - db = s.withRelations(db) - if params.ProjectFlockKandangId != 0 { - db = db.Where("project_flock_kandang_id = ?", params.ProjectFlockKandangId) - } - if params.Week != 0 { - db = db.Where("week = ?", params.Week) - } - return db.Order("uniform_date DESC").Order("id DESC") - }) + uniformitys, total, err := s.Repository.GetAllWithFilters(c.Context(), offset, params.Limit, params) if err != nil { s.Log.Errorf("Failed to get uniformitys: %+v", err) @@ -114,7 +101,7 @@ func (s uniformityService) GetAll(c *fiber.Ctx, params *validation.Query) ([]ent } func (s uniformityService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlockKandangUniformity, error) { - uniformity, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + uniformity, err := s.Repository.GetByID(c.Context(), id, s.Repository.WithDefaultRelations()) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Uniformity not found") } @@ -132,14 +119,14 @@ func (s uniformityService) GetSummary(c *fiber.Ctx, id uint) (*entity.ProjectFlo return s.GetOne(c, id) } -func (s uniformityService) GetStandard(c *fiber.Ctx, uniformity *entity.ProjectFlockKandangUniformity) (*UniformityStandard, error) { +func (s uniformityService) GetStandard(c *fiber.Ctx, uniformity *entity.ProjectFlockKandangUniformity) (*utypes.UniformityStandard, error) { if uniformity == nil { return nil, nil } return s.resolveUniformityStandard(c.Context(), *uniformity) } -func (s uniformityService) MapStandards(c *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]UniformityStandard, error) { +func (s uniformityService) MapStandards(c *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]utypes.UniformityStandard, error) { if len(items) == 0 { return nil, nil } @@ -149,7 +136,7 @@ func (s uniformityService) MapStandards(c *fiber.Ctx, items []entity.ProjectFloc categoryStandard := make(map[string]*entity.ProductionStandard) detailCache := make(map[uint]map[int]entity.StandardGrowthDetail) - result := make(map[uint]UniformityStandard, len(items)) + result := make(map[uint]utypes.UniformityStandard, len(items)) for _, item := range items { if item.Id == 0 { @@ -180,7 +167,7 @@ func (s uniformityService) MapStandards(c *fiber.Ctx, items []entity.ProjectFloc if !ok { continue } - standardDTO := UniformityStandard{ + standardDTO := utypes.UniformityStandard{ MeanWeight: cloneFloat64(detail.TargetMeanBw), Uniformity: float64Ptr(detail.MinUniformity), } @@ -190,6 +177,128 @@ func (s uniformityService) MapStandards(c *fiber.Ctx, items []entity.ProjectFloc return result, nil } +func (s uniformityService) MapCharts(c *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]utypes.UniformityChartData, error) { + if len(items) == 0 { + return nil, nil + } + + grouped := make(map[uint][]entity.ProjectFlockKandangUniformity) + for _, item := range items { + if item.ProjectFlockKandangId == 0 { + continue + } + grouped[item.ProjectFlockKandangId] = append(grouped[item.ProjectFlockKandangId], item) + } + + if len(grouped) == 0 { + return nil, nil + } + + result := make(map[uint]utypes.UniformityChartData, len(items)) + + for _, group := range grouped { + allWeeks := make(map[int]utypes.UniformityChartWeek) + weekOrder := make([]int, 0, len(group)) + weeksWithData := 0 + gaugeWeeks := make([]utypes.UniformityChartGaugeWeek, 0, len(group)) + latestByWeek := make(map[int]entity.ProjectFlockKandangUniformity) + + for _, item := range group { + if item.Week == 0 { + continue + } + if existing, ok := latestByWeek[item.Week]; !ok || isUniformityNewer(item, existing) { + latestByWeek[item.Week] = item + } + } + + for week := range latestByWeek { + weekOrder = append(weekOrder, week) + } + sort.Ints(weekOrder) + + for _, week := range weekOrder { + item := latestByWeek[week] + var weekSummary utypes.UniformityChartWeek + if len(item.ChartData) > 0 { + if err := json.Unmarshal(item.ChartData, &weekSummary); err != nil { + return nil, err + } + } + if weekSummary.WeightDistribution == nil { + weekSummary.WeightDistribution = []utypes.UniformityChartRange{} + } + if !weekSummary.HasData && item.ChickQtyOfWeight > 0 { + weekSummary.HasData = true + } + if weekSummary.HasData { + weeksWithData++ + } + allWeeks[week] = weekSummary + + hasData := item.ChickQtyOfWeight > 0 + gaugeWeeks = append(gaugeWeeks, utypes.UniformityChartGaugeWeek{ + Week: week, + UniformityPercent: item.Uniformity, + IdealCount: item.UniformQty, + OutsideIdealCount: item.NotUniformQty, + TotalCount: item.ChickQtyOfWeight, + HasData: hasData, + }) + } + + weekIndex := make(map[int]int, len(weekOrder)) + for idx, week := range weekOrder { + weekIndex[week] = idx + } + + totalWeeks := len(weekOrder) + for _, item := range group { + if item.Id == 0 || item.Week == 0 { + continue + } + currentIndex := weekIndex[item.Week] + chart := utypes.UniformityChartData{ + BarChart: utypes.UniformityChartBar{ + CurrentWeek: item.Week, + AllWeeks: allWeeks, + }, + GaugeChart: utypes.UniformityChartGauge{ + CurrentWeek: item.Week, + AvailableWeeks: gaugeWeeks, + WeekInfo: utypes.UniformityChartWeekInfo{ + TotalWeeks: totalWeeks, + WeeksWithData: weeksWithData, + CurrentWeekIndex: currentIndex, + HasPrevWeek: currentIndex > 0, + HasNextWeek: currentIndex < totalWeeks-1, + }, + }, + } + result[item.Id] = chart + } + } + + return result, nil +} + +func isUniformityNewer(a, b entity.ProjectFlockKandangUniformity) bool { + var aDate, bDate time.Time + if a.UniformDate != nil { + aDate = *a.UniformDate + } + if b.UniformDate != nil { + bDate = *b.UniformDate + } + if !aDate.IsZero() || !bDate.IsZero() { + if aDate.Equal(bDate) { + return a.Id > b.Id + } + return aDate.After(bDate) + } + return a.Id > b.Id +} + func (s uniformityService) MapDocuments(c *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]string, error) { if s.DocumentSvc == nil || len(items) == 0 { return map[uint]string{}, nil @@ -252,6 +361,11 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file if err != nil { return nil, err } + chartSummary := buildChartWeekSummary(extractWeights(rows)) + chartJSON, err := json.Marshal(chartSummary) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "failed to build chart data") + } actorID, err := m.ActorIDFromContext(c) if err != nil { return nil, err @@ -267,6 +381,7 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file ProjectFlockKandangId: req.ProjectFlockKandangId, UniformQty: calculation.UniformQty, NotUniformQty: calculation.OutsideQty, + ChartData: chartJSON, UniformDate: &uniformDate, CreatedBy: actorID, } @@ -307,7 +422,12 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file }, }) if err != nil { - s.rollbackUniformityCreate(c.Context(), createBody.Id) + if errDelete := s.ApprovalRepo.DeleteByTarget(c.Context(), utils.ApprovalWorkflowUniformity.String(), createBody.Id); errDelete != nil { + s.Log.WithError(errDelete).Warnf("Failed to rollback uniformity approvals for %d", createBody.Id) + } + if errDelete := s.Repository.DeleteOne(c.Context(), createBody.Id); errDelete != nil { + s.Log.WithError(errDelete).Warnf("Failed to rollback uniformity %d", createBody.Id) + } return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to upload uniformity document") } } @@ -391,6 +511,11 @@ func (s uniformityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id ui if err != nil { return nil, err } + chartSummary := buildChartWeekSummary(extractWeights(rows)) + chartJSON, err := json.Marshal(chartSummary) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "failed to build chart data") + } updateBody["uniformity"] = calculation.Uniformity updateBody["cv"] = calculation.Cv @@ -399,6 +524,7 @@ func (s uniformityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id ui updateBody["mean_down"] = calculation.MeanDown updateBody["uniform_qty"] = calculation.UniformQty updateBody["not_uniform_qty"] = calculation.OutsideQty + updateBody["chart_data"] = chartJSON } if len(updateBody) == 0 { @@ -479,13 +605,13 @@ func (s uniformityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id ui } func (s *uniformityService) ensureUniqueUniformity(ctx context.Context, id uint, projectFlockKandangID uint, week int, uniformDate *time.Time) error { - if projectFlockKandangID == 0 || week == 0 || uniformDate == nil || uniformDate.IsZero() { + if projectFlockKandangID == 0 || week == 0 { return nil } query := s.Repository.DB().WithContext(ctx). Model(&entity.ProjectFlockKandangUniformity{}). - Where("project_flock_kandang_id = ? AND week = ? AND uniform_date = ?", projectFlockKandangID, week, *uniformDate) + Where("project_flock_kandang_id = ? AND week = ?", projectFlockKandangID, week) if id != 0 { query = query.Where("id <> ?", id) } @@ -495,7 +621,7 @@ func (s *uniformityService) ensureUniqueUniformity(ctx context.Context, id uint, return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate uniformity uniqueness") } if count > 0 { - return fiber.NewError(fiber.StatusConflict, "Uniformity already exists for the same project flock kandang, week, and date") + return fiber.NewError(fiber.StatusConflict, "Uniformity already exists for the same project flock kandang and week") } return nil } @@ -590,30 +716,7 @@ func (s uniformityService) Approval(c *fiber.Ctx, req *validation.Approve) ([]en return results, nil } -type UniformityDetailItem struct { - Id int - Weight float64 - Range string -} - -type UniformityCalculation struct { - ChickQtyOfWeight float64 - MeanWeight float64 - MeanDown float64 - MeanUp float64 - UniformQty float64 - OutsideQty float64 - Uniformity float64 - Cv float64 - Details []UniformityDetailItem -} - -type UniformityStandard struct { - MeanWeight *float64 - Uniformity *float64 -} - -func (s uniformityService) ComputeUniformity(rows []BodyWeightExcelRow) (UniformityCalculation, error) { +func (s uniformityService) ComputeUniformity(rows []BodyWeightExcelRow) (utypes.UniformityCalculation, error) { return computeUniformity(rows) } @@ -621,37 +724,37 @@ func (s uniformityService) GetDocumentInfo(c *fiber.Ctx, uniformityID uint) (*en return s.fetchUniformityDocument(c.Context(), uniformityID, true) } -func (s uniformityService) CalculateUniformityFromDocument(c *fiber.Ctx, uniformityID uint) (UniformityCalculation, *entity.Document, string, error) { +func (s uniformityService) CalculateUniformityFromDocument(c *fiber.Ctx, uniformityID uint) (utypes.UniformityCalculation, *entity.Document, string, error) { document, url, err := s.fetchUniformityDocument(c.Context(), uniformityID, false) if err != nil { - return UniformityCalculation{}, nil, "", err + return utypes.UniformityCalculation{}, nil, "", err } if document == nil || url == "" { - return UniformityCalculation{}, nil, "", fiber.NewError(fiber.StatusNotFound, "Uniformity document not found") + return utypes.UniformityCalculation{}, nil, "", fiber.NewError(fiber.StatusNotFound, "Uniformity document not found") } req, err := http.NewRequestWithContext(c.Context(), http.MethodGet, url, nil) if err != nil { - return UniformityCalculation{}, nil, "", err + return utypes.UniformityCalculation{}, nil, "", err } resp, err := http.DefaultClient.Do(req) if err != nil { - return UniformityCalculation{}, nil, "", err + return utypes.UniformityCalculation{}, nil, "", err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return UniformityCalculation{}, nil, "", fiber.NewError(fiber.StatusBadRequest, "Failed to download uniformity document") + return utypes.UniformityCalculation{}, nil, "", fiber.NewError(fiber.StatusBadRequest, "Failed to download uniformity document") } rows, err := parseBodyWeightExcelReader(resp.Body) if err != nil { - return UniformityCalculation{}, nil, "", err + return utypes.UniformityCalculation{}, nil, "", err } calculation, err := computeUniformity(rows) if err != nil { - return UniformityCalculation{}, nil, "", err + return utypes.UniformityCalculation{}, nil, "", err } return calculation, document, url, nil @@ -783,7 +886,7 @@ func (s *uniformityService) attachLatestApproval(ctx context.Context, item *enti return nil } -func (s *uniformityService) resolveUniformityStandard(ctx context.Context, item entity.ProjectFlockKandangUniformity) (*UniformityStandard, error) { +func (s *uniformityService) resolveUniformityStandard(ctx context.Context, item entity.ProjectFlockKandangUniformity) (*utypes.UniformityStandard, error) { if s.ProductionStandardRepo == nil || s.StandardGrowthDetailRepo == nil { return nil, nil } @@ -801,7 +904,7 @@ func (s *uniformityService) resolveUniformityStandard(ctx context.Context, item return nil, err } - return &UniformityStandard{ + return &utypes.UniformityStandard{ MeanWeight: cloneFloat64(detail.TargetMeanBw), Uniformity: float64Ptr(detail.MinUniformity), }, nil @@ -858,22 +961,6 @@ func float64Ptr(value float64) *float64 { return © } -func (s *uniformityService) rollbackUniformityCreate(ctx context.Context, uniformityID uint) { - if uniformityID == 0 { - return - } - - if s.ApprovalRepo != nil { - if err := s.ApprovalRepo.DeleteByTarget(ctx, utils.ApprovalWorkflowUniformity.String(), uniformityID); err != nil { - s.Log.WithError(err).Warnf("Failed to rollback uniformity approvals for %d", uniformityID) - } - } - - if err := s.Repository.DeleteOne(ctx, uniformityID); err != nil { - s.Log.WithError(err).Warnf("Failed to rollback uniformity %d", uniformityID) - } -} - func uniqueUintSlice(values []uint) []uint { if len(values) == 0 { return nil @@ -893,114 +980,3 @@ func uniqueUintSlice(values []uint) []uint { } return result } - -func computeUniformity(rows []BodyWeightExcelRow) (UniformityCalculation, error) { - weights := make([]float64, 0, len(rows)) - details := make([]UniformityDetailItem, 0, len(rows)) - hasRangeLabels := false - for idx, row := range rows { - if row.Weight <= 0 { - continue - } - id := row.No - if id <= 0 { - id = idx + 1 - } - weights = append(weights, row.Weight) - rangeLabel := strings.TrimSpace(row.Range) - if rangeLabel != "" { - upper := strings.ToUpper(rangeLabel) - if upper == "HIGH" || upper == "LOW" { - hasRangeLabels = true - } - rangeLabel = upper - } - details = append(details, UniformityDetailItem{ - Id: id, - Weight: row.Weight, - Range: rangeLabel, - }) - } - - total := float64(len(weights)) - if total == 0 { - return UniformityCalculation{}, fiber.NewError(fiber.StatusBadRequest, "no body weight data found") - } - - var sum float64 - for _, w := range weights { - sum += w - } - mean := sum / total - meanUpThreshold := roundToPrecision(mean*1.10, 3) - meanDownThreshold := roundToPrecision(mean*0.90, 3) - - var uniformCount float64 - for i := range details { - if hasRangeLabels { - if details[i].Range == "HIGH" || details[i].Range == "LOW" { - details[i].Range = "Outside" - continue - } - details[i].Range = "Ideal" - uniformCount++ - continue - } - - w := details[i].Weight - if w > meanUpThreshold || w < meanDownThreshold { - details[i].Range = "Outside" - continue - } - details[i].Range = "Ideal" - uniformCount++ - } - outsideCount := total - uniformCount - - var cv float64 - if mean > 0 && total > 1 { - stddevWeights := weights - stddevCount := float64(len(stddevWeights)) - if stddevCount > 1 { - var stddevSum float64 - for _, w := range stddevWeights { - stddevSum += w - } - stddevMean := stddevSum / stddevCount - var sumSquares float64 - for _, w := range stddevWeights { - diff := w - stddevMean - sumSquares += diff * diff - } - stddev := math.Sqrt(sumSquares / (stddevCount - 1)) - cv = (stddev / mean) * 100 - } - } - - uniformity := (uniformCount / total) * 100 - - return UniformityCalculation{ - ChickQtyOfWeight: total, - MeanWeight: roundToPrecision(mean, 0), - MeanDown: roundToPrecision(mean*0.90, 0), - MeanUp: roundToPrecision(mean*1.10, 0), - UniformQty: uniformCount, - OutsideQty: outsideCount, - Uniformity: roundToPrecision(uniformity, 0), - Cv: roundToPrecision(cv, 1), - Details: details, - }, nil -} - -func roundToPrecision(value float64, precision int) float64 { - if precision < 0 { - return value - } - scale := math.Pow10(precision) - scaled := value * scale - fraction := scaled - math.Floor(scaled) - if fraction >= 0.5 { - return math.Ceil(scaled) / scale - } - return math.Floor(scaled) / scale -} diff --git a/internal/modules/production/uniformities/types/uniformity.types.go b/internal/modules/production/uniformities/types/uniformity.types.go new file mode 100644 index 00000000..877795f3 --- /dev/null +++ b/internal/modules/production/uniformities/types/uniformity.types.go @@ -0,0 +1,87 @@ +package types + +type UniformityDetailItem struct { + Id int + Weight float64 + Range string +} + +type UniformityCalculation struct { + ChickQtyOfWeight float64 + MeanWeight float64 + MeanDown float64 + MeanUp float64 + UniformQty float64 + OutsideQty float64 + Uniformity float64 + Cv float64 + Details []UniformityDetailItem +} + +type UniformityStandard struct { + MeanWeight *float64 + Uniformity *float64 +} + +type UniformityChartRange struct { + Range string `json:"range"` + MinWeight float64 `json:"min_weight"` + MaxWeight float64 `json:"max_weight"` + BirdCount float64 `json:"bird_count"` + IsIdealRange bool `json:"is_ideal_range"` + IdealRange string `json:"ideal_range,omitempty"` + OutsideRange string `json:"outside_range,omitempty"` +} + +type UniformityChartIdealRange struct { + MinWeight float64 `json:"min_weight"` + MaxWeight float64 `json:"max_weight"` + TotalIdealBirds float64 `json:"total_ideal_birds"` +} + +type UniformityChartStatistics struct { + MinWeight float64 `json:"min_weight"` + MaxWeight float64 `json:"max_weight"` + AverageWeight float64 `json:"average_weight"` + TotalBirdsMeasured float64 `json:"total_birds_measured"` +} + +type UniformityChartWeek struct { + HasData bool `json:"has_data"` + WeightDistribution []UniformityChartRange `json:"weight_distribution"` + IdealRange *UniformityChartIdealRange `json:"ideal_range"` + Statistics *UniformityChartStatistics `json:"statistics"` +} + +type UniformityChartBar struct { + CurrentWeek int `json:"current_week"` + AllWeeks map[int]UniformityChartWeek `json:"all_weeks"` +} + +type UniformityChartGaugeWeek struct { + Week int `json:"week"` + UniformityPercent float64 `json:"uniformity_percentage"` + IdealCount float64 `json:"ideal_count"` + OutsideIdealCount float64 `json:"outside_ideal_count"` + TotalCount float64 `json:"total_count"` + HasData bool `json:"has_data"` +} + +type UniformityChartWeekInfo struct { + TotalWeeks int `json:"total_weeks"` + WeeksWithData int `json:"weeks_with_data"` + CurrentWeekIndex int `json:"current_week_index"` + HasPrevWeek bool `json:"has_prev_week"` + HasNextWeek bool `json:"has_next_week"` +} + +type UniformityChartGauge struct { + CurrentWeek int `json:"current_week"` + AvailableWeeks []UniformityChartGaugeWeek `json:"available_weeks"` + WeekInfo UniformityChartWeekInfo `json:"week_info"` +} + +type UniformityChartData struct { + BarChart UniformityChartBar `json:"bar_chart"` + GaugeChart UniformityChartGauge `json:"gauge_chart"` +} \ No newline at end of file diff --git a/internal/modules/production/uniformities/validations/uniformity.validation.go b/internal/modules/production/uniformities/validations/uniformity.validation.go index b2aeaf26..e4f7f8a0 100644 --- a/internal/modules/production/uniformities/validations/uniformity.validation.go +++ b/internal/modules/production/uniformities/validations/uniformity.validation.go @@ -4,6 +4,7 @@ import ( "mime/multipart" "strconv" "strings" + "time" "github.com/gofiber/fiber/v2" ) @@ -21,10 +22,13 @@ type Update struct { } type Query struct { - Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` - ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"` - Week int `query:"week" validate:"omitempty,min=1"` + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"` + Week int `query:"week" validate:"omitempty,min=1"` + StartDate string `query:"start_date" validate:"omitempty"` + EndDate string `query:"end_date" validate:"omitempty"` + WithChart bool `query:"with_chart"` } type UploadExcelRequest struct { @@ -37,6 +41,8 @@ type Approve struct { Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` } +const maxUniformityUploadBytes = 5 * 1024 * 1024 + func ParseIDParam(c *fiber.Ctx, name string) (uint, error) { raw := strings.TrimSpace(c.Params(name)) if raw == "" { @@ -55,15 +61,49 @@ func ParseQuery(c *fiber.Ctx) (*Query, error) { Limit: c.QueryInt("limit", 10), ProjectFlockKandangId: uint(c.QueryInt("project_flock_kandang_id", 0)), Week: c.QueryInt("week", 0), + StartDate: strings.TrimSpace(c.Query("start_date")), + EndDate: strings.TrimSpace(c.Query("end_date")), + WithChart: c.QueryBool("with_chart", false), } if query.Page < 1 || query.Limit < 1 { return nil, fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") } + if _, _, err := ParseDateRange(query.StartDate, query.EndDate); err != nil { + return nil, err + } + return query, nil } +func ParseDateRange(startDate, endDate string) (*time.Time, *time.Time, error) { + var startDateValue *time.Time + var endDateValue *time.Time + + if startDate != "" { + parsed, err := time.Parse("2006-01-02", startDate) + if err != nil { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "start_date must be in YYYY-MM-DD format") + } + startDateValue = &parsed + } + if endDate != "" { + parsed, err := time.Parse("2006-01-02", endDate) + if err != nil { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "end_date must be in YYYY-MM-DD format") + } + endDateValue = &parsed + } + if startDateValue != nil && endDateValue != nil { + if endDateValue.Before(*startDateValue) { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "end_date must be greater than or equal to start_date") + } + } + + return startDateValue, endDateValue, nil +} + func ParseCreate(c *fiber.Ctx) (*Create, *multipart.FileHeader, error) { date := strings.TrimSpace(c.FormValue("date")) if date == "" { @@ -94,6 +134,9 @@ func ParseCreate(c *fiber.Ctx) (*Create, *multipart.FileHeader, error) { if err != nil { return nil, nil, fiber.NewError(fiber.StatusBadRequest, "document is required") } + if err := validateUniformityFileSize(file); err != nil { + return nil, nil, err + } return &Create{ Date: date, @@ -134,6 +177,8 @@ func ParseUpdate(c *fiber.Ctx) (*Update, *multipart.FileHeader, error) { file, err := c.FormFile("document") if err != nil { file = nil + } else if err := validateUniformityFileSize(file); err != nil { + return nil, nil, err } return req, file, nil @@ -151,6 +196,9 @@ func ParseUploadFiles(c *fiber.Ctx) ([]*multipart.FileHeader, error) { if err != nil || file == nil { return nil, fiber.NewError(fiber.StatusBadRequest, "document is required") } + if err := validateUniformityFileSize(file); err != nil { + return nil, err + } return []*multipart.FileHeader{file}, nil } @@ -162,3 +210,10 @@ func ParseApprove(c *fiber.Ctx) (*Approve, error) { } return req, nil } + +func validateUniformityFileSize(file *multipart.FileHeader) error { + if file != nil && file.Size > maxUniformityUploadBytes { + return fiber.NewError(fiber.StatusRequestEntityTooLarge, "Document size must be <= 5MB") + } + return nil +} diff --git a/internal/modules/purchases/controllers/purchase.controller.go b/internal/modules/purchases/controllers/purchase.controller.go index 977b4ac1..c4291619 100644 --- a/internal/modules/purchases/controllers/purchase.controller.go +++ b/internal/modules/purchases/controllers/purchase.controller.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "math" + "mime/multipart" "strconv" "strings" @@ -15,6 +16,8 @@ import ( "github.com/gofiber/fiber/v2" ) +const maxPurchaseUploadBytes = 5 * 1024 * 1024 + type PurchaseController struct { service service.PurchaseService } @@ -184,6 +187,9 @@ func (ctrl *PurchaseController) ReceiveProducts(c *fiber.Ctx) error { if len(req.TravelDocuments) == 0 { req.TravelDocuments = form.File["documents"] } + if err := validatePurchaseDocumentSizes(req.TravelDocuments); err != nil { + return err + } result, err := ctrl.service.ReceiveProducts(c, uint(id), req) if err != nil { return err @@ -198,6 +204,15 @@ func (ctrl *PurchaseController) ReceiveProducts(c *fiber.Ctx) error { }) } +func validatePurchaseDocumentSizes(files []*multipart.FileHeader) error { + for _, file := range files { + if file != nil && file.Size > maxPurchaseUploadBytes { + return fiber.NewError(fiber.StatusRequestEntityTooLarge, "Document size must be <= 5MB") + } + } + return nil +} + func (ctrl *PurchaseController) DeleteItems(c *fiber.Ctx) error { param := c.Params("id") id, err := strconv.Atoi(param) diff --git a/internal/modules/purchases/module.go b/internal/modules/purchases/module.go index 7e80de38..fae714fb 100644 --- a/internal/modules/purchases/module.go +++ b/internal/modules/purchases/module.go @@ -75,7 +75,7 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate fifoService := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log) _ = fifoService.RegisterStockable(fifo.StockableConfig{ - Key: fifo.StockableKey("PURCHASE_ITEMS"), + Key: fifo.StockableKeyPurchaseItems, Table: "purchase_items", Columns: fifo.StockableColumns{ ID: "id", diff --git a/internal/modules/purchases/repositories/purchase.repository.go b/internal/modules/purchases/repositories/purchase.repository.go index fc599877..2cb0ba75 100644 --- a/internal/modules/purchases/repositories/purchase.repository.go +++ b/internal/modules/purchases/repositories/purchase.repository.go @@ -25,6 +25,7 @@ type PurchaseRepository interface { NextPrNumber(ctx context.Context, tx *gorm.DB) (string, error) NextPoNumber(ctx context.Context, tx *gorm.DB) (string, error) BackfillProjectFlockKandang(ctx context.Context, purchaseID uint) error + SoftDeleteByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) error GetItemsByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.PurchaseItem, error) GetItemsByWarehouseKandang(ctx context.Context, projectFlockID uint) ([]entity.PurchaseItem, error) } @@ -89,6 +90,44 @@ WHERE pi.purchase_id = ? return r.DB().WithContext(ctx).Exec(query, purchaseID).Error } +func (r *PurchaseRepositoryImpl) SoftDeleteByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) error { + if len(projectFlockKandangIDs) == 0 { + return nil + } + + return r.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var purchaseIDs []uint + query := ` +SELECT pi.purchase_id +FROM purchase_items pi +WHERE pi.project_flock_kandang_id IN (?) +GROUP BY pi.purchase_id +HAVING COUNT(*) = COUNT(CASE WHEN pi.project_flock_kandang_id IN (?) THEN 1 END) +` + if err := tx.Raw(query, projectFlockKandangIDs, projectFlockKandangIDs).Scan(&purchaseIDs).Error; err != nil { + return err + } + + now := time.Now().UTC() + if len(purchaseIDs) > 0 { + if err := tx.Model(&entity.Purchase{}). + Where("id IN (?) AND deleted_at IS NULL", purchaseIDs). + Update("deleted_at", now).Error; err != nil { + return err + } + if err := tx.Where("purchase_id IN (?)", purchaseIDs).Delete(&entity.PurchaseItem{}).Error; err != nil { + return err + } + } + + deleteItems := tx.Where("project_flock_kandang_id IN (?)", projectFlockKandangIDs) + if len(purchaseIDs) > 0 { + deleteItems = deleteItems.Where("purchase_id NOT IN (?)", purchaseIDs) + } + return deleteItems.Delete(&entity.PurchaseItem{}).Error + }) +} + func (r *PurchaseRepositoryImpl) CreateItems(ctx context.Context, purchaseID uint, items []*entity.PurchaseItem) error { if len(items) == 0 { return nil @@ -246,7 +285,7 @@ func (r *PurchaseRepositoryImpl) generateSequentialNumber(ctx context.Context, t var values []string err := db.WithContext(ctx). Model(&entity.Purchase{}). - Where(fmt.Sprintf("%s LIKE ?", column), prefix+"%"). + Where(fmt.Sprintf("%s ILIKE ?", column), prefix+"%"). Select(column). Order(fmt.Sprintf("%s DESC", column)). Limit(20). diff --git a/internal/modules/purchases/route.go b/internal/modules/purchases/route.go index 0fe038c3..ed0c74f1 100644 --- a/internal/modules/purchases/route.go +++ b/internal/modules/purchases/route.go @@ -15,12 +15,12 @@ func Routes(router fiber.Router, purchaseService service.PurchaseService, userSe route := router.Group("/purchases") route.Use(m.Auth(userService)) - route.Get("/",m.RequirePermissions(m.P_PurchaseGetAll), ctrl.GetAll) - route.Get("/:id",m.RequirePermissions(m.P_PurchaseGetOne), 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) + route.Get("/", m.RequirePermissions(m.P_PurchaseGetAll), ctrl.GetAll) + route.Get("/:id", m.RequirePermissions(m.P_PurchaseGetOne), ctrl.GetOne) + route.Post("/", m.RequirePermissions(m.P_PurchaseCreateOne), ctrl.CreateOne) + route.Post("/:id/approvals/staff", m.RequirePermissions(m.P_PurchaseApprovalStaff), ctrl.ApproveStaffPurchase) + route.Post("/:id/approvals/manager", m.RequirePermissions(m.P_PurchaseApprovalManager), ctrl.ApproveManagerPurchase) + route.Post("/:id/receipts", m.RequirePermissions(m.P_PurchaseReceive), ctrl.ReceiveProducts) + route.Delete("/:id", m.RequirePermissions(m.P_PurchaseDeleteOne), ctrl.DeletePurchase) + route.Delete("/:id/items", m.RequirePermissions(m.P_PurchaseItemDeleteOne), ctrl.DeleteItems) } diff --git a/internal/modules/purchases/services/expense_bridge.go b/internal/modules/purchases/services/expense_bridge.go index 6c74a1fc..23b95c58 100644 --- a/internal/modules/purchases/services/expense_bridge.go +++ b/internal/modules/purchases/services/expense_bridge.go @@ -594,7 +594,7 @@ func (b *expenseBridge) createExpenseViaService( req := &expenseValidation.Create{ PoNumber: "", TransactionDate: utils.FormatDate(expenseDate), - Category: "BOP", + Category: string(utils.ExpenseCategoryBOP), SupplierID: uint64(supplierID), LocationID: locationID, ExpenseNonstocks: []expenseValidation.ExpenseNonstock{{ diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index 68b21d6a..35ca2f75 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -42,8 +42,7 @@ type PurchaseService interface { } const ( - priceTolerance = 0.0001 - purchaseStockableKey = fifo.StockableKey("PURCHASE_ITEMS") + priceTolerance = 0.0001 ) type purchaseService struct { @@ -134,6 +133,7 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti purchases, total, err := s.PurchaseRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) + db = db.Where("purchases.deleted_at IS NULL") if params.SupplierID > 0 { db = db.Where("supplier_id = ?", params.SupplierID) @@ -924,7 +924,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation continue } if _, err := s.FifoSvc.Replenish(c.Context(), commonSvc.StockReplenishRequest{ - StockableKey: purchaseStockableKey, + StockableKey: fifo.StockableKeyPurchaseItems, StockableID: adj.itemID, ProductWarehouseID: adj.pwID, Quantity: adj.qty, diff --git a/internal/modules/repports/controllers/repport.controller.go b/internal/modules/repports/controllers/repport.controller.go index 39136e85..22ff4acf 100644 --- a/internal/modules/repports/controllers/repport.controller.go +++ b/internal/modules/repports/controllers/repport.controller.go @@ -3,6 +3,7 @@ package controller import ( "math" "strconv" + "strings" "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto" service "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/services" @@ -164,6 +165,59 @@ func (c *RepportController) GetPurchaseSupplier(ctx *fiber.Ctx) error { }) } +func (c *RepportController) GetDebtSupplier(ctx *fiber.Ctx) error { + supplierIDs, err := parseCommaSeparatedInt64s(ctx.Query("supplier_ids", "")) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + query := &validation.DebtSupplierQuery{ + Page: ctx.QueryInt("page", 1), + Limit: ctx.QueryInt("limit", 10), + SupplierIDs: supplierIDs, + StartDate: ctx.Query("start_date", ""), + EndDate: ctx.Query("end_date", ""), + FilterBy: ctx.Query("filter_by", ""), + SortOrder: ctx.Query("sort_order", ""), + } + + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + result, totalResults, err := c.RepportService.GetDebtSupplier(ctx, query) + if err != nil { + return err + } + + supplierIDs = query.SupplierIDs + if supplierIDs == nil { + supplierIDs = []int64{} + } + + filters := map[string]interface{}{ + "start_date": query.StartDate, + "end_date": query.EndDate, + "supplier_ids": supplierIDs, + "filter_by": query.FilterBy, + } + + return ctx.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.DebtSupplierDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get supplier debt recap successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + Filters: filters, + }, + Data: result, + }) +} + func (c *RepportController) GetHppPerKandang(ctx *fiber.Ctx) error { data, meta, err := c.RepportService.GetHppPerKandang(ctx) if err != nil { @@ -227,3 +281,27 @@ func (c *RepportController) GetProductionResult(ctx *fiber.Ctx) error { Data: data, }) } + +func parseCommaSeparatedInt64s(raw string) ([]int64, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return []int64{}, nil + } + + parts := strings.Split(raw, ",") + result := make([]int64, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + id, err := strconv.ParseInt(part, 10, 64) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "supplier_ids must be comma separated integers") + } + result = append(result, id) + } + + return result, nil +} diff --git a/internal/modules/repports/dto/repportDebtSupplier.dto.go b/internal/modules/repports/dto/repportDebtSupplier.dto.go new file mode 100644 index 00000000..5dce055f --- /dev/null +++ b/internal/modules/repports/dto/repportDebtSupplier.dto.go @@ -0,0 +1,37 @@ +package dto + +import ( + areaDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/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 DebtSupplierRowDTO struct { + PrNumber string `json:"pr_number"` + PoNumber string `json:"po_number"` + PrDate string `json:"pr_date"` + PoDate string `json:"po_date"` + Aging int `json:"aging"` + Area *areaDTO.AreaRelationDTO `json:"area,omitempty"` + Warehouse *warehouseDTO.WarehouseRelationDTO `json:"warehouse,omitempty"` + DueDate string `json:"due_date"` + DueStatus string `json:"due_status"` + TotalPrice float64 `json:"total_price"` + PaymentPrice float64 `json:"payment_price"` + DebtPrice float64 `json:"debt_price"` + Status string `json:"status"` + TravelNumber string `json:"travel_number"` +} + +type DebtSupplierTotalDTO struct { + Aging int `json:"aging"` + TotalPrice float64 `json:"total_price"` + PaymentPrice float64 `json:"payment_price"` + DebtPrice float64 `json:"debt_price"` +} + +type DebtSupplierDTO struct { + Supplier *supplierDTO.SupplierRelationDTO `json:"supplier"` + Rows []DebtSupplierRowDTO `json:"rows"` + Total DebtSupplierTotalDTO `json:"total"` +} diff --git a/internal/modules/repports/module.go b/internal/modules/repports/module.go index 40a3c0f3..61f37d4d 100644 --- a/internal/modules/repports/module.go +++ b/internal/modules/repports/module.go @@ -31,12 +31,13 @@ func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * recordingRepository := recordingRepo.NewRecordingRepository(db) approvalRepository := commonRepo.NewApprovalRepository(db) purchaseSupplierRepository := repportRepo.NewPurchaseSupplierRepository(db) + debtSupplierRepository := repportRepo.NewDebtSupplierRepository(db) hppPerKandangRepository := repportRepo.NewHppPerKandangRepository(db) productionResultRepository := repportRepo.NewProductionResultRepository(db) userRepository := rUser.NewUserRepository(db) approvalSvc := approvalService.NewApprovalService(approvalRepository) - repportService := sRepport.NewRepportService(validate, expenseRealizationRepository, marketingDeliveryProductRepository, purchaseRepository, chickinRepository, recordingRepository, approvalSvc, purchaseSupplierRepository, hppPerKandangRepository, productionResultRepository) + repportService := sRepport.NewRepportService(validate, expenseRealizationRepository, marketingDeliveryProductRepository, purchaseRepository, chickinRepository, recordingRepository, approvalSvc, purchaseSupplierRepository, debtSupplierRepository, hppPerKandangRepository, productionResultRepository) userService := sUser.NewUserService(userRepository, validate) RepportRoutes(router, userService, repportService) diff --git a/internal/modules/repports/repositories/debt_supplier.repository.go b/internal/modules/repports/repositories/debt_supplier.repository.go new file mode 100644 index 00000000..84e9402d --- /dev/null +++ b/internal/modules/repports/repositories/debt_supplier.repository.go @@ -0,0 +1,221 @@ +package repositories + +import ( + "context" + "fmt" + "strings" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "gorm.io/gorm" +) + +type DebtSupplierRepository interface { + GetSuppliersWithPurchases(ctx context.Context, offset, limit int, filters *validation.DebtSupplierQuery) ([]entity.Supplier, int64, error) + GetPurchasesBySuppliers(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) ([]entity.Purchase, error) + GetPaymentTotalsByReferences(ctx context.Context, supplierIDs []uint, references []string) (map[string]float64, error) +} + +type debtSupplierRepositoryImpl struct { + db *gorm.DB +} + +func NewDebtSupplierRepository(db *gorm.DB) DebtSupplierRepository { + return &debtSupplierRepositoryImpl{db: db} +} + +func resolveDebtSupplierDateColumn(filterBy string) string { + switch strings.ToLower(strings.TrimSpace(filterBy)) { + case "po_date": + return "purchases.po_date" + case "pr_date": + return "purchases.created_at" + case "do_date", "received_date", "": + return "purchase_items.received_date" + default: + return "purchase_items.received_date" + } +} + +func (r *debtSupplierRepositoryImpl) baseSupplierQuery(ctx context.Context, filters *validation.DebtSupplierQuery) *gorm.DB { + dateColumn := resolveDebtSupplierDateColumn(filters.FilterBy) + + db := r.db.WithContext(ctx). + Model(&entity.Supplier{}). + Joins("JOIN purchases ON purchases.supplier_id = suppliers.id"). + Joins("JOIN purchase_items ON purchase_items.purchase_id = purchases.id") + + if len(filters.SupplierIDs) > 0 { + db = db.Where("suppliers.id IN ?", filters.SupplierIDs) + } + + if filters.StartDate != "" { + if dateFrom, err := utils.ParseDateString(filters.StartDate); err == nil { + db = db.Where(fmt.Sprintf("DATE(%s) >= ?", dateColumn), dateFrom) + } + } + + if filters.EndDate != "" { + if dateTo, err := utils.ParseDateString(filters.EndDate); err == nil { + db = db.Where(fmt.Sprintf("DATE(%s) <= ?", dateColumn), dateTo) + } + } + + return db +} + +func (r *debtSupplierRepositoryImpl) GetSuppliersWithPurchases(ctx context.Context, offset, limit int, filters *validation.DebtSupplierQuery) ([]entity.Supplier, int64, error) { + query := r.baseSupplierQuery(ctx, filters) + + var totalSuppliers int64 + if err := query. + Distinct("suppliers.id"). + Count(&totalSuppliers).Error; err != nil { + return nil, 0, err + } + + if totalSuppliers == 0 { + return []entity.Supplier{}, 0, nil + } + + if offset < 0 { + offset = 0 + } + + var supplierIDs []uint + if err := query. + Select("suppliers.id"). + Order("suppliers.id ASC"). + Offset(offset). + Limit(limit). + Pluck("suppliers.id", &supplierIDs).Error; err != nil { + return nil, 0, err + } + + if len(supplierIDs) == 0 { + return []entity.Supplier{}, totalSuppliers, nil + } + + var suppliers []entity.Supplier + if err := r.db.WithContext(ctx). + Where("id IN ?", supplierIDs). + Find(&suppliers).Error; err != nil { + return nil, 0, err + } + + return suppliers, totalSuppliers, nil +} + +func (r *debtSupplierRepositoryImpl) GetPurchasesBySuppliers(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) ([]entity.Purchase, error) { + if len(supplierIDs) == 0 { + return []entity.Purchase{}, nil + } + + purchaseIDs, err := r.getPurchaseIDs(ctx, supplierIDs, filters) + if err != nil { + return nil, err + } + if len(purchaseIDs) == 0 { + return []entity.Purchase{}, nil + } + + preloadItems := func(db *gorm.DB) *gorm.DB { + db = db. + Preload("Warehouse"). + Preload("Warehouse.Area"). + Order("purchase_items.id ASC") + + if strings.EqualFold(strings.TrimSpace(filters.FilterBy), "do_date") || strings.EqualFold(strings.TrimSpace(filters.FilterBy), "received_date") || strings.TrimSpace(filters.FilterBy) == "" { + if filters.StartDate != "" { + if dateFrom, err := utils.ParseDateString(filters.StartDate); err == nil { + db = db.Where("DATE(purchase_items.received_date) >= ?", dateFrom) + } + } + if filters.EndDate != "" { + if dateTo, err := utils.ParseDateString(filters.EndDate); err == nil { + db = db.Where("DATE(purchase_items.received_date) <= ?", dateTo) + } + } + } + + return db + } + + var purchases []entity.Purchase + if err := r.db.WithContext(ctx). + Model(&entity.Purchase{}). + Preload("Supplier"). + Preload("Items", preloadItems). + Where("purchases.id IN ?", purchaseIDs). + Order("purchases.id ASC"). + Find(&purchases).Error; err != nil { + return nil, err + } + + return purchases, nil +} + +func (r *debtSupplierRepositoryImpl) getPurchaseIDs(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) ([]uint, error) { + dateColumn := resolveDebtSupplierDateColumn(filters.FilterBy) + + db := r.db.WithContext(ctx). + Table("purchases"). + Select("DISTINCT purchases.id"). + Joins("JOIN purchase_items ON purchase_items.purchase_id = purchases.id"). + Where("purchases.supplier_id IN ?", supplierIDs) + + if filters.StartDate != "" { + if dateFrom, err := utils.ParseDateString(filters.StartDate); err == nil { + db = db.Where(fmt.Sprintf("DATE(%s) >= ?", dateColumn), dateFrom) + } + } + + if filters.EndDate != "" { + if dateTo, err := utils.ParseDateString(filters.EndDate); err == nil { + db = db.Where(fmt.Sprintf("DATE(%s) <= ?", dateColumn), dateTo) + } + } + + var purchaseIDs []uint + if err := db.Order("purchases.id ASC").Pluck("purchases.id", &purchaseIDs).Error; err != nil { + return nil, err + } + + return purchaseIDs, nil +} + +func (r *debtSupplierRepositoryImpl) GetPaymentTotalsByReferences(ctx context.Context, supplierIDs []uint, references []string) (map[string]float64, error) { + if len(supplierIDs) == 0 || len(references) == 0 { + return map[string]float64{}, nil + } + + type paymentRow struct { + ReferenceNumber *string `gorm:"column:reference_number"` + Total float64 `gorm:"column:total"` + } + + rows := make([]paymentRow, 0) + if err := r.db.WithContext(ctx). + Model(&entity.Payment{}). + Select("reference_number, SUM(nominal) AS total"). + Where("party_type = ?", string(utils.PaymentPartySupplier)). + Where("direction = ?", "OUT"). + Where("party_id IN ?", supplierIDs). + Where("reference_number IN ?", references). + Group("reference_number"). + Scan(&rows).Error; err != nil { + return nil, err + } + + result := make(map[string]float64, len(rows)) + for _, row := range rows { + if row.ReferenceNumber == nil || strings.TrimSpace(*row.ReferenceNumber) == "" { + continue + } + result[*row.ReferenceNumber] = row.Total + } + + return result, nil +} diff --git a/internal/modules/repports/repositories/hpp_per_kandang.repository.go b/internal/modules/repports/repositories/hpp_per_kandang.repository.go index 7e1c8143..4bd9aab4 100644 --- a/internal/modules/repports/repositories/hpp_per_kandang.repository.go +++ b/internal/modules/repports/repositories/hpp_per_kandang.repository.go @@ -106,22 +106,22 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, Where("r.deleted_at IS NULL") recordingPfk = applyLocationFilters(recordingPfk, areaIDs, locationIDs, kandangIDs) - purchaseStockableKey := fifo.StockableKey("PURCHASE_ITEMS").String() - transferStockableKey := fifo.StockableKey("STOCK_TRANSFER_DETAILS").String() + purchaseStockableKey := fifo.StockableKeyPurchaseItems.String() + transferStockableKey := fifo.StockableKeyStockTransferIn.String() query := r.db.WithContext(ctx). Table("recordings AS r"). Select(` k.id AS kandang_id, - COALESCE(SUM(CASE + COALESCE(SUM(CASE WHEN f.name = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0) WHEN sa.stockable_type = ? AND tf.name = ? THEN COALESCE(std.quantity, 0) * COALESCE(tpi.price, 0) - ELSE 0 + ELSE 0 END), 0) AS feed_cost, - COALESCE(SUM(CASE + COALESCE(SUM(CASE WHEN f.name = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0) WHEN sa.stockable_type = ? AND tf.name = ? THEN COALESCE(std.quantity, 0) * COALESCE(tpi.price, 0) - ELSE 0 + ELSE 0 END), 0) AS ovk_cost`, utils.FlagPakan, transferStockableKey, utils.FlagPakan, utils.FlagOVK, transferStockableKey, utils.FlagOVK). diff --git a/internal/modules/repports/route.go b/internal/modules/repports/route.go index 0da9adb2..0a0cf8a3 100644 --- a/internal/modules/repports/route.go +++ b/internal/modules/repports/route.go @@ -18,7 +18,8 @@ func RepportRoutes(v1 fiber.Router, u user.UserService, s repport.RepportService route.Get("/expense", m.RequirePermissions(m.P_ReportExpenseGetAll), ctrl.GetExpense) route.Get("/marketing", m.RequirePermissions(m.P_ReportDeliveryGetAll), ctrl.GetMarketing) route.Get("/purchase-supplier", m.RequirePermissions(m.P_ReportPurchaseSupplierGetAll), ctrl.GetPurchaseSupplier) - route.Get("/hpp-per-kandang", ctrl.GetHppPerKandang) - route.Get("/production-result/:idProjectFlockKandang", ctrl.GetProductionResult) + route.Get("/debt-supplier", m.RequirePermissions(m.P_ReportDebtSupplierGetAll), ctrl.GetDebtSupplier) + route.Get("/hpp-per-kandang", m.RequirePermissions(m.P_ReportHppPerKandangGetAll), ctrl.GetHppPerKandang) + route.Get("/production-result/:idProjectFlockKandang", m.RequirePermissions(m.P_ReportProductionResultGetAll), ctrl.GetProductionResult) } diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index ebf68867..c7576e5f 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -18,6 +18,9 @@ import ( approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" + areaDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/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" chickinRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" purchaseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" @@ -34,6 +37,7 @@ type RepportService interface { GetExpense(ctx *fiber.Ctx, params *validation.ExpenseQuery) ([]dto.RepportExpenseListDTO, int64, error) GetMarketing(ctx *fiber.Ctx, params *validation.MarketingQuery) ([]dto.RepportMarketingItemDTO, int64, error) GetPurchaseSupplier(ctx *fiber.Ctx, params *validation.PurchaseSupplierQuery) ([]dto.PurchaseSupplierDTO, int64, error) + GetDebtSupplier(ctx *fiber.Ctx, params *validation.DebtSupplierQuery) ([]dto.DebtSupplierDTO, int64, error) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error) GetProductionResult(ctx *fiber.Ctx, params *validation.ProductionResultQuery) ([]dto.ProductionResultDTO, int64, error) } @@ -48,6 +52,7 @@ type repportService struct { RecordingRepo recordingRepo.RecordingRepository ApprovalSvc approvalService.ApprovalService PurchaseSupplierRepo repportRepo.PurchaseSupplierRepository + DebtSupplierRepo repportRepo.DebtSupplierRepository HppPerKandangRepo repportRepo.HppPerKandangRepository ProductionResultRepo repportRepo.ProductionResultRepository } @@ -70,6 +75,7 @@ func NewRepportService( recordingRepo recordingRepo.RecordingRepository, approvalSvc approvalService.ApprovalService, purchaseSupplierRepo repportRepo.PurchaseSupplierRepository, + debtSupplierRepo repportRepo.DebtSupplierRepository, hppPerKandangRepo repportRepo.HppPerKandangRepository, productionResultRepo repportRepo.ProductionResultRepository, ) RepportService { @@ -83,6 +89,7 @@ func NewRepportService( RecordingRepo: recordingRepo, ApprovalSvc: approvalSvc, PurchaseSupplierRepo: purchaseSupplierRepo, + DebtSupplierRepo: debtSupplierRepo, HppPerKandangRepo: hppPerKandangRepo, ProductionResultRepo: productionResultRepo, } @@ -634,6 +641,200 @@ func (s *repportService) GetPurchaseSupplier(c *fiber.Ctx, params *validation.Pu return result, totalSuppliers, nil } +func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSupplierQuery) ([]dto.DebtSupplierDTO, int64, error) { + if params.FilterBy == "" { + params.FilterBy = "do_date" + } + + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + if offset < 0 { + offset = 0 + } + + suppliers, totalSuppliers, err := s.DebtSupplierRepo.GetSuppliersWithPurchases(c.Context(), offset, params.Limit, params) + if err != nil { + return nil, 0, err + } + if totalSuppliers == 0 || len(suppliers) == 0 { + return []dto.DebtSupplierDTO{}, totalSuppliers, nil + } + + supplierMap := make(map[uint]entity.Supplier, len(suppliers)) + supplierIDs := make([]uint, 0, len(suppliers)) + for _, supplier := range suppliers { + supplierMap[supplier.Id] = supplier + supplierIDs = append(supplierIDs, supplier.Id) + } + + purchases, err := s.DebtSupplierRepo.GetPurchasesBySuppliers(c.Context(), supplierIDs, params) + if err != nil { + return nil, 0, err + } + + purchasesBySupplier := make(map[uint][]entity.Purchase, len(supplierIDs)) + references := make([]string, 0) + seenRefs := make(map[string]struct{}) + for _, purchase := range purchases { + supplierID := purchase.SupplierId + purchasesBySupplier[supplierID] = append(purchasesBySupplier[supplierID], purchase) + + reference := purchase.PrNumber + if purchase.PoNumber != nil && strings.TrimSpace(*purchase.PoNumber) != "" { + reference = *purchase.PoNumber + } + if _, exists := seenRefs[reference]; !exists { + seenRefs[reference] = struct{}{} + references = append(references, reference) + } + } + + paymentTotals, err := s.DebtSupplierRepo.GetPaymentTotalsByReferences(c.Context(), supplierIDs, references) + if err != nil { + return nil, 0, err + } + + location, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "failed to load timezone configuration") + } + now := time.Now().In(location) + + result := make([]dto.DebtSupplierDTO, 0, len(supplierIDs)) + for _, supplierID := range supplierIDs { + supplier, exists := supplierMap[supplierID] + if !exists { + continue + } + + items := purchasesBySupplier[supplierID] + rows := make([]dto.DebtSupplierRowDTO, 0, len(items)) + total := dto.DebtSupplierTotalDTO{} + + for _, purchase := range items { + row := buildDebtSupplierRow(purchase, paymentTotals, now, location) + rows = append(rows, row) + + if row.Aging > total.Aging { + total.Aging = row.Aging + } + total.TotalPrice += row.TotalPrice + total.PaymentPrice += row.PaymentPrice + total.DebtPrice += row.DebtPrice + } + + sortDesc := strings.EqualFold(params.SortOrder, "desc") + sort.SliceStable(rows, func(i, j int) bool { + if sortDesc { + return rows[i].PrDate > rows[j].PrDate + } + return rows[i].PrDate < rows[j].PrDate + }) + + var supplierDTORef *supplierDTO.SupplierRelationDTO + if supplier.Id != 0 { + mapped := supplierDTO.ToSupplierRelationDTO(supplier) + supplierDTORef = &mapped + } + + result = append(result, dto.DebtSupplierDTO{ + Supplier: supplierDTORef, + Rows: rows, + Total: total, + }) + } + + return result, totalSuppliers, nil +} + +func buildDebtSupplierRow(purchase entity.Purchase, paymentTotals map[string]float64, now time.Time, loc *time.Location) dto.DebtSupplierRowDTO { + prNumber := purchase.PrNumber + poNumber := "" + if purchase.PoNumber != nil { + poNumber = *purchase.PoNumber + } + + reference := prNumber + if strings.TrimSpace(poNumber) != "" { + reference = poNumber + } + + prDate := purchase.CreatedAt.In(loc) + startDate := time.Date(prDate.Year(), prDate.Month(), prDate.Day(), 0, 0, 0, 0, loc) + endDate := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) + aging := int(endDate.Sub(startDate).Hours() / 24) + + totalPrice := 0.0 + travelNumber := "-" + var area *areaDTO.AreaRelationDTO + var warehouse *warehouseDTO.WarehouseRelationDTO + + if len(purchase.Items) > 0 { + firstItem := purchase.Items[0] + if firstItem.TravelNumber != nil && strings.TrimSpace(*firstItem.TravelNumber) != "" { + travelNumber = *firstItem.TravelNumber + } + + if firstItem.Warehouse != nil && firstItem.Warehouse.Id != 0 { + mappedWarehouse := warehouseDTO.ToWarehouseRelationDTO(*firstItem.Warehouse) + warehouse = &mappedWarehouse + if firstItem.Warehouse.Area.Id != 0 { + mappedArea := areaDTO.ToAreaRelationDTO(firstItem.Warehouse.Area) + area = &mappedArea + } + } + + for _, item := range purchase.Items { + totalPrice += item.TotalPrice + } + } + + paymentPrice := paymentTotals[reference] + debtPrice := paymentPrice - totalPrice + + dueDate := "" + dueStatus := "-" + if purchase.DueDate != nil && !purchase.DueDate.IsZero() { + due := purchase.DueDate.In(loc) + dueDate = due.Format("2006-01-02") + if now.After(due) { + dueStatus = "Sudah Jatuh Tempo" + } else { + dueStatus = "Mendekati Jatuh Tempo" + } + } + + status := "Belum Lunas" + if debtPrice >= 0 { + status = "Lunas" + } + + poDate := "" + if purchase.PoDate != nil && !purchase.PoDate.IsZero() { + poDate = purchase.PoDate.In(loc).Format("2006-01-02") + } + + return dto.DebtSupplierRowDTO{ + PrNumber: prNumber, + PoNumber: poNumber, + PrDate: prDate.Format("2006-01-02"), + PoDate: poDate, + Aging: aging, + Area: area, + Warehouse: warehouse, + DueDate: dueDate, + DueStatus: dueStatus, + TotalPrice: totalPrice, + PaymentPrice: paymentPrice, + DebtPrice: debtPrice, + Status: status, + TravelNumber: travelNumber, + } +} + func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error) { params, filters, err := s.parseHppPerKandangQuery(ctx) if err != nil { diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index b909d77c..6c80275f 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -43,6 +43,16 @@ type PurchaseSupplierQuery struct { FilterBy string `query:"filter_by" validate:"omitempty"` } +type DebtSupplierQuery struct { + Page int `query:"page" validate:"omitempty,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,min=1,gt=0"` + SupplierIDs []int64 `query:"-" validate:"omitempty,dive,gt=0"` + StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"` + EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"` + FilterBy string `query:"filter_by" validate:"omitempty,oneof=do_date po_date pr_date"` + SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` +} + type HppPerKandangQuery struct { Page int `query:"page" validate:"omitempty,min=1,gt=0"` Limit int `query:"limit" validate:"omitempty,min=1,max=100,gt=0"` diff --git a/internal/modules/users/services/user.service.go b/internal/modules/users/services/user.service.go index 3b28197e..1e101793 100644 --- a/internal/modules/users/services/user.service.go +++ b/internal/modules/users/services/user.service.go @@ -45,7 +45,7 @@ func (s userService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Us users, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { if params.Search != "" { - return db.Where("name LIKE ?", "%"+params.Search+"%") + return db.Where("name ILIKE ?", "%"+params.Search+"%") } return db.Order("created_at DESC").Order("updated_at DESC") }) diff --git a/internal/response/response.go b/internal/response/response.go index 710d320e..a6bc087f 100644 --- a/internal/response/response.go +++ b/internal/response/response.go @@ -29,6 +29,14 @@ type SuccessWithPaginate[T any] struct { Data []T `json:"data"` } +type SuccessWithMeta struct { + Code int `json:"code"` + Status string `json:"status"` + Message string `json:"message"` + Meta Meta `json:"meta"` + Data interface{} `json:"data"` +} + type ErrorDetails struct { Code int `json:"code"` Status string `json:"status"` diff --git a/internal/route/route.go b/internal/route/route.go index 519ea5aa..71682d2b 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -22,6 +22,7 @@ import ( repports "gitlab.com/mbugroup/lti-api.git/internal/modules/repports" ssoModule "gitlab.com/mbugroup/lti-api.git/internal/modules/sso" users "gitlab.com/mbugroup/lti-api.git/internal/modules/users" + dashboards "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards" // MODULE IMPORTS ) @@ -48,6 +49,7 @@ func Routes(app *fiber.App, db *gorm.DB) { repports.RepportModule{}, finance.FinanceModule{}, dailyChecklists.DailyChecklistModule{}, + dashboards.DashboardModule{}, // MODULE REGISTRY } diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 6ec50447..35ce3132 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -432,6 +432,8 @@ const ( DocumentableTypeExpense DocumentableType = "EXPENSE" DocumentableTypeExpenseRealization DocumentableType = "EXPENSE_REALIZATION" DocumentableTypePurchaseItem DocumentableType = "PURCHASE_ITEM" + + DocumentTypeDailyChecklist DocumentType = "DAILY_CHECKLIST_DOCUMENT" ) // ------------------------------------------------------------------- diff --git a/internal/utils/error.go b/internal/utils/error.go index ead06aeb..66537c00 100644 --- a/internal/utils/error.go +++ b/internal/utils/error.go @@ -2,11 +2,14 @@ package utils import ( "errors" + "strings" "gitlab.com/mbugroup/lti-api.git/internal/common/validation" "gitlab.com/mbugroup/lti-api.git/internal/response" "github.com/gofiber/fiber/v2" + "github.com/jackc/pgconn" + pgconnv5 "github.com/jackc/pgx/v5/pgconn" ) func ErrorHandler(c *fiber.Ctx, err error) error { @@ -14,6 +17,10 @@ func ErrorHandler(c *fiber.Ctx, err error) error { return response.Error(c, fiber.StatusBadRequest, message, nil) } + if statusCode, message := mapPgError(err); statusCode != 0 { + return response.Error(c, statusCode, message, nil) + } + var fiberErr *fiber.Error if errors.As(err, &fiberErr) { return response.Error(c, fiberErr.Code, fiberErr.Message, nil) @@ -26,6 +33,37 @@ func NotFoundHandler(c *fiber.Ctx) error { return response.Error(c, fiber.StatusNotFound, "Endpoint Not Found", nil) } +func mapPgError(err error) (int, string) { + code, message := getPgErrorDetails(err) + if code == "" { + return 0, "" + } + + switch code { + case "23503": + return fiber.StatusConflict, "Data tidak bisa dihapus karena masih digunakan oleh data lain." + case "P0001": + if strings.HasPrefix(message, "Cannot soft delete") { + return fiber.StatusConflict, "Data tidak bisa dihapus karena masih digunakan oleh data lain." + } + } + + return 0, "" +} + +func getPgErrorDetails(err error) (string, string) { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + return pgErr.Code, pgErr.Message + } + + var pgErrV5 *pgconnv5.PgError + if errors.As(err, &pgErrV5) { + return pgErrV5.Code, pgErrV5.Message + } + + return "", "" +} func BadRequest(msg string) error { return fiber.NewError(fiber.StatusBadRequest, msg) diff --git a/internal/utils/fifo/constants.go b/internal/utils/fifo/constants.go index ea6f96c0..03f61f82 100644 --- a/internal/utils/fifo/constants.go +++ b/internal/utils/fifo/constants.go @@ -1,7 +1,18 @@ package fifo const ( - UsableKeyRecordingStock UsableKey = "RECORDING_STOCK" - UsableKeyProjectChickin UsableKey = "PROJECT_CHICKIN" - UsableKeyMarketingDelivery UsableKey = "MARKETING_DELIVERY" + // Usable Keys + UsableKeyRecordingStock UsableKey = "RECORDING_STOCK" + UsableKeyProjectChickin UsableKey = "PROJECT_CHICKIN" + UsableKeyMarketingDelivery UsableKey = "MARKETING_DELIVERY" + UsableKeyTransferToLayingOut UsableKey = "TRANSFERTOLAYING_OUT" + UsableKeyStockTransferOut UsableKey = "STOCK_TRANSFER_OUT" + UsableKeyAdjustmentOut UsableKey = "ADJUSTMENT_OUT" + + // Stockable Keys + StockableKeyTransferToLayingIn StockableKey = "TRANSFERTOLAYING_IN" + StockableKeyStockTransferIn StockableKey = "STOCK_TRANSFER_IN" + StockableKeyAdjustmentIn StockableKey = "ADJUSTMENT_IN" + StockableKeyPurchaseItems StockableKey = "PURCHASE_ITEMS" + StockableKeyProjectFlockPopulation StockableKey = "PROJECT_FLOCK_POPULATION" ) diff --git a/internal/utils/fifo/registry.go b/internal/utils/fifo/registry.go index 61fed294..d9801185 100644 --- a/internal/utils/fifo/registry.go +++ b/internal/utils/fifo/registry.go @@ -54,11 +54,12 @@ type StockableConfig struct { // UsableConfig registers a table that consumes stock (recordings, adjustments, sales, etc). type UsableConfig struct { - Key UsableKey - Table string - Columns UsableColumns - OrderBy []string - Scope QueryScope + Key UsableKey + Table string + Columns UsableColumns + OrderBy []string + Scope QueryScope + ExcludedStockables []StockableKey // Stockables to exclude when consuming stock } var (