diff --git a/internal/database/migrations/20251030134527_recreate_project_chikins_table.up.sql b/internal/database/migrations/20251030134527_recreate_project_chikins_table.up.sql index e029646b..4ece8942 100644 --- a/internal/database/migrations/20251030134527_recreate_project_chikins_table.up.sql +++ b/internal/database/migrations/20251030134527_recreate_project_chikins_table.up.sql @@ -29,7 +29,7 @@ ADD CONSTRAINT fk_project_chickins_kandang FOREIGN KEY (project_flock_kandang_id -- Relasi ke product_warehouses ALTER TABLE project_chickins -ADD CONSTRAINT fk_project_chickins_warehouse FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses (id) ON DELETE RESTRICT ON UPDATE CASCADE; +ADD CONSTRAINT fk_project_chickins_warehouse FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses (id) ON DELETE CASCADE ON UPDATE CASCADE; -- Relasi ke users ALTER TABLE project_chickins diff --git a/internal/database/migrations/20251227100000_update_expense_table.down.sql b/internal/database/migrations/20251227100000_update_expense_table.down.sql new file mode 100644 index 00000000..fbaff587 --- /dev/null +++ b/internal/database/migrations/20251227100000_update_expense_table.down.sql @@ -0,0 +1,24 @@ +-- Rollback: Update expense and expense_nonstocks tables + +-- Drop indexes +DROP INDEX IF EXISTS idx_expenses_project_flock_id; +DROP INDEX IF EXISTS idx_expenses_location_id; + +-- Drop Foreign Key constraint +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_expenses_location_id' + ) THEN + ALTER TABLE expenses + DROP CONSTRAINT fk_expenses_location_id; + END IF; +END $$; + +-- Drop columns from expenses table +ALTER TABLE expenses +DROP COLUMN IF EXISTS project_flock_id; + +ALTER TABLE expenses +DROP COLUMN IF EXISTS location_id; diff --git a/internal/database/migrations/20251227100000_update_expense_table.up.sql b/internal/database/migrations/20251227100000_update_expense_table.up.sql new file mode 100644 index 00000000..6415ac98 --- /dev/null +++ b/internal/database/migrations/20251227100000_update_expense_table.up.sql @@ -0,0 +1,29 @@ +-- Migration: Update expense and expense_nonstocks tables + +-- Add location_id column to expenses table +ALTER TABLE expenses +ADD COLUMN IF NOT EXISTS location_id BIGINT NOT NULL DEFAULT 1; + +-- Add project_flock_id column to expenses table (JSON type) +ALTER TABLE expenses +ADD COLUMN IF NOT EXISTS project_flock_id JSON NULL; + +-- Add Foreign Key constraint to locations table +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'locations') THEN + ALTER TABLE expenses + ADD CONSTRAINT fk_expenses_location_id + FOREIGN KEY (location_id) REFERENCES locations(id) ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- Create index for location_id +CREATE INDEX IF NOT EXISTS idx_expenses_location_id ON expenses (location_id); + +-- Create index for project_flock_id +CREATE INDEX IF NOT EXISTS idx_expenses_project_flock_id ON expenses ((project_flock_id::text)); + +-- Ensure kandang_id is nullable in expense_nonstocks table +ALTER TABLE expense_nonstocks +ALTER COLUMN kandang_id DROP NOT NULL; diff --git a/internal/database/migrations/20251228015437_add_fifo_fields_to_stock_transfer_details.down.sql b/internal/database/migrations/20251228015437_add_fifo_fields_to_stock_transfer_details.down.sql new file mode 100644 index 00000000..9b5b8164 --- /dev/null +++ b/internal/database/migrations/20251228015437_add_fifo_fields_to_stock_transfer_details.down.sql @@ -0,0 +1,42 @@ +-- =============================================================== +-- ROLLBACK: Remove FIFO fields from STOCK_TRANSFER_DETAILS +-- =============================================================== + +-- Drop indexes +DROP INDEX IF EXISTS idx_stock_transfer_details_dest_pw; +DROP INDEX IF EXISTS idx_stock_transfer_details_source_pw; + +-- Drop foreign keys +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_stock_transfer_details_source_pw' + ) THEN + EXECUTE 'ALTER TABLE stock_transfer_details + DROP CONSTRAINT fk_stock_transfer_details_source_pw'; + END IF; + + IF EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_stock_transfer_details_dest_pw' + ) THEN + EXECUTE 'ALTER TABLE stock_transfer_details + DROP CONSTRAINT fk_stock_transfer_details_dest_pw'; + END IF; +END $$; + +-- Drop FIFO columns +ALTER TABLE stock_transfer_details +DROP COLUMN IF EXISTS total_used, +DROP COLUMN IF EXISTS total_qty, +DROP COLUMN IF EXISTS pending_qty, +DROP COLUMN IF EXISTS usage_qty, +DROP COLUMN IF EXISTS dest_product_warehouse_id, +DROP COLUMN IF EXISTS source_product_warehouse_id; + +-- Restore original columns (in case rollback) +ALTER TABLE stock_transfer_details +ADD COLUMN IF NOT EXISTS quantity NUMERIC(15, 3) NOT NULL DEFAULT 0, +ADD COLUMN IF NOT EXISTS before_quantity NUMERIC(15, 3), +ADD COLUMN IF NOT EXISTS after_quantity NUMERIC(15, 3); diff --git a/internal/database/migrations/20251228015437_add_fifo_fields_to_stock_transfer_details.up.sql b/internal/database/migrations/20251228015437_add_fifo_fields_to_stock_transfer_details.up.sql new file mode 100644 index 00000000..7f6ad5cb --- /dev/null +++ b/internal/database/migrations/20251228015437_add_fifo_fields_to_stock_transfer_details.up.sql @@ -0,0 +1,83 @@ +-- =============================================================== +-- ADD FIFO FIELDS TO STOCK_TRANSFER_DETAILS +-- Enable transfer module to work with FIFO stock system +-- +-- Notes: +-- - Field 'quantity' will be removed (replaced by usage_qty + pending_qty) +-- - Fields 'before_quantity' & 'after_quantity' will be removed (unused legacy) +-- - New FIFO fields track actual allocation instead of requested quantity +-- =============================================================== + +-- Add FIFO tracking fields +ALTER TABLE stock_transfer_details +ADD COLUMN IF NOT EXISTS source_product_warehouse_id BIGINT, +ADD COLUMN IF NOT EXISTS dest_product_warehouse_id BIGINT, +ADD COLUMN IF NOT EXISTS usage_qty NUMERIC(15, 3) DEFAULT 0, +ADD COLUMN IF NOT EXISTS pending_qty NUMERIC(15, 3) DEFAULT 0, +ADD COLUMN IF NOT EXISTS total_qty NUMERIC(15, 3) DEFAULT 0, +ADD COLUMN IF NOT EXISTS total_used NUMERIC(15, 3) DEFAULT 0; + +-- Remove obsolete columns (quantity replaced by FIFO fields, legacy fields never used) +ALTER TABLE stock_transfer_details +DROP COLUMN IF EXISTS quantity, +DROP COLUMN IF EXISTS before_quantity, +DROP COLUMN IF EXISTS after_quantity; + +-- Add foreign keys for product warehouse references +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'product_warehouses') THEN + -- Source warehouse foreign key + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_stock_transfer_details_source_pw' + ) THEN + EXECUTE + 'ALTER TABLE stock_transfer_details + ADD CONSTRAINT fk_stock_transfer_details_source_pw + FOREIGN KEY (source_product_warehouse_id) + REFERENCES product_warehouses(id) + ON DELETE SET NULL ON UPDATE CASCADE'; + END IF; + + -- Destination warehouse foreign key + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_stock_transfer_details_dest_pw' + ) THEN + EXECUTE + 'ALTER TABLE stock_transfer_details + ADD CONSTRAINT fk_stock_transfer_details_dest_pw + FOREIGN KEY (dest_product_warehouse_id) + REFERENCES product_warehouses(id) + ON DELETE SET NULL ON UPDATE CASCADE'; + END IF; + END IF; +END $$; + +-- Add indexes for FIFO operations +CREATE INDEX IF NOT EXISTS idx_stock_transfer_details_source_pw +ON stock_transfer_details (source_product_warehouse_id); + +CREATE INDEX IF NOT EXISTS idx_stock_transfer_details_dest_pw +ON stock_transfer_details (dest_product_warehouse_id); + +-- Add comments for documentation +COMMENT ON COLUMN stock_transfer_details.source_product_warehouse_id IS +'Source product warehouse ID - referensi warehouse asal (FIFO usable)'; + +COMMENT ON COLUMN stock_transfer_details.dest_product_warehouse_id IS +'Destination product warehouse ID - referensi warehouse tujuan (FIFO stockable)'; + +COMMENT ON COLUMN stock_transfer_details.usage_qty IS +'Actual quantity successfully taken from source warehouse (FIFO usable tracking) - replaces quantity field'; + +COMMENT ON COLUMN stock_transfer_details.pending_qty IS +'Quantity waiting for stock availability (FIFO usable tracking)'; + +COMMENT ON COLUMN stock_transfer_details.total_qty IS +'Total lot quantity available at destination warehouse (FIFO stockable tracking)'; + +COMMENT ON COLUMN stock_transfer_details.total_used IS +'Quantity already consumed from this lot at destination warehouse (FIFO stockable tracking)'; + diff --git a/internal/database/migrations/20251228112012_create_adjustment_stocks_table.down.sql b/internal/database/migrations/20251228112012_create_adjustment_stocks_table.down.sql new file mode 100644 index 00000000..9941a992 --- /dev/null +++ b/internal/database/migrations/20251228112012_create_adjustment_stocks_table.down.sql @@ -0,0 +1,16 @@ +-- Rollback: Drop adjustment_stocks table + +BEGIN; + +DROP INDEX IF EXISTS idx_adjustment_stocks_product_warehouse; +DROP INDEX IF EXISTS idx_adjustment_stocks_stock_log; + +ALTER TABLE adjustment_stocks +DROP CONSTRAINT IF EXISTS fk_adjustment_stocks_product_warehouse; + +ALTER TABLE adjustment_stocks +DROP CONSTRAINT IF EXISTS fk_adjustment_stocks_stock_log; + +DROP TABLE IF EXISTS adjustment_stocks; + +COMMIT; diff --git a/internal/database/migrations/20251228112012_create_adjustment_stocks_table.up.sql b/internal/database/migrations/20251228112012_create_adjustment_stocks_table.up.sql new file mode 100644 index 00000000..1c79439b --- /dev/null +++ b/internal/database/migrations/20251228112012_create_adjustment_stocks_table.up.sql @@ -0,0 +1,40 @@ +-- Migration: Create adjustment_stocks table for FIFO tracking +-- This table tracks FIFO allocation for stock adjustments (both increase and decrease) + +BEGIN; + +CREATE TABLE IF NOT EXISTS adjustment_stocks ( + id BIGSERIAL PRIMARY KEY, + stock_log_id BIGINT NOT NULL, + product_warehouse_id BIGINT NOT NULL, + + -- FIFO fields for Adjustment INCREASE (Stockable) + -- Tracks stock added to warehouse via adjustment + total_qty NUMERIC(15, 3) DEFAULT 0, + total_used NUMERIC(15, 3) DEFAULT 0, + + -- FIFO fields for Adjustment DECREASE (Usable) + -- Tracks stock consumed from warehouse via adjustment + usage_qty NUMERIC(15, 3) DEFAULT 0, + pending_qty NUMERIC(15, 3) DEFAULT 0, + + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +-- Foreign keys +ALTER TABLE adjustment_stocks +ADD CONSTRAINT fk_adjustment_stocks_stock_log +FOREIGN KEY (stock_log_id) REFERENCES stock_logs(id) +ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE adjustment_stocks +ADD CONSTRAINT fk_adjustment_stocks_product_warehouse +FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses(id) +ON DELETE CASCADE ON UPDATE CASCADE; + +-- Indexes +CREATE INDEX idx_adjustment_stocks_stock_log ON adjustment_stocks(stock_log_id); +CREATE INDEX idx_adjustment_stocks_product_warehouse ON adjustment_stocks(product_warehouse_id); + +COMMIT; diff --git a/internal/entities/adjustment_stock.go b/internal/entities/adjustment_stock.go new file mode 100644 index 00000000..bbc93167 --- /dev/null +++ b/internal/entities/adjustment_stock.go @@ -0,0 +1,29 @@ +package entities + +import "time" + +// AdjustmentStock tracks FIFO allocation for stock adjustments +// - For INCREASE adjustments (Stockable): Tracks stock added to warehouse +// - For DECREASE adjustments (Usable): Tracks stock consumed from warehouse +type AdjustmentStock struct { + Id uint `gorm:"primaryKey"` + StockLogId uint `gorm:"column:stock_log_id;not null;index"` + ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` + + // === FIFO FIELDS FOR INCREASE ADJUSTMENT (Stockable) === + // Tracks stock added to warehouse via adjustment INCREASE + TotalQty float64 `gorm:"column:total_qty;default:0"` // Total lot quantity available + TotalUsed float64 `gorm:"column:total_used;default:0"` // Quantity already used from this lot + + // === FIFO FIELDS FOR DECREASE ADJUSTMENT (Usable) === + // Tracks stock consumed from warehouse via adjustment DECREASE + UsageQty float64 `gorm:"column:usage_qty;default:0"` // Actual quantity consumed + PendingQty float64 `gorm:"column:pending_qty;default:0"` // Pending quantity (waiting for stock) + + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"` + + // Relations + StockLog *StockLog `gorm:"foreignKey:StockLogId;references:Id"` + ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` +} diff --git a/internal/entities/expense.go b/internal/entities/expense.go index 83a6031b..7bea3076 100644 --- a/internal/entities/expense.go +++ b/internal/entities/expense.go @@ -12,6 +12,8 @@ type Expense struct { SupplierId uint64 `gorm:""` Category string `gorm:"type:varchar(50);not null"` PoNumber string `gorm:"type:varchar(50)"` + LocationId uint64 `gorm:"not null"` + ProjectFlockId *string `gorm:"type:json"` RealizationDate time.Time `gorm:"type:date;column:realization_date"` TransactionDate time.Time `gorm:"type:date;not null"` Notes string `gorm:"type:text;column:notes"` @@ -21,6 +23,7 @@ type Expense struct { DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` Supplier *Supplier `gorm:"foreignKey:SupplierId;references:Id"` + Location *Location `gorm:"foreignKey:LocationId;references:Id"` CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` Nonstocks []ExpenseNonstock `gorm:"foreignKey:ExpenseId;references:Id"` Documents []Document `gorm:"foreignKey:DocumentableId;references:Id"` diff --git a/internal/entities/stock_transfer_detail.go b/internal/entities/stock_transfer_detail.go index 253a3bf8..9ab27824 100644 --- a/internal/entities/stock_transfer_detail.go +++ b/internal/entities/stock_transfer_detail.go @@ -7,12 +7,28 @@ type StockTransferDetail struct { Id uint64 `gorm:"primaryKey;autoIncrement"` StockTransferId uint64 ProductId uint64 - Quantity float64 - CreatedAt time.Time - UpdatedAt time.Time - DeletedAt *time.Time `gorm:"index"` - // Relations - StockTransfer *StockTransfer `gorm:"foreignKey:StockTransferId"` - Product *Product `gorm:"foreignKey:ProductId"` - DeliveryItems []StockTransferDeliveryItem `gorm:"foreignKey:StockTransferDetailId"` + + // === 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"` + + // === RELATIONS === + StockTransfer *StockTransfer `gorm:"foreignKey:StockTransferId"` + Product *Product `gorm:"foreignKey:ProductId"` + SourceProductWarehouse *ProductWarehouse `gorm:"foreignKey:SourceProductWarehouseID"` + DestProductWarehouse *ProductWarehouse `gorm:"foreignKey:DestProductWarehouseID"` + DeliveryItems []StockTransferDeliveryItem `gorm:"foreignKey:StockTransferDetailId"` } diff --git a/internal/modules/closings/dto/closingKeuangan.dto.go b/internal/modules/closings/dto/closingKeuangan.dto.go index 90dda2a9..08bfb5fc 100644 --- a/internal/modules/closings/dto/closingKeuangan.dto.go +++ b/internal/modules/closings/dto/closingKeuangan.dto.go @@ -35,6 +35,7 @@ const ( type CalculationContext struct { TotalPopulation float64 TotalWeightProduced float64 + TotalEggWeightKg float64 TotalDepletion float64 TotalWeightSold float64 ActualPopulation float64 @@ -48,6 +49,7 @@ type ClosingKeuanganInput struct { DeliveryProducts []entities.MarketingDeliveryProduct Chickins []entities.ProjectChickin TotalWeightProduced float64 + TotalEggWeightKg float64 TotalDepletion float64 } @@ -77,8 +79,10 @@ type HppGroup struct { } type SummaryHpp struct { - Label string `json:"label"` - Comparison + Label string `json:"label"` + Comparison `json:"-"` + EggBudgeting *FinancialMetrics `json:"egg_budgeting,omitempty"` + EggRealization *FinancialMetrics `json:"egg_realization,omitempty"` } type HppPurchasesSection struct { @@ -231,7 +235,7 @@ func ToHppBahanBakuGroup(budgets []entities.ProjectBudget, realizations []entiti // === HPP SUMMARY === -func ToSummaryHpp(label string, purchaseItems []entities.PurchaseItem, budgets []entities.ProjectBudget, realizations []entities.ExpenseRealization, ctx CalculationContext) SummaryHpp { +func ToSummaryHpp(label string, purchaseItems []entities.PurchaseItem, budgets []entities.ProjectBudget, realizations []entities.ExpenseRealization, projectFlockCategory string, ctx CalculationContext) SummaryHpp { purchaseTotal := sumPurchaseTotal(purchaseItems) budgetTotal := sumBudgetsByFilter(budgets, func(*entities.ProjectBudget) bool { return true }) totalBudget := purchaseTotal + budgetTotal @@ -241,16 +245,34 @@ func ToSummaryHpp(label string, purchaseItems []entities.PurchaseItem, budgets [ budgetRpPerBird, budgetRpPerKg := calculatePerUnitMetrics(totalBudget, ctx.TotalPopulation, ctx.TotalWeightProduced) realizationRpPerBird, realizationRpPerKg := calculatePerUnitMetrics(totalRealization, ctx.TotalPopulation, ctx.TotalWeightProduced) - return SummaryHpp{ + summary := SummaryHpp{ Label: label, Comparison: ToComparison( ToFinancialMetrics(budgetRpPerBird, budgetRpPerKg, totalBudget), ToFinancialMetrics(realizationRpPerBird, realizationRpPerKg, totalRealization), ), } + + if projectFlockCategory == string(utils.ProjectFlockCategoryLaying) && ctx.TotalEggWeightKg > 0 { + budgetEggRpPerKg, _ := calculatePerUnitMetrics(totalBudget, 0, ctx.TotalEggWeightKg) + realizationEggRpPerKg, _ := calculatePerUnitMetrics(totalRealization, 0, ctx.TotalEggWeightKg) + + summary.EggBudgeting = &FinancialMetrics{ + RpPerBird: 0, + RpPerKg: budgetEggRpPerKg, + Amount: totalBudget, + } + summary.EggRealization = &FinancialMetrics{ + RpPerBird: 0, + RpPerKg: realizationEggRpPerKg, + Amount: totalRealization, + } + } + + return summary } -func ToHppPurchasesSection(purchaseItems []entities.PurchaseItem, budgets []entities.ProjectBudget, realizations []entities.ExpenseRealization, ctx CalculationContext) HppPurchasesSection { +func ToHppPurchasesSection(purchaseItems []entities.PurchaseItem, budgets []entities.ProjectBudget, realizations []entities.ExpenseRealization, projectFlockCategory string, ctx CalculationContext) HppPurchasesSection { hppGroups := []HppGroup{ { GroupName: HPPGroupPengeluaran, @@ -259,7 +281,7 @@ func ToHppPurchasesSection(purchaseItems []entities.PurchaseItem, budgets []enti ToHppBahanBakuGroup(budgets, realizations, ctx), } - summaryHpp := ToSummaryHpp(HPPSummaryLabel, purchaseItems, budgets, realizations, ctx) + summaryHpp := ToSummaryHpp(HPPSummaryLabel, purchaseItems, budgets, realizations, projectFlockCategory, ctx) return HppPurchasesSection{ Hpp: hppGroups, @@ -322,11 +344,9 @@ func ToPenjualanItems(projectFlockCategory string, deliveryProducts []entities.M func ToPembelianItems(purchases []entities.PurchaseItem, realizations []entities.ExpenseRealization, ctx CalculationContext) []PLItem { purchaseAmount := sumPurchaseTotal(purchases) - bopAmount := getOperationalExpenses(realizations) - totalCost := purchaseAmount + bopAmount return []PLItem{ - createPLItemWithMetrics(PLItemTypeSapronak, totalCost, ctx), + createPLItemWithMetrics(PLItemTypeSapronak, purchaseAmount, ctx), } } @@ -414,12 +434,13 @@ func ToClosingKeuanganReport(input ClosingKeuanganInput) ReportResponse { ctx := CalculationContext{ TotalPopulation: totalPopulation, TotalWeightProduced: input.TotalWeightProduced, + TotalEggWeightKg: input.TotalEggWeightKg, TotalDepletion: input.TotalDepletion, TotalWeightSold: totalWeightSold, ActualPopulation: totalPopulation - input.TotalDepletion, } - hppSection := ToHppPurchasesSection(input.PurchaseItems, input.Budgets, input.Realizations, ctx) + hppSection := ToHppPurchasesSection(input.PurchaseItems, input.Budgets, input.Realizations, input.ProjectFlockCategory, ctx) penjualanItems := ToPenjualanItems(input.ProjectFlockCategory, input.DeliveryProducts, ctx) pembelianItems := ToPembelianItems(input.PurchaseItems, input.Realizations, ctx) overheadItems := ToOverheadItems(input.Realizations, ctx) diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index cf49826a..e3f09dda 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -31,6 +31,8 @@ type ClosingRepository interface { FetchSapronakChickinUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error) FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) + GetActualUsageCostByProjectFlockID(ctx context.Context, projectFlockID uint) ([]ActualUsageCostRow, error) + GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error) } type ClosingRepositoryImpl struct { @@ -804,3 +806,150 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand }) return in, out, nil } + +type ActualUsageCostRow struct { + ProductID uint `gorm:"column:product_id"` + ProductName string `gorm:"column:product_name"` + FlagName string `gorm:"column:flag_name"` + TotalQty float64 `gorm:"column:total_qty"` + TotalPrice float64 `gorm:"column:total_price"` + AveragePrice float64 `gorm:"column:average_price"` +} + +func (r *ClosingRepositoryImpl) GetActualUsageCostByProjectFlockID(ctx context.Context, projectFlockID uint) ([]ActualUsageCostRow, error) { + if projectFlockID == 0 { + return []ActualUsageCostRow{}, nil + } + + db := r.DB().WithContext(ctx) + + // Get all project flock kandang IDs for this project flock + var pfkIDs []uint + err := db.Table("project_flock_kandangs"). + Where("project_flock_id = ?", projectFlockID). + Pluck("id", &pfkIDs).Error + if err != nil { + return nil, err + } + + if len(pfkIDs) == 0 { + return []ActualUsageCostRow{}, nil + } + + var rows []ActualUsageCostRow + + // Part 1: Get usage from recording_stocks (PAKAN, OVK, Vitamin, Obat, Kimia, dll) + purchaseStockableKey := "PURCHASE_ITEMS" + transferStockableKey := "STOCK_TRANSFER_DETAILS" + + recordingQuery := db. + Table("recordings AS r"). + Select(` + pw.product_id AS product_id, + p.name AS product_name, + COALESCE(f.name, tf.name) AS flag_name, + COALESCE(SUM( + CASE + WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0) + WHEN sa.stockable_type = ? THEN COALESCE(std.quantity, 0) + ELSE 0 + END + ), 0) AS total_qty, + COALESCE(SUM( + CASE + WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0) + WHEN sa.stockable_type = ? THEN COALESCE(std.quantity, 0) * COALESCE(tpi.price, 0) + ELSE 0 + END + ), 0) AS total_price, + COALESCE(SUM( + CASE + WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0) + WHEN sa.stockable_type = ? THEN COALESCE(std.quantity, 0) + ELSE 0 + END + ), 0) AS qty_divisor, + COALESCE(SUM( + CASE + WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0) + WHEN sa.stockable_type = ? THEN COALESCE(std.quantity, 0) * COALESCE(tpi.price, 0) + ELSE 0 + END + ), 0) / NULLIF(COALESCE(SUM( + CASE + WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0) + WHEN sa.stockable_type = ? THEN COALESCE(std.quantity, 0) + ELSE 0 + END + ), 0), 0) AS average_price`, + purchaseStockableKey, transferStockableKey, + purchaseStockableKey, transferStockableKey, + purchaseStockableKey, transferStockableKey, + purchaseStockableKey, transferStockableKey, + purchaseStockableKey, transferStockableKey). + Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.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("LEFT JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.status = ?", + "recording_stocks", entity.StockAllocationStatusActive). + Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", purchaseStockableKey). + Joins("LEFT JOIN stock_transfer_details AS std ON std.id = sa.stockable_id AND sa.stockable_type = ?", transferStockableKey). + Joins("LEFT JOIN stock_transfers AS st ON st.id = std.stock_transfer_id"). + Joins("LEFT JOIN purchase_items AS tpi ON tpi.product_id = std.product_id AND tpi.warehouse_id = st.from_warehouse_id"). + Joins("LEFT JOIN flags AS f ON f.flagable_id = pi.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Joins("LEFT JOIN flags AS tf ON tf.flagable_id = std.product_id AND tf.flagable_type = ?", entity.FlagableTypeProduct). + Where("r.project_flock_kandangs_id IN ?", pfkIDs). + Where("r.deleted_at IS NULL"). + Group("pw.product_id, p.name, COALESCE(f.name, tf.name)") + + if err := recordingQuery.Scan(&rows).Error; err != nil { + return nil, err + } + + // Part 2: Get usage from project_chickins (DOC, Pullet) + chickinQuery := db. + Table("project_chickins AS pc"). + Select(` + pw.product_id AS product_id, + p.name AS product_name, + f.name AS flag_name, + COALESCE(SUM(pc.usage_qty), 0) AS total_qty, + COALESCE(SUM(pc.usage_qty * COALESCE(pi.price, 0)), 0) AS total_price, + COALESCE(AVG(COALESCE(pi.price, 0)), 0) AS average_price + `). + Joins("JOIN product_warehouses AS pw ON pw.id = pc.product_warehouse_id"). + Joins("JOIN products AS p ON p.id = pw.product_id"). + Joins("LEFT JOIN purchase_items AS pi ON pi.product_warehouse_id = pc.product_warehouse_id"). + Joins("LEFT JOIN flags AS f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Where("pc.project_flock_kandang_id IN ?", pfkIDs). + Where("pc.usage_qty > 0"). + Group("pw.product_id, p.name, f.name") + + var chickinRows []ActualUsageCostRow + if err := chickinQuery.Scan(&chickinRows).Error; err != nil { + return nil, err + } + + // Merge results + rows = append(rows, chickinRows...) + + return rows, nil +} + +func (r *ClosingRepositoryImpl) GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error) { + if len(productIDs) == 0 { + return []entity.Product{}, nil + } + + var products []entity.Product + err := r.DB().WithContext(ctx). + Preload("Flags"). + Where("id IN ?", productIDs). + Find(&products).Error + + if err != nil { + return nil, err + } + + return products, nil +} diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index ab8e6f7b..9f643a78 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -426,11 +426,15 @@ func (s closingService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (* return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch budgets") } - purchaseItems, err := s.PurchaseRepo.GetItemsByProjectFlockID(c.Context(), projectFlockID) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch purchase items") + // 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) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch realizations") @@ -455,6 +459,11 @@ func (s closingService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (* s.Log.Warnf("GetProductionWeightAndQtyByProjectFlockID error: %v", err) } + totalEggWeightKg, err := s.RecordingRepo.GetTotalEggProductionWeightByProjectFlockID(c.Context(), projectFlockID) + if err != nil { + s.Log.Warnf("GetTotalEggProductionWeightByProjectFlockID error: %v", err) + } + totalDepletion, err := s.RecordingRepo.GetTotalDepletionByProjectFlockID(c.Context(), projectFlockID) if err != nil { s.Log.Warnf("GetTotalDepletionByProjectFlockID error: %v", err) @@ -468,6 +477,7 @@ func (s closingService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (* DeliveryProducts: deliveryProducts, Chickins: chickins, TotalWeightProduced: totalWeightProduced, + TotalEggWeightKg: totalEggWeightKg, TotalDepletion: totalDepletion, } @@ -476,8 +486,6 @@ func (s closingService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (* return &report, nil } -// GetExpeditionHPP menghitung HPP ekspedisi per vendor untuk sebuah project flock. -// Jika projectFlockKandangID tidak nil, maka hanya data untuk kandang tersebut yang dihitung. func (s closingService) GetExpeditionHPP(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.ExpeditionHPPDTO, error) { if projectFlockID == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id") @@ -778,5 +786,54 @@ func closestFcrValues(standards []entity.FcrStandard, averageWeight float64) (fl } return closest.Mortality, closest.FcrNumber - +} + +func (s closingService) convertActualUsageToPurchaseItems(ctx context.Context, actualUsageRows []repository.ActualUsageCostRow) []entity.PurchaseItem { + if len(actualUsageRows) == 0 { + return []entity.PurchaseItem{} + } + + // Collect all product IDs + productIDs := make([]uint, len(actualUsageRows)) + for i, row := range actualUsageRows { + productIDs[i] = row.ProductID + } + + // Fetch products with flags from repository + products, err := s.Repository.GetProductsWithFlagsByIDs(ctx, productIDs) + if err != nil { + s.Log.Warnf("Failed to fetch products for actual usage: %v", err) + products = []entity.Product{} + } + + // Create product map + productMap := make(map[uint]*entity.Product) + for i := range products { + productMap[products[i].Id] = &products[i] + } + + // Convert to pseudo purchase items + purchaseItems := make([]entity.PurchaseItem, 0, len(actualUsageRows)) + for _, row := range actualUsageRows { + product := productMap[row.ProductID] + + // Skip if product not found + if product == nil { + s.Log.Warnf("Product ID %d not found for actual usage", row.ProductID) + continue + } + + purchaseItem := entity.PurchaseItem{ + Id: 0, // Pseudo item, no ID + ProductId: row.ProductID, + TotalQty: row.TotalQty, + TotalPrice: row.TotalPrice, + Price: row.AveragePrice, + Product: product, + } + + purchaseItems = append(purchaseItems, purchaseItem) + } + + return purchaseItems } diff --git a/internal/modules/expenses/controllers/expense.controller.go b/internal/modules/expenses/controllers/expense.controller.go index 55114ec8..49642231 100644 --- a/internal/modules/expenses/controllers/expense.controller.go +++ b/internal/modules/expenses/controllers/expense.controller.go @@ -90,6 +90,12 @@ func (u *ExpenseController) CreateOne(c *fiber.Ctx) error { } req.SupplierID = supplierID + locationID, err := strconv.ParseUint(c.FormValue("location_id"), 10, 64) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid location_id format") + } + req.LocationID = locationID + form, err := c.MultipartForm() if err != nil { return fiber.NewError(fiber.StatusBadRequest, "Invalid multipart form") @@ -106,17 +112,7 @@ func (u *ExpenseController) CreateOne(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid expense_nonstocks JSON: %v", err)) } - if singleExpenseNonstock.KandangID == 0 { - return fiber.NewError(fiber.StatusBadRequest, "Field KandangID is required") - } - req.ExpenseNonstocks = []validation.ExpenseNonstock{singleExpenseNonstock} - } else { - for i, expenseNonstock := range req.ExpenseNonstocks { - if expenseNonstock.KandangID == 0 { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Field KandangID is required for expense_nonstocks[%d]", i)) - } - } } } else { return fiber.NewError(fiber.StatusBadRequest, "Field expense_nonstocks is required") @@ -171,6 +167,15 @@ func (u *ExpenseController) UpdateOne(c *fiber.Ctx) error { req.SupplierID = &supplierID } + locationIDVal := c.FormValue("location_id") + if locationIDVal != "" { + locationID, err := strconv.ParseUint(locationIDVal, 10, 64) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid location_id format") + } + req.LocationID = &locationID + } + expenseNonstocksJSON := c.FormValue("expense_nonstocks") if expenseNonstocksJSON != "" { var expenseNonstocks []validation.ExpenseNonstock @@ -178,12 +183,6 @@ func (u *ExpenseController) UpdateOne(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid expense_nonstocks JSON: %v", err)) } - for i, expenseNonstock := range expenseNonstocks { - if expenseNonstock.KandangID == 0 { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Field KandangID is required for expense_nonstocks[%d]", i)) - } - } - req.ExpenseNonstocks = &expenseNonstocks } diff --git a/internal/modules/expenses/dto/expense.dto.go b/internal/modules/expenses/dto/expense.dto.go index 4bb9ebe1..6402f8fd 100644 --- a/internal/modules/expenses/dto/expense.dto.go +++ b/internal/modules/expenses/dto/expense.dto.go @@ -76,7 +76,6 @@ type ExpenseRealizationDTO struct { type KandangGroupDTO struct { Id uint64 `json:"id"` - KandangId uint64 `json:"kandang_id"` Name string `json:"name,omitempty"` Pengajuans []ExpenseNonstockDTO `json:"pengajuans,omitempty"` Realisasi []ExpenseRealizationDTO `json:"realisasi,omitempty"` @@ -178,7 +177,6 @@ func ToExpenseDetailDTO(e *entity.Expense) ExpenseDetailDTO { var pengajuans []ExpenseNonstockDTO var realisasi []ExpenseRealizationDTO - // Map documents from Document service for _, doc := range e.Documents { documents = append(documents, DocumentDTO{ ID: uint64(doc.Id), @@ -186,7 +184,6 @@ func ToExpenseDetailDTO(e *entity.Expense) ExpenseDetailDTO { }) } - // Map realization documents from Document service for _, doc := range e.RealizationDocuments { realizationDocs = append(realizationDocs, DocumentDTO{ ID: uint64(doc.Id), @@ -271,6 +268,8 @@ func ToExpenseNonstockDTO(ns entity.ExpenseNonstock) ExpenseNonstockDTO { func ToKandangGroupDTO(pengajuans []ExpenseNonstockDTO, realisasi []ExpenseRealizationDTO, nonstocks []entity.ExpenseNonstock) []KandangGroupDTO { kandangMap := make(map[uint64]*KandangGroupDTO) + var directPengajuans []ExpenseNonstockDTO + var directRealisasi []ExpenseRealizationDTO for _, p := range pengajuans { var kandangId uint64 @@ -287,16 +286,19 @@ func ToKandangGroupDTO(pengajuans []ExpenseNonstockDTO, realisasi []ExpenseReali } if kandangId > 0 { + if kandangMap[kandangId] == nil { kandangMap[kandangId] = &KandangGroupDTO{ Id: kandangId, - KandangId: kandangId, Name: kandangName, Pengajuans: []ExpenseNonstockDTO{}, Realisasi: []ExpenseRealizationDTO{}, } } kandangMap[kandangId].Pengajuans = append(kandangMap[kandangId].Pengajuans, p) + } else { + + directPengajuans = append(directPengajuans, p) } } @@ -316,13 +318,24 @@ func ToKandangGroupDTO(pengajuans []ExpenseNonstockDTO, realisasi []ExpenseReali if kandangMap[kandangId] == nil { kandangMap[kandangId] = &KandangGroupDTO{ Id: kandangId, - KandangId: kandangId, Name: kandangName, Pengajuans: []ExpenseNonstockDTO{}, Realisasi: []ExpenseRealizationDTO{}, } } kandangMap[kandangId].Realisasi = append(kandangMap[kandangId].Realisasi, r) + } else { + } + } + + // If there are direct expenses (without kandang), add them as a special entry with id=0 + if len(directPengajuans) > 0 || len(directRealisasi) > 0 { + kandangMap[0] = &KandangGroupDTO{ + Id: 0, + + Name: "", + Pengajuans: directPengajuans, + Realisasi: directRealisasi, } } diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index 728c689f..b4753451 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "errors" "fmt" @@ -144,11 +145,8 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen supplierID := uint(req.SupplierID) - supplierExistsFunc := func(ctx context.Context, id uint) (bool, error) { - return commonRepo.Exists[entity.Supplier](ctx, s.SupplierRepo.DB(), id) - } if err := commonSvc.EnsureRelations(c.Context(), - commonSvc.RelationCheck{Name: "Supplier", ID: &supplierID, Exists: supplierExistsFunc}, + commonSvc.RelationCheck{Name: "Supplier", ID: &supplierID, Exists: s.SupplierRepo.IdExists}, ); err != nil { return nil, err } @@ -199,11 +197,47 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen return fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context") } createdBy := uint64(actorID) + + hasKandang := false + for _, ens := range req.ExpenseNonstocks { + if ens.KandangID != nil { + hasKandang = true + break + } + } + + var projectFlockIdJSON *string + 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") + } + + if len(activeProjectFlocks) == 0 { + return fiber.NewError(fiber.StatusBadRequest, "No active project flocks found for this location") + } + + projectFlockIDs := make([]uint64, len(activeProjectFlocks)) + for i, pf := range activeProjectFlocks { + projectFlockIDs[i] = uint64(pf.Id) + } + + projectFlockIdsJSON, err := json.Marshal(projectFlockIDs) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to marshal project_flock_ids") + } + jsonStr := string(projectFlockIdsJSON) + projectFlockIdJSON = &jsonStr + } + expense = &entity.Expense{ ReferenceNumber: referenceNumber, PoNumber: req.PoNumber, Category: req.Category, SupplierId: req.SupplierID, + LocationId: req.LocationID, + ProjectFlockId: projectFlockIdJSON, TransactionDate: expenseDate, CreatedBy: createdBy, } @@ -216,35 +250,36 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen for _, expenseNonstock := range req.ExpenseNonstocks { + isAttachingToKandang := (expenseNonstock.KandangID != nil) + var projectFlockKandangId *uint64 + var kandangId *uint64 - if req.Category == string(utils.ExpenseCategoryBOP) { + if isAttachingToKandang { + kandangId = expenseNonstock.KandangID - projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(expenseNonstock.KandangID)) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang") + if req.Category == string(utils.ExpenseCategoryBOP) { + + projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(*kandangId)) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang") + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to find project flock kandang for this kandang") } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to find project flock kandang for this kandang") + id := uint64(projectFlockKandang.Id) + projectFlockKandangId = &id } - id := uint64(projectFlockKandang.Id) - projectFlockKandangId = &id + + } else { + kandangId = nil + projectFlockKandangId = nil } for _, costItem := range expenseNonstock.CostItems { nonstockId := costItem.NonstockID - var kandangId *uint64 - if req.Category == string(utils.ExpenseCategoryNonBOP) { - id := uint64(expenseNonstock.KandangID) - kandangId = &id - } else if req.Category == string(utils.ExpenseCategoryBOP) { - if projectFlockKandangId != nil { - kandangId = &expenseNonstock.KandangID - } - } - - expenseNonstock := &entity.ExpenseNonstock{ + newExpenseNonstock := &entity.ExpenseNonstock{ ExpenseId: &expense.Id, ProjectFlockKandangId: projectFlockKandangId, KandangId: kandangId, @@ -254,7 +289,7 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen Notes: costItem.Notes, } - if err := expenseNonstockRepoTx.CreateOne(c.Context(), expenseNonstock, nil); err != nil { + if err := expenseNonstockRepoTx.CreateOne(c.Context(), newExpenseNonstock, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense cost item") } } @@ -361,6 +396,11 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) updateBody["supplier_id"] = *req.SupplierID } + if req.LocationID != nil { + locationID := uint(*req.LocationID) + updateBody["location_id"] = locationID + } + if len(updateBody) == 0 && req.ExpenseNonstocks == nil && len(req.Documents) == 0 { responseDTO, err := s.GetOne(c, id) @@ -475,18 +515,26 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) for _, expenseNonstock := range *req.ExpenseNonstocks { var projectFlockKandangId *uint64 + var kandangId *uint64 - if updatedExpense.Category == string(utils.ExpenseCategoryBOP) { - projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(tx) - projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(expenseNonstock.KandangID)) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang") + // Check if attaching to kandang + if expenseNonstock.KandangID != nil { + kandangId = expenseNonstock.KandangID + + if updatedExpense.Category == string(utils.ExpenseCategoryBOP) { + // BOP with kandang: Get active project flock kandang + projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(tx) + projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(*kandangId)) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang") + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to find project flock kandang for this kandang") } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to find project flock kandang for this kandang") + id := uint64(projectFlockKandang.Id) + projectFlockKandangId = &id } - id := uint64(projectFlockKandang.Id) - projectFlockKandangId = &id + // NON-BOP: projectFlockKandangId stays nil } for _, costItem := range expenseNonstock.CostItems { @@ -498,18 +546,8 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) return err } - var kandangId *uint64 - if updatedExpense.Category == string(utils.ExpenseCategoryNonBOP) { - id := uint64(expenseNonstock.KandangID) - kandangId = &id - } else if updatedExpense.Category == string(utils.ExpenseCategoryBOP) { - if projectFlockKandangId != nil { - kandangId = &expenseNonstock.KandangID - } - } - expenseId := uint64(id) - expenseNonstock := &entity.ExpenseNonstock{ + newExpenseNonstock := &entity.ExpenseNonstock{ ExpenseId: &expenseId, ProjectFlockKandangId: projectFlockKandangId, KandangId: kandangId, @@ -519,7 +557,7 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) Notes: costItem.Notes, } - if err := expenseNonstockRepoTx.CreateOne(c.Context(), expenseNonstock, nil); err != nil { + if err := expenseNonstockRepoTx.CreateOne(c.Context(), newExpenseNonstock, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense cost item") } } diff --git a/internal/modules/expenses/validations/expense.validation.go b/internal/modules/expenses/validations/expense.validation.go index 9dc2b07b..4501b87d 100644 --- a/internal/modules/expenses/validations/expense.validation.go +++ b/internal/modules/expenses/validations/expense.validation.go @@ -9,12 +9,13 @@ type Create struct { TransactionDate string `form:"transaction_date" json:"transaction_date" validate:"required,datetime=2006-01-02"` Category string `form:"category" json:"category" validate:"required,oneof=BOP NON-BOP"` SupplierID uint64 `form:"supplier_id" json:"supplier_id" validate:"required,gt=0"` + LocationID uint64 `form:"location_id" json:"location_id" validate:"required,gt=0"` Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"` ExpenseNonstocks []ExpenseNonstock `form:"expense_nonstocks" json:"expense_nonstocks" validate:"required,min=1,dive"` } type ExpenseNonstock struct { - KandangID uint64 `form:"kandang_id" json:"kandang_id" validate:"required,gt=0"` + KandangID *uint64 `form:"kandang_id" json:"kandang_id" validate:"omitempty"` CostItems []CostItem `form:"cost_items" json:"cost_items" validate:"required,min=1,dive"` } @@ -22,13 +23,14 @@ type CostItem struct { NonstockID uint64 `form:"nonstock_id" json:"nonstock_id" validate:"required,gt=0"` Quantity float64 `form:"quantity" json:"quantity" validate:"required,gt=0"` Price float64 `form:"price" json:"price" validate:"required,gt=0"` - Notes string `form:"notes" json:"notes" validate:"required,max=500"` + Notes string `form:"notes" json:"notes" validate:"omitempty,max=500"` } type Update struct { TransactionDate *string `form:"transaction_date" json:"transaction_date" validate:"omitempty,datetime=2006-01-02"` Category *string `form:"category" json:"category" validate:"omitempty,oneof=BOP NON-BOP"` SupplierID *uint64 `form:"supplier_id" json:"supplier_id" validate:"omitempty,gt=0"` + LocationID *uint64 `form:"location_id" json:"location_id" validate:"omitempty,gt=0"` Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"` ExpenseNonstocks *[]ExpenseNonstock `form:"expense_nonstocks" json:"expense_nonstocks" validate:"omitempty,min=1,dive"` } diff --git a/internal/modules/inventory/adjustments/module.go b/internal/modules/inventory/adjustments/module.go index 610dc11e..08e556ea 100644 --- a/internal/modules/inventory/adjustments/module.go +++ b/internal/modules/inventory/adjustments/module.go @@ -5,6 +5,9 @@ import ( "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" + rAdjustmentStock "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/repositories" sAdjustment "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/services" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rproduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" @@ -13,19 +16,67 @@ import ( rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" ) type AdjustmentModule struct{} func (AdjustmentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + // Repositories stockLogsRepo := rStockLogs.NewStockLogRepository(db) warehouseRepo := rWarehouse.NewWarehouseRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) userRepo := rUser.NewUserRepository(db) productRepo := rproduct.NewProductRepository(db) + adjustmentStockRepo := rAdjustmentStock.NewAdjustmentStockRepository(db) + stockAllocRepo := commonRepo.NewStockAllocationRepository(db) - adjustmentService := sAdjustment.NewAdjustmentService(productRepo, stockLogsRepo, warehouseRepo, productWarehouseRepo, validate, projectFlockKandangRepo) + fifoService := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log) + + err := fifoService.RegisterStockable(fifo.StockableConfig{ + Key: fifo.StockableKey("ADJUSTMENT_IN"), + Table: "adjustment_stocks", + 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"}, + }) + if err != nil { + panic("Failed to register ADJUSTMENT_IN as Stockable: " + err.Error()) + } + + err = fifoService.RegisterUsable(fifo.UsableConfig{ + Key: fifo.UsableKey("ADJUSTMENT_OUT"), + Table: "adjustment_stocks", + Columns: fifo.UsableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + UsageQuantity: "usage_qty", + PendingQuantity: "pending_qty", + CreatedAt: "created_at", + }, + OrderBy: []string{"created_at ASC", "id ASC"}, + }) + if err != nil { + panic("Failed to register ADJUSTMENT_OUT as Usable: " + err.Error()) + } + + adjustmentService := sAdjustment.NewAdjustmentService( + productRepo, + stockLogsRepo, + warehouseRepo, + productWarehouseRepo, + adjustmentStockRepo, + fifoService, + validate, + projectFlockKandangRepo, + ) userService := sUser.NewUserService(userRepo, validate) AdjustmentRoutes(router, userService, adjustmentService) diff --git a/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go b/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go new file mode 100644 index 00000000..8d62b05c --- /dev/null +++ b/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go @@ -0,0 +1,50 @@ +package repositories + +import ( + "context" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type AdjustmentStockRepository interface { + CreateOne(ctx context.Context, data *entity.AdjustmentStock, modifier func(*gorm.DB) *gorm.DB) error + GetByStockLogID(ctx context.Context, stockLogID uint) (*entity.AdjustmentStock, error) + WithTx(tx *gorm.DB) AdjustmentStockRepository + DB() *gorm.DB +} + +type adjustmentStockRepositoryImpl struct { + db *gorm.DB +} + +func NewAdjustmentStockRepository(db *gorm.DB) AdjustmentStockRepository { + return &adjustmentStockRepositoryImpl{db: db} +} + +func (r *adjustmentStockRepositoryImpl) CreateOne(ctx context.Context, data *entity.AdjustmentStock, modifier func(*gorm.DB) *gorm.DB) error { + q := r.db.WithContext(ctx) + if modifier != nil { + q = modifier(q) + } + return q.Create(data).Error +} + +func (r *adjustmentStockRepositoryImpl) GetByStockLogID(ctx context.Context, stockLogID uint) (*entity.AdjustmentStock, error) { + var record entity.AdjustmentStock + err := r.db.WithContext(ctx). + Where("stock_log_id = ?", stockLogID). + First(&record).Error + if err != nil { + return nil, err + } + return &record, nil +} + +func (r *adjustmentStockRepositoryImpl) WithTx(tx *gorm.DB) AdjustmentStockRepository { + return &adjustmentStockRepositoryImpl{db: tx} +} + +func (r *adjustmentStockRepositoryImpl) DB() *gorm.DB { + return r.db +} diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index 5a634382..d7b1641b 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -12,6 +12,7 @@ import ( common "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + adjustmentStockRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/validations" ProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" productRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" @@ -29,24 +30,37 @@ type AdjustmentService interface { } type adjustmentService struct { - Log *logrus.Logger - Validate *validator.Validate - StockLogsRepository stockLogsRepo.StockLogRepository - WarehouseRepo warehouseRepo.WarehouseRepository - ProductWarehouseRepo ProductWarehouse.ProductWarehouseRepository - ProductRepo productRepo.ProductRepository - ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository + Log *logrus.Logger + Validate *validator.Validate + StockLogsRepository stockLogsRepo.StockLogRepository + WarehouseRepo warehouseRepo.WarehouseRepository + ProductWarehouseRepo ProductWarehouse.ProductWarehouseRepository + ProductRepo productRepo.ProductRepository + ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository + AdjustmentStockRepository adjustmentStockRepo.AdjustmentStockRepository + FifoSvc common.FifoService } -func NewAdjustmentService(productRepo productRepo.ProductRepository, stockLogsRepo stockLogsRepo.StockLogRepository, warehouseRepo warehouseRepo.WarehouseRepository, productWarehouseRepo ProductWarehouse.ProductWarehouseRepository, validate *validator.Validate, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository) AdjustmentService { +func NewAdjustmentService( + productRepo productRepo.ProductRepository, + stockLogsRepo stockLogsRepo.StockLogRepository, + warehouseRepo warehouseRepo.WarehouseRepository, + productWarehouseRepo ProductWarehouse.ProductWarehouseRepository, + adjustmentStockRepo adjustmentStockRepo.AdjustmentStockRepository, + fifoSvc common.FifoService, + validate *validator.Validate, + projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, +) AdjustmentService { return &adjustmentService{ - Log: utils.Log, - Validate: validate, - StockLogsRepository: stockLogsRepo, - WarehouseRepo: warehouseRepo, - ProductWarehouseRepo: productWarehouseRepo, - ProductRepo: productRepo, - ProjectFlockKandangRepo: projectFlockKandangRepo, + Log: utils.Log, + Validate: validate, + StockLogsRepository: stockLogsRepo, + WarehouseRepo: warehouseRepo, + ProductWarehouseRepo: productWarehouseRepo, + ProductRepo: productRepo, + ProjectFlockKandangRepo: projectFlockKandangRepo, + AdjustmentStockRepository: adjustmentStockRepo, + FifoSvc: fifoSvc, } } @@ -152,15 +166,16 @@ 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), LoggableId: 0, Notes: req.Note, ProductWarehouseId: productWarehouse.Id, - CreatedBy: actorID, // TODO: should Get from auth middleware + CreatedBy: actorID, } + if transactionType == string(utils.StockLogTransactionTypeIncrease) { afterQuantity += req.Quantity newLog.Increase = afterQuantity @@ -177,6 +192,57 @@ 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, + } + + if transactionType == string(utils.StockLogTransactionTypeIncrease) { + // Adjustment INCREASE → Replenish stock (Stockable) + note := fmt.Sprintf("Stock Adjustment IN #%d", newLog.Id) + replenishResult, err := s.FifoSvc.Replenish(ctx, common.StockReplenishRequest{ + StockableKey: "ADJUSTMENT_IN", + StockableID: newLog.Id, + ProductWarehouseID: uint(productWarehouse.Id), + Quantity: req.Quantity, + Note: ¬e, + Tx: tx, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to replenish stock via FIFO: %v", err)) + } + + // Update stockable tracking fields + adjustmentStock.TotalQty = replenishResult.AddedQuantity + adjustmentStock.TotalUsed = 0 + + } else { + // Adjustment DECREASE → Consume stock (Usable) + consumeResult, err := s.FifoSvc.Consume(ctx, common.StockConsumeRequest{ + UsableKey: "ADJUSTMENT_OUT", + UsableID: newLog.Id, + ProductWarehouseID: uint(productWarehouse.Id), + Quantity: req.Quantity, + AllowPending: false, // Don't allow pending for adjustment + Tx: tx, + }) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to consume stock via FIFO: %v", err)) + } + + // Update usable tracking fields + adjustmentStock.UsageQty = consumeResult.UsageQuantity + adjustmentStock.PendingQty = consumeResult.PendingQuantity + } + + // Save AdjustmentStock record + if err := s.AdjustmentStockRepository.WithTx(tx).CreateOne(ctx, adjustmentStock, nil); err != nil { + s.Log.Errorf("Failed to create adjustment stock: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create adjustment stock record") + } + + // 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/dto/product_warehouse.dto.go b/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go index 81fbec1f..57a13021 100644 --- a/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go +++ b/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go @@ -24,11 +24,12 @@ type ProductWarehousNestedDTO struct { type ProductWarehouseListDTO struct { ProductWarehouseRelationDTO - Product *productDTO.ProductRelationDTO `json:"product,omitempty"` - Warehouse *WarehouseRelationDTO `json:"warehouse,omitempty"` - CreatedUser *UserRelationDTO `json:"created_user,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + Product *productDTO.ProductRelationDTO `json:"product,omitempty"` + Warehouse *WarehouseRelationDTO `json:"warehouse,omitempty"` + ProjectFlockKandang *ProjectFlockKandangRelationDTO `json:"project_flock_kandang,omitempty"` + CreatedUser *UserRelationDTO `json:"created_user,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type UserRelationDTO struct { @@ -71,6 +72,19 @@ type AreaRelationDTO struct { Name string `json:"name"` } +type ProjectFlockKandangRelationDTO struct { + Id uint `json:"id"` + ProjectFlockId uint `json:"project_flock_id"` + KandangId uint `json:"kandang_id"` + Period int `json:"period"` + ProjectFlock *ProjectFlockRelationDTO `json:"project_flock,omitempty"` +} + +type ProjectFlockRelationDTO struct { + Id uint `json:"id"` + FlockName string `json:"flock_name"` +} + // === Mapper Functions === func ToProductWarehouseRelationDTO(e entity.ProductWarehouse) ProductWarehouseRelationDTO { @@ -105,6 +119,12 @@ func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDT // Map Product relation jika ada if e.Product.Id != 0 { product := productDTO.ToProductRelationDTO(e.Product) + + // Tambahkan flock name ke product name jika ada project flock + if e.ProjectFlockKandang != nil && e.ProjectFlockKandang.ProjectFlock.Id != 0 { + product.Name = product.Name + " (" + e.ProjectFlockKandang.ProjectFlock.FlockName + ")" + } + dto.Product = &product } @@ -139,6 +159,26 @@ func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDT dto.Warehouse = &warehouse } + // Map ProjectFlockKandang relation jika ada + if e.ProjectFlockKandang != nil && e.ProjectFlockKandang.Id != 0 { + pfkDTO := &ProjectFlockKandangRelationDTO{ + Id: e.ProjectFlockKandang.Id, + ProjectFlockId: e.ProjectFlockKandang.ProjectFlockId, + KandangId: e.ProjectFlockKandang.KandangId, + Period: e.ProjectFlockKandang.Period, + } + + // Map ProjectFlock jika ada + if e.ProjectFlockKandang.ProjectFlock.Id != 0 { + pfkDTO.ProjectFlock = &ProjectFlockRelationDTO{ + Id: e.ProjectFlockKandang.ProjectFlock.Id, + FlockName: e.ProjectFlockKandang.ProjectFlock.FlockName, + } + } + + dto.ProjectFlockKandang = pfkDTO + } + // Map CreatedUser relation jika ada // if e.CreatedUser.Id != 0 { // user := UserRelationDTO{ 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 4f213f2c..e759138e 100644 --- a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go +++ b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go @@ -81,9 +81,29 @@ func (r *ProductWarehouseRepositoryImpl) ProductWarehouseExistByProductAndWareho func (r *ProductWarehouseRepositoryImpl) GetProductWarehouseByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (*entity.ProductWarehouse, error) { var productWarehouse entity.ProductWarehouse - if err := r.DB().WithContext(ctx).Where("product_id = ? AND warehouse_id = ?", productId, warehouseId).First(&productWarehouse).Error; err != nil { + + err := r.DB().WithContext(ctx). + Where("product_id = ? AND warehouse_id = ? AND project_flock_kandang_id IS NOT NULL", productId, warehouseId). + Order("id DESC"). + Preload("ProjectFlockKandang"). + First(&productWarehouse).Error + + if err == nil { + + if productWarehouse.ProjectFlockKandang.ClosedAt == nil { + return &productWarehouse, nil + } + + } + + err = r.DB().WithContext(ctx). + Where("product_id = ? AND warehouse_id = ? AND project_flock_kandang_id IS NULL", productId, warehouseId). + First(&productWarehouse).Error + + if err != nil { return nil, err } + return &productWarehouse, nil } @@ -244,6 +264,8 @@ func (r *ProductWarehouseRepositoryImpl) GetDetailByID(ctx context.Context, id u Preload("Warehouse"). Preload("Warehouse.Area"). Preload("Warehouse.Location"). + Preload("ProjectFlockKandang"). + Preload("ProjectFlockKandang.ProjectFlock"). First(&productWarehouse, id).Error if err != nil { return nil, err diff --git a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go index f690b2a2..152bfa24 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -44,7 +44,8 @@ func (s productWarehouseService) withRelations(db *gorm.DB) *gorm.DB { Preload("Warehouse.Location"). Preload("Warehouse.Area"). Preload("Warehouse.Kandang"). - Preload("ProjectFlockKandang") + Preload("ProjectFlockKandang"). + Preload("ProjectFlockKandang.ProjectFlock") } func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProductWarehouse, int64, error) { diff --git a/internal/modules/inventory/transfers/dto/transfer.dto.go b/internal/modules/inventory/transfers/dto/transfer.dto.go index f1286595..8f075715 100644 --- a/internal/modules/inventory/transfers/dto/transfer.dto.go +++ b/internal/modules/inventory/transfers/dto/transfer.dto.go @@ -159,7 +159,7 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { Id: d.Product.Id, Name: d.Product.Name, }, - Quantity: d.Quantity, + Quantity: d.UsageQty + d.PendingQty, // Total actual quantity allocated }) } @@ -229,7 +229,7 @@ func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { Id: d.Product.Id, Name: d.Product.Name, }, - Quantity: d.Quantity, + Quantity: d.UsageQty + d.PendingQty, // Total actual quantity allocated }) } diff --git a/internal/modules/inventory/transfers/module.go b/internal/modules/inventory/transfers/module.go index 9389f9f4..60d1764a 100644 --- a/internal/modules/inventory/transfers/module.go +++ b/internal/modules/inventory/transfers/module.go @@ -18,6 +18,8 @@ import ( rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" ) type TransferModule struct{} @@ -34,13 +36,51 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate warehouseRepo := rWarehouse.NewWarehouseRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) documentRepo := commonRepo.NewDocumentRepository(db) + stockAllocRepo := commonRepo.NewStockAllocationRepository(db) documentSvc, err := commonSvc.NewDocumentServiceFromConfig(context.Background(), documentRepo) if err != nil { panic(err) } - transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo, warehouseRepo, projectFlockKandangRepo, documentSvc) + // Initialize FIFO Service + fifoService := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log) + + // Register Transfer as Stockable (adds stock to destination warehouse) + err = fifoService.RegisterStockable(fifo.StockableConfig{ + Key: fifo.StockableKey("STOCK_TRANSFER_IN"), + Table: "stock_transfer_details", + Columns: fifo.StockableColumns{ + ID: "id", + ProductWarehouseID: "dest_product_warehouse_id", + TotalQuantity: "total_qty", + TotalUsedQuantity: "total_used", + CreatedAt: "created_at", + }, + OrderBy: []string{"created_at ASC", "id ASC"}, + }) + if err != nil { + panic(err) + } + + // Register Transfer as Usable (consumes stock from source warehouse) + err = fifoService.RegisterUsable(fifo.UsableConfig{ + Key: fifo.UsableKey("STOCK_TRANSFER_OUT"), + Table: "stock_transfer_details", + Columns: fifo.UsableColumns{ + ID: "id", + ProductWarehouseID: "source_product_warehouse_id", + UsageQuantity: "usage_qty", + PendingQuantity: "pending_qty", + CreatedAt: "created_at", + }, + OrderBy: []string{"created_at ASC", "id ASC"}, + }) + if err != nil { + panic(err) + } + + transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo, warehouseRepo, projectFlockKandangRepo, documentSvc, fifoService) userService := sUser.NewUserService(userRepo, validate) TransferRoutes(router, userService, transferService) diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index a8a8996e..8ae019a4 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -44,9 +44,10 @@ type transferService struct { WarehouseRepo warehouseRepo.WarehouseRepository ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository DocumentSvc commonSvc.DocumentService + FifoSvc commonSvc.FifoService } -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) 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) TransferService { return &transferService{ Log: utils.Log, Validate: validate, @@ -60,6 +61,7 @@ func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTr WarehouseRepo: warehouseRepo, ProjectFlockKandangRepo: projectFlockKandangRepo, DocumentSvc: documentSvc, + FifoSvc: fifoSvc, } } @@ -126,6 +128,7 @@ 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 { @@ -152,6 +155,21 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return nil, err } + destPfkID, err := s.getActiveProjectFlockKandangID(c.Context(), uint(req.DestinationWarehouseID)) + if err != nil { + 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") + } + } + actorID, err := m.ActorIDFromContext(c) if err != nil { return nil, err @@ -206,14 +224,62 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return err } - var details []*entity.StockTransferDetail + // 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 { - details = append(details, &entity.StockTransferDetail{ + // Get source product warehouse + sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID( + c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID), + ) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok produk %d tidak tersedia di gudang asal", product.ProductID)) + } + return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data product warehouse source") + } + + // Get or create destination product warehouse + destPW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID( + c.Context(), uint(product.ProductID), uint(req.DestinationWarehouseID), + ) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data product warehouse destination") + } + if errors.Is(err, gorm.ErrRecordNotFound) { + ctx := c.Context() + projectFlockKandangID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.DestinationWarehouseID)) + if err != nil { + return err + } + destPW = &entity.ProductWarehouse{ + ProductId: uint(product.ProductID), + WarehouseId: uint(req.DestinationWarehouseID), + Quantity: 0, + ProjectFlockKandangId: &projectFlockKandangID, + } + if err := s.ProductWarehouseRepo.WithTx(tx).CreateOne(c.Context(), destPW, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat product warehouse destination") + } + } + + detail := &entity.StockTransferDetail{ StockTransferId: entityTransfer.Id, ProductId: uint64(product.ProductID), - Quantity: product.ProductQty, - }) + + SourceProductWarehouseID: func() *uint64 { id := uint64(sourcePW.Id); return &id }(), + UsageQty: 0, + PendingQty: 0, + + DestProductWarehouseID: func() *uint64 { id := uint64(destPW.Id); return &id }(), + TotalQty: 0, + TotalUsed: 0, + } + details = append(details, detail) + detailMap[uint64(product.ProductID)] = detail } + if err := s.StockTransferDetailRepo.WithTx(tx).CreateMany(c.Context(), details, nil); err != nil { return err } @@ -233,23 +299,18 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return err } - detailMap := make(map[uint64]uint64) - for _, d := range details { - detailMap[d.ProductId] = d.Id - } - var deliveryItems []*entity.StockTransferDeliveryItem for i, delivery := range deliveries { item := req.Deliveries[i] for _, prod := range item.Products { - detailID, ok := detailMap[uint64(prod.ProductID)] + detail, ok := detailMap[uint64(prod.ProductID)] if !ok { return fmt.Errorf("produk %d tidak ditemukan di detail", prod.ProductID) } deliveryItems = append(deliveryItems, &entity.StockTransferDeliveryItem{ StockTransferDeliveryId: delivery.Id, - StockTransferDetailId: detailID, + StockTransferDetailId: detail.Id, Quantity: prod.ProductQty, }) } @@ -280,69 +341,54 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } } + // Execute FIFO operations for each product for _, product := range req.Products { - sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID)) + 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", + UsableID: uint(detail.Id), + ProductWarehouseID: uint(*detail.SourceProductWarehouseID), + Quantity: product.ProductQty, + AllowPending: false, // Don't allow pending, must have actual stock + Tx: tx, + }) if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get source product warehouse") - } - if sourcePW.Quantity < product.ProductQty { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock in source warehouse for product ID: %d", product.ProductID)) - } - sourcePW.Quantity -= product.ProductQty - if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(c.Context(), sourcePW.Id, sourcePW, nil); err != nil { - return err + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak cukup di gudang asal untuk produk %d: %v", product.ProductID, err)) } - decreaseLog := &entity.StockLog{ - Decrease: product.ProductQty, - Notes: "", - LoggableType: string(utils.StockLogTypeTransfer), - LoggableId: uint(entityTransfer.Id), - ProductWarehouseId: sourcePW.Id, - CreatedBy: actorID, - } - if err := s.StockLogsRepository.WithTx(tx).CreateOne(c.Context(), decreaseLog, nil); err != nil { - return err + // Update usage tracking fields for source warehouse + if err := tx.Model(&entity.StockTransferDetail{}). + Where("id = ?", detail.Id). + Updates(map[string]interface{}{ + "usage_qty": consumeResult.UsageQuantity, + "pending_qty": consumeResult.PendingQuantity, + }).Error; err != nil { + return fmt.Errorf("gagal update usage tracking: %w", err) } - destPW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID( - c.Context(), uint(product.ProductID), uint(req.DestinationWarehouseID), - ) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get destination product warehouse") - } - if err != nil && errors.Is(err, gorm.ErrRecordNotFound) { - ctx := c.Context() - projectFlockKandangID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.DestinationWarehouseID)) - if err != nil { - return err - } - destPW = &entity.ProductWarehouse{ - ProductId: uint(product.ProductID), - WarehouseId: uint(req.DestinationWarehouseID), - Quantity: 0, - ProjectFlockKandangId: &projectFlockKandangID, - } - if err := s.ProductWarehouseRepo.WithTx(tx).CreateOne(c.Context(), destPW, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to create destination product warehouse") - } + // 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", + StockableID: uint(detail.Id), + ProductWarehouseID: uint(*detail.DestProductWarehouseID), + Quantity: product.ProductQty, + Note: ¬e, + Tx: tx, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal menambah stok di gudang tujuan untuk produk %d: %v", product.ProductID, err)) } - destPW.Quantity += product.ProductQty - if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(c.Context(), destPW.Id, destPW, nil); err != nil { - return err - } - - increaseLog := &entity.StockLog{ - Increase: product.ProductQty, - LoggableType: string(utils.StockLogTypeTransfer), - LoggableId: uint(entityTransfer.Id), - Notes: "", - ProductWarehouseId: destPW.Id, - CreatedBy: actorID, - } - if err := s.StockLogsRepository.WithTx(tx).CreateOne(c.Context(), increaseLog, nil); err != nil { - return err + // Update total tracking fields for destination warehouse + if err := tx.Model(&entity.StockTransferDetail{}). + Where("id = ?", detail.Id). + Updates(map[string]interface{}{ + "total_qty": replenishResult.AddedQuantity, + }).Error; err != nil { + return fmt.Errorf("gagal update total tracking: %w", err) } } diff --git a/internal/modules/marketing/module.go b/internal/modules/marketing/module.go index d8c8fc6a..b93c6129 100644 --- a/internal/modules/marketing/module.go +++ b/internal/modules/marketing/module.go @@ -33,18 +33,15 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate customerRepo := rCustomer.NewCustomerRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) - // Initialize FIFO service stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) - // Register marketing_delivery_products as FIFO Usable - // Note: ProductWarehouseID comes from marketing_products table via preload if err := fifoService.RegisterUsable(fifo.UsableConfig{ Key: fifo.UsableKeyMarketingDelivery, Table: "marketing_delivery_products", Columns: fifo.UsableColumns{ ID: "id", - ProductWarehouseID: "product_warehouse_id", // Resolved from marketing_products via preload + ProductWarehouseID: "product_warehouse_id", UsageQuantity: "usage_qty", PendingQuantity: "pending_qty", CreatedAt: "created_at", @@ -55,11 +52,9 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate } } - // Initialize approval service approvalRepo := commonRepo.NewApprovalRepository(db) approvalSvc := commonSvc.NewApprovalService(approvalRepo) - // Register workflow steps for marketing approval if err := approvalSvc.RegisterWorkflowSteps(utils.ApprovalWorkflowMarketing, utils.MarketingApprovalSteps); err != nil { panic(fmt.Sprintf("failed to register marketing approval workflow: %v", err)) } @@ -67,11 +62,9 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate warehouseRepo := rWarehouse.NewWarehouseRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) - // Initialize services salesOrdersService := service.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc, warehouseRepo, projectFlockKandangRepo, validate) deliveryOrdersService := service.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, approvalSvc, fifoService, validate) userService := sUser.NewUserService(userRepo, validate) - // Register routes RegisterRoutes(router, userService, salesOrdersService, deliveryOrdersService) } diff --git a/internal/modules/marketing/services/salesorder.service.go b/internal/modules/marketing/services/salesorder.service.go index bef2a477..dc6e62de 100644 --- a/internal/modules/marketing/services/salesorder.service.go +++ b/internal/modules/marketing/services/salesorder.service.go @@ -603,15 +603,16 @@ func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Cont } marketingDeliveryProduct := &entity.MarketingDeliveryProduct{ - MarketingProductId: marketingProduct.Id, - UnitPrice: 0, - TotalWeight: 0, - AvgWeight: 0, - TotalPrice: 0, - DeliveryDate: nil, - VehicleNumber: rp.VehicleNumber, - UsageQty: 0, - PendingQty: 0, + MarketingProductId: marketingProduct.Id, + ProductWarehouseId: marketingProduct.ProductWarehouseId, + UnitPrice: 0, + TotalWeight: 0, + AvgWeight: 0, + TotalPrice: 0, + DeliveryDate: nil, + VehicleNumber: rp.VehicleNumber, + UsageQty: 0, + PendingQty: 0, } if err := invDeliveryRepo.CreateOne(ctx, marketingDeliveryProduct, nil); err != nil { return err diff --git a/internal/modules/master/suppliers/repositories/supplier.repository.go b/internal/modules/master/suppliers/repositories/supplier.repository.go index 6b5a0ae2..c4c892b5 100644 --- a/internal/modules/master/suppliers/repositories/supplier.repository.go +++ b/internal/modules/master/suppliers/repositories/supplier.repository.go @@ -12,6 +12,7 @@ type SupplierRepository interface { repository.BaseRepository[entity.Supplier] NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) AliasExists(ctx context.Context, alias string, excludeID *uint) (bool, error) + IdExists(ctx context.Context, id uint) (bool, error) } type SupplierRepositoryImpl struct { @@ -33,3 +34,7 @@ func (r *SupplierRepositoryImpl) NameExists(ctx context.Context, name string, ex func (r *SupplierRepositoryImpl) AliasExists(ctx context.Context, alias string, excludeID *uint) (bool, error) { return repository.ExistsByField[entity.Supplier](ctx, r.db, "alias", alias, excludeID) } + +func (r *SupplierRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.Supplier](ctx, r.db, id) +} diff --git a/internal/modules/production/project_flocks/repositories/projectflock.repository.go b/internal/modules/production/project_flocks/repositories/projectflock.repository.go index 15afaf59..4af5cbcd 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock.repository.go @@ -19,6 +19,7 @@ type ProjectflockRepository interface { GetNextPeriodsForKandangs(ctx context.Context, kandangIDs []uint) (map[uint]int, error) 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) AreaExists(ctx context.Context, id uint) (bool, error) FcrExists(ctx context.Context, id uint) (bool, error) LocationExists(ctx context.Context, id uint) (bool, error) @@ -295,3 +296,17 @@ func (r *ProjectflockRepositoryImpl) ExistsByFlockName(ctx context.Context, floc } return count > 0, nil } + +func (r *ProjectflockRepositoryImpl) GetActiveByLocationID(ctx context.Context, locationID uint64) ([]entity.ProjectFlock, error) { + var projectFlocks []entity.ProjectFlock + err := r.DB().WithContext(ctx). + Joins("JOIN project_flock_kandangs ON project_flock_kandangs.project_flock_id = project_flocks.id"). + Where("project_flocks.location_id = ?", locationID). + Where("project_flock_kandangs.closed_at IS NULL"). + Group("project_flocks.id"). + Find(&projectFlocks).Error + if err != nil { + return nil, err + } + return projectFlocks, nil +} diff --git a/internal/modules/purchases/module.go b/internal/modules/purchases/module.go index 6daf2a39..fa10559d 100644 --- a/internal/modules/purchases/module.go +++ b/internal/modules/purchases/module.go @@ -12,6 +12,7 @@ import ( 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" + rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" rNonstock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/repositories" rProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" @@ -35,6 +36,7 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate supplierRepo := rSupplier.NewSupplierRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) nonstockRepo := rNonstock.NewNonstockRepository(db) + kandangRepo := rKandang.NewKandangRepository(db) expenseRepository := expenseRepo.NewExpenseRepository(db) expenseRealizationRepo := expenseRepo.NewExpenseRealizationRepository(db) projectFlockKandangRepository := projectFlockKandangRepo.NewProjectFlockKandangRepository(db) @@ -67,6 +69,7 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate db, purchaseRepo, projectFlockKandangRepository, + kandangRepo, expenseServiceInstance, ) diff --git a/internal/modules/purchases/services/expense_bridge.go b/internal/modules/purchases/services/expense_bridge.go index d8356e6a..146f04f2 100644 --- a/internal/modules/purchases/services/expense_bridge.go +++ b/internal/modules/purchases/services/expense_bridge.go @@ -17,6 +17,7 @@ import ( 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" + 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" rPurchase "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" @@ -53,6 +54,7 @@ type expenseBridge struct { db *gorm.DB purchaseRepo rPurchase.PurchaseRepository projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository + kandangRepo kandangRepo.KandangRepository expenseSvc expenseSvc.ExpenseService } @@ -60,12 +62,14 @@ func NewExpenseBridge( db *gorm.DB, purchaseRepo rPurchase.PurchaseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, + kandangRepo kandangRepo.KandangRepository, expenseSvc expenseSvc.ExpenseService, ) PurchaseExpenseBridge { return &expenseBridge{ db: db, purchaseRepo: purchaseRepo, projectFlockKandangRepo: projectFlockKandangRepo, + kandangRepo: kandangRepo, expenseSvc: expenseSvc, } } @@ -550,6 +554,16 @@ func (b *expenseBridge) createExpenseViaService( return nil, fiber.NewError(fiber.StatusBadRequest, "Warehouse not connect to kandangs") } + 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)) + } + costItems := make([]expenseValidation.CostItem, 0, len(items)) for _, gi := range items { note := fmt.Sprintf("purchase_item:%d", gi.payload.PurchaseItemID) @@ -570,8 +584,9 @@ func (b *expenseBridge) createExpenseViaService( TransactionDate: utils.FormatDate(expenseDate), Category: "BOP", SupplierID: uint64(supplierID), + LocationID: uint64(kandang.LocationId), ExpenseNonstocks: []expenseValidation.ExpenseNonstock{{ - KandangID: uint64(*kandangID), + KandangID: func() *uint64 { id := uint64(*kandangID); return &id }(), CostItems: costItems, }}, } diff --git a/internal/modules/repports/controllers/repport.controller.go b/internal/modules/repports/controllers/repport.controller.go index 0ab2ccbd..82229a45 100644 --- a/internal/modules/repports/controllers/repport.controller.go +++ b/internal/modules/repports/controllers/repport.controller.go @@ -164,3 +164,26 @@ func (c *RepportController) GetPurchaseSupplier(ctx *fiber.Ctx) error { Data: result, }) } + +func (c *RepportController) GetHppPerKandang(ctx *fiber.Ctx) error { + data, meta, err := c.RepportService.GetHppPerKandang(ctx) + if err != nil { + return err + } + + resp := struct { + Code int `json:"code"` + Status string `json:"status"` + Message string `json:"message"` + Meta dto.HppPerKandangMetaDTO `json:"meta"` + Data dto.HppPerKandangResponseData `json:"data"` + }{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get HPP harian kandang layer successfully", + Meta: *meta, + Data: *data, + } + + return ctx.Status(fiber.StatusOK).JSON(resp) +} diff --git a/internal/modules/repports/dto/repportHpp.dto.go b/internal/modules/repports/dto/repportHpp.dto.go new file mode 100644 index 00000000..63c5dce9 --- /dev/null +++ b/internal/modules/repports/dto/repportHpp.dto.go @@ -0,0 +1,123 @@ +package dto + +type HppPerKandangFiltersDTO struct { + AreaID string `json:"area_id"` + LocationID string `json:"location_id"` + KandangID string `json:"kandang_id"` + WeightMin string `json:"weight_min"` + WeightMax string `json:"weight_max"` + Period string `json:"period"` + ShowUnrecorded string `json:"show_unrecorded"` +} + +type HppPerKandangMetaDTO struct { + Page int `json:"page"` + Limit int `json:"limit"` + TotalPages int64 `json:"total_pages"` + TotalResults int64 `json:"total_results"` + Filters HppPerKandangFiltersDTO `json:"filters"` +} + +type HppPerKandangResponseData struct { + Period string `json:"period"` + Rows []HppPerKandangRowDTO `json:"rows"` + Summary HppPerKandangSummaryDTO `json:"summary"` +} + +type HppPerKandangRowDTO struct { + ID int `json:"id"` + Kandang HppPerKandangRowKandangDTO `json:"kandang"` + WeightRange HppPerKandangWeightRangeDTO `json:"weight_range"` + RemainingChickenBirds int64 `json:"remaining_chicken_birds"` + RemainingChickenWeightKg float64 `json:"remaining_chicken_weight_kg"` + AvgWeightKg float64 `json:"avg_weight_kg"` + EggProductionPieces int64 `json:"egg_production_pieces"` + EggProductionKg float64 `json:"egg_production_kg"` + // FeedCostRp float64 `json:"feed_cost_rp"` + // OvkCostRp float64 `json:"ovk_cost_rp"` + EggHppRpPerKg float64 `json:"egg_hpp_rp_per_kg"` + EggValueRp int64 `json:"egg_value_rp"` + FeedSuppliers []HppPerKandangSupplierDTO `json:"feed_suppliers"` + DocSuppliers []HppPerKandangSupplierDTO `json:"doc_suppliers"` + AverageDocPriceRp int64 `json:"average_doc_price_rp"` + HppRp float64 `json:"hpp_rp"` + RemainingValueRp int64 `json:"remaining_value_rp"` +} + +type HppPerKandangRowKandangDTO struct { + ID int64 `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Location HppPerKandangLocationDTO `json:"location"` + Pic HppPerKandangPICDTO `json:"pic"` +} + +type HppPerKandangLocationDTO struct { + ID int64 `json:"id"` + Name string `json:"name"` +} + +type HppPerKandangPICDTO struct { + ID int64 `json:"id"` + Name string `json:"name"` +} + +type HppPerKandangWeightRangeDTO struct { + WeightMin float64 `json:"weight_min"` + WeightMax float64 `json:"weight_max"` +} + +type HppPerKandangSupplierDTO struct { + ID int64 `json:"id"` + Name string `json:"name"` + Alias string `json:"alias"` + Category string `json:"category"` +} + +type HppPerKandangSummaryDTO struct { + PerWeightRange []HppPerKandangSummaryWeightRangeDTO `json:"per_weight_range"` + Total HppPerKandangSummaryTotalDTO `json:"total"` +} + +type HppPerKandangSummaryWeightRangeDTO struct { + ID int `json:"id"` + WeightRange HppPerKandangWeightRangeDTO `json:"weight_range"` + Label string `json:"label"` + RemainingChickenBirds int64 `json:"remaining_chicken_birds"` + RemainingChickenWeightKg float64 `json:"remaining_chicken_weight_kg"` + AvgWeightKg float64 `json:"avg_weight_kg"` + EggProductionPieces int64 `json:"egg_production_pieces"` + EggProductionKg float64 `json:"egg_production_kg"` + EggHppRpPerKg float64 `json:"egg_hpp_rp_per_kg"` + EggValueRp int64 `json:"egg_value_rp"` + FeedSuppliers []HppPerKandangSupplierDTO `json:"feed_suppliers"` + DocSuppliers []HppPerKandangSupplierDTO `json:"doc_suppliers"` + AverageDocPriceRp float64 `json:"average_doc_price_rp"` + HppRp float64 `json:"hpp_rp"` + RemainingValueRp int64 `json:"remaining_value_rp"` +} + +type HppPerKandangSummaryTotalDTO struct { + TotalRemainingChickenBirds int64 `json:"total_remaining_chicken_birds"` + TotalRemainingChickenWeightKg float64 `json:"total_remaining_chicken_weight_kg"` + AverageWeightKg float64 `json:"average_weight_kg"` + TotalRemainingValueRp int64 `json:"total_remaining_value_rp"` + TotalEggProductionPieces int64 `json:"total_egg_production_pieces"` + TotalEggProductionKg float64 `json:"total_egg_production_kg"` + AverageEggHppRpPerKg float64 `json:"average_egg_hpp_rp_per_kg"` + TotalEggValueRp int64 `json:"total_egg_value_rp"` + TotalHppRp float64 `json:"total_hpp_rp"` + TotalAverageDocPriceRp float64 `json:"total_average_doc_price_rp"` +} + +func NewHppPerKandangFiltersDTO(area, location, kandang, weightMin, weightMax, period, showUnrecorded string) HppPerKandangFiltersDTO { + return HppPerKandangFiltersDTO{ + AreaID: area, + LocationID: location, + KandangID: kandang, + WeightMin: weightMin, + WeightMax: weightMax, + Period: period, + ShowUnrecorded: showUnrecorded, + } +} diff --git a/internal/modules/repports/module.go b/internal/modules/repports/module.go index 1e019c90..105d9ad5 100644 --- a/internal/modules/repports/module.go +++ b/internal/modules/repports/module.go @@ -31,10 +31,11 @@ func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * recordingRepository := recordingRepo.NewRecordingRepository(db) approvalRepository := commonRepo.NewApprovalRepository(db) purchaseSupplierRepository := repportRepo.NewPurchaseSupplierRepository(db) + hppPerKandangRepository := repportRepo.NewHppPerKandangRepository(db) userRepository := rUser.NewUserRepository(db) approvalSvc := approvalService.NewApprovalService(approvalRepository) - repportService := sRepport.NewRepportService(validate, expenseRealizationRepository, marketingDeliveryProductRepository, purchaseRepository, chickinRepository, recordingRepository, approvalSvc, purchaseSupplierRepository) + repportService := sRepport.NewRepportService(validate, expenseRealizationRepository, marketingDeliveryProductRepository, purchaseRepository, chickinRepository, recordingRepository, approvalSvc, purchaseSupplierRepository, hppPerKandangRepository) userService := sUser.NewUserService(userRepository, validate) RepportRoutes(router, userService, repportService) diff --git a/internal/modules/repports/repositories/hpp_per_kandang.repository.go b/internal/modules/repports/repositories/hpp_per_kandang.repository.go new file mode 100644 index 00000000..7e1c8143 --- /dev/null +++ b/internal/modules/repports/repositories/hpp_per_kandang.repository.go @@ -0,0 +1,361 @@ +package repositories + +import ( + "context" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" + "gorm.io/gorm" +) + +type HppPerKandangRow struct { + KandangID uint + KandangName string + KandangStatus string + LocationID uint + LocationName string + PicID uint + PicName string + RemainingChickenBirds float64 + RemainingChickenWeight float64 + EggProductionWeightKg float64 + EggProductionPieces float64 +} + +type HppPerKandangCostRow struct { + KandangID uint + FeedCost float64 + OvkCost float64 + DocCost float64 + DocQty float64 + BudgetCost float64 + ExpenseCost float64 +} + +type HppPerKandangSupplierRow struct { + KandangID uint + SupplierID uint + SupplierName string + SupplierAlias string + Category string +} + +type HppPerKandangRepository interface { + GetRowsByPeriod(ctx context.Context, start, end time.Time, areaIDs, locationIDs, kandangIDs []int64) ([]HppPerKandangRow, error) + GetFeedOvkDocCostByPeriod(ctx context.Context, start, end time.Time, areaIDs, locationIDs, kandangIDs []int64) ([]HppPerKandangCostRow, []HppPerKandangSupplierRow, error) +} + +type hppPerKandangRepository struct { + db *gorm.DB +} + +func NewHppPerKandangRepository(db *gorm.DB) HppPerKandangRepository { + return &hppPerKandangRepository{db: db} +} + +func (r *hppPerKandangRepository) GetRowsByPeriod(ctx context.Context, start, end time.Time, areaIDs, locationIDs, kandangIDs []int64) ([]HppPerKandangRow, error) { + var rows []HppPerKandangRow + + query := r.db.WithContext(ctx). + Table("recordings AS r"). + Select(` + k.id AS kandang_id, + k.name AS kandang_name, + k.status AS kandang_status, + loc.id AS location_id, + loc.name AS location_name, + pic.id AS pic_id, + pic.name AS pic_name, + COALESCE(MAX(r.total_chick_qty), 0) AS remaining_chicken_birds, + COALESCE(SUM(rbw.total_weight), 0) AS remaining_chicken_weight, + COALESCE(SUM(re.weight), 0) AS egg_production_weight_kg, + COALESCE(SUM(re.qty), 0) AS egg_production_pieces`). + 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 locations AS loc ON loc.id = k.location_id"). + Joins("JOIN users AS pic ON pic.id = k.pic_id"). + Joins("LEFT JOIN recording_bws AS rbw ON rbw.recording_id = r.id"). + Joins("LEFT JOIN recording_eggs AS re ON re.recording_id = r.id"). + Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL") + + query = applyLocationFilters(query, areaIDs, locationIDs, kandangIDs) + + query = query.Group("k.id, k.name, k.status, loc.id, loc.name, pic.id, pic.name"). + Order("k.id ASC") + + if err := query.Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, start, end time.Time, areaIDs, locationIDs, kandangIDs []int64) ([]HppPerKandangCostRow, []HppPerKandangSupplierRow, error) { + var rows []HppPerKandangCostRow + + recordingPfk := r.db.WithContext(ctx). + Table("recordings AS r"). + Select("DISTINCT pfk.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 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") + recordingPfk = applyLocationFilters(recordingPfk, areaIDs, locationIDs, kandangIDs) + + purchaseStockableKey := fifo.StockableKey("PURCHASE_ITEMS").String() + transferStockableKey := fifo.StockableKey("STOCK_TRANSFER_DETAILS").String() + + query := r.db.WithContext(ctx). + Table("recordings AS r"). + Select(` + k.id AS kandang_id, + 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 + END), 0) AS feed_cost, + 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 + END), 0) AS ovk_cost`, + utils.FlagPakan, transferStockableKey, utils.FlagPakan, + utils.FlagOVK, transferStockableKey, utils.FlagOVK). + 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 locations AS loc ON loc.id = k.location_id"). + Joins("LEFT JOIN recording_stocks AS rs ON rs.recording_id = r.id"). + Joins("LEFT JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.status = ?", fifo.UsableKeyRecordingStock.String(), entity.StockAllocationStatusActive). + Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", purchaseStockableKey). + Joins("LEFT JOIN stock_transfer_details AS std ON std.id = sa.stockable_id AND sa.stockable_type = ?", transferStockableKey). + Joins("LEFT JOIN stock_transfers AS st ON st.id = std.stock_transfer_id"). + Joins("LEFT JOIN purchase_items AS tpi ON tpi.product_id = std.product_id AND tpi.warehouse_id = st.from_warehouse_id"). + Joins("LEFT JOIN flags AS f ON f.flagable_id = pi.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Joins("LEFT JOIN flags AS tf ON tf.flagable_id = std.product_id AND tf.flagable_type = ?", entity.FlagableTypeProduct). + Where("r.project_flock_kandangs_id IN (?)", recordingPfk.Session(&gorm.Session{NewDB: true})). + Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL") + + query = applyLocationFilters(query, areaIDs, locationIDs, kandangIDs) + + query = query.Group("k.id").Order("k.id ASC") + + if err := query.Scan(&rows).Error; err != nil { + return nil, nil, err + } + + docRows := make([]struct { + KandangID uint + DocCost float64 + DocQty float64 + SupplierID *uint + SupplierName *string + SupplierAlias *string + }, 0) + + docQuery := r.db.WithContext(ctx). + Table("project_chickins AS pc"). + Select(` + pfk.kandang_id AS kandang_id, + COALESCE(SUM(pc.usage_qty * COALESCE(pi.price, 0)), 0) AS doc_cost, + COALESCE(SUM(pc.usage_qty), 0) AS doc_qty, + s.id AS supplier_id, + s.name AS supplier_name, + s.alias AS supplier_alias`). + 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"). + Joins("JOIN locations AS loc ON loc.id = k.location_id"). + Joins("LEFT JOIN purchase_items AS pi ON pi.product_warehouse_id = pc.product_warehouse_id"). + Joins("LEFT JOIN purchases AS pur ON pur.id = pi.purchase_id"). + Joins("LEFT JOIN suppliers AS s ON s.id = pur.supplier_id"). + Where("pc.project_flock_kandang_id IN (?)", recordingPfk.Session(&gorm.Session{NewDB: true})). + Group("pfk.kandang_id, s.id, s.name, s.alias") + docQuery = applyLocationFilters(docQuery, areaIDs, locationIDs, kandangIDs) + + if err := docQuery.Scan(&docRows).Error; err != nil { + return nil, nil, err + } + + costMap := make(map[uint]*HppPerKandangCostRow, len(rows)) + for i := range rows { + row := rows[i] + costMap[row.KandangID] = &rows[i] + } + + docSuppliers := make([]HppPerKandangSupplierRow, 0) + docSeen := make(map[uint]map[uint]bool) + for _, doc := range docRows { + entry, ok := costMap[doc.KandangID] + if !ok { + rows = append(rows, HppPerKandangCostRow{ + KandangID: doc.KandangID, + }) + entry = &rows[len(rows)-1] + costMap[doc.KandangID] = entry + } + entry.DocCost += doc.DocCost + entry.DocQty += doc.DocQty + if doc.SupplierID != nil { + if docSeen[doc.KandangID] == nil { + docSeen[doc.KandangID] = make(map[uint]bool) + } + if !docSeen[doc.KandangID][*doc.SupplierID] { + docSeen[doc.KandangID][*doc.SupplierID] = true + supplierName := "" + if doc.SupplierName != nil { + supplierName = *doc.SupplierName + } + supplierAlias := "" + if doc.SupplierAlias != nil { + supplierAlias = *doc.SupplierAlias + } + docSuppliers = append(docSuppliers, HppPerKandangSupplierRow{ + KandangID: doc.KandangID, + SupplierID: *doc.SupplierID, + SupplierName: supplierName, + SupplierAlias: supplierAlias, + Category: "DOC", + }) + } + } + } + + budgetRows := make([]struct { + KandangID uint + BudgetCost float64 + }, 0) + + pfkUsageSub := r.db. + Table("project_chickins AS pc"). + Select(` + pc.project_flock_kandang_id, + SUM(pc.usage_qty) AS kandang_usage_qty`). + Group("pc.project_flock_kandang_id") + + projectUsageSub := r.db. + Table("project_chickins AS pc"). + Select(` + pfk.project_flock_id, + SUM(pc.usage_qty) AS project_usage_qty`). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = pc.project_flock_kandang_id"). + Group("pfk.project_flock_id") + + budgetQuery := r.db.WithContext(ctx). + Table("project_flock_kandangs AS pfk"). + Select(` + k.id AS kandang_id, + COALESCE(SUM((pb.qty * pb.price) * COALESCE(k_usage.kandang_usage_qty, 0) / NULLIF(p_usage.project_usage_qty, 0)), 0) AS budget_cost`). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Joins("JOIN locations AS loc ON loc.id = k.location_id"). + Joins("JOIN project_budgets AS pb ON pb.project_flock_id = pfk.project_flock_id"). + Joins("LEFT JOIN (?) AS k_usage ON k_usage.project_flock_kandang_id = pfk.id", pfkUsageSub). + Joins("LEFT JOIN (?) AS p_usage ON p_usage.project_flock_id = pfk.project_flock_id", projectUsageSub). + Where("pfk.id IN (?)", recordingPfk.Session(&gorm.Session{NewDB: true})). + Group("k.id") + budgetQuery = applyLocationFilters(budgetQuery, areaIDs, locationIDs, kandangIDs) + + if err := budgetQuery.Scan(&budgetRows).Error; err != nil { + return nil, nil, err + } + + for _, budget := range budgetRows { + entry, ok := costMap[budget.KandangID] + if !ok { + rows = append(rows, HppPerKandangCostRow{ + KandangID: budget.KandangID, + }) + entry = &rows[len(rows)-1] + costMap[budget.KandangID] = entry + } + entry.BudgetCost += budget.BudgetCost + } + + expenseRows := make([]struct { + KandangID uint + ExpenseCost float64 + }, 0) + + expenseQuery := r.db.WithContext(ctx). + Table("project_flock_kandangs AS pfk"). + Select(` + k.id AS kandang_id, + COALESCE(SUM(er.qty * er.price), 0) AS expense_cost`). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Joins("JOIN locations AS loc ON loc.id = k.location_id"). + Joins("JOIN expense_nonstocks AS en ON en.project_flock_kandang_id = pfk.id"). + Joins("JOIN expense_realizations AS er ON er.expense_nonstock_id = en.id"). + Where("pfk.id IN (?)", recordingPfk.Session(&gorm.Session{NewDB: true})). + Group("k.id") + expenseQuery = applyLocationFilters(expenseQuery, areaIDs, locationIDs, kandangIDs) + + if err := expenseQuery.Scan(&expenseRows).Error; err != nil { + return nil, nil, err + } + + for _, exp := range expenseRows { + entry, ok := costMap[exp.KandangID] + if !ok { + rows = append(rows, HppPerKandangCostRow{ + KandangID: exp.KandangID, + }) + entry = &rows[len(rows)-1] + costMap[exp.KandangID] = entry + } + entry.ExpenseCost += exp.ExpenseCost + } + + feedSuppliers := make([]HppPerKandangSupplierRow, 0) + + feedQuery := r.db.WithContext(ctx). + Table("recordings AS r"). + Select("DISTINCT k.id AS kandang_id, s.id AS supplier_id, s.name AS supplier_name, s.alias AS supplier_alias"). + 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 locations AS loc ON loc.id = k.location_id"). + Joins("LEFT JOIN recording_stocks AS rs ON rs.recording_id = r.id"). + Joins("LEFT JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.status = ?", fifo.UsableKeyRecordingStock.String(), entity.StockAllocationStatusActive). + Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", purchaseStockableKey). + Joins("LEFT JOIN purchases AS pur ON pur.id = pi.purchase_id"). + Joins("LEFT JOIN suppliers AS s ON s.id = pur.supplier_id"). + Joins("LEFT JOIN flags AS f ON f.flagable_id = pi.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Where("f.name IN ?", []utils.FlagType{utils.FlagPakan, utils.FlagOVK}). + Where("r.project_flock_kandangs_id IN (?)", recordingPfk.Session(&gorm.Session{NewDB: true})). + Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL") + feedQuery = applyLocationFilters(feedQuery, areaIDs, locationIDs, kandangIDs) + + if err := feedQuery.Scan(&feedSuppliers).Error; err != nil { + return nil, nil, err + } + + for i := range feedSuppliers { + if _, exists := costMap[feedSuppliers[i].KandangID]; !exists { + rows = append(rows, HppPerKandangCostRow{ + KandangID: feedSuppliers[i].KandangID, + }) + costMap[feedSuppliers[i].KandangID] = &rows[len(rows)-1] + } + feedSuppliers[i].Category = "FEED" + } + + supplierRows := append(docSuppliers, feedSuppliers...) + + return rows, supplierRows, nil +} + +func applyLocationFilters(query *gorm.DB, areaIDs, locationIDs, kandangIDs []int64) *gorm.DB { + if len(areaIDs) > 0 { + query = query.Where("loc.area_id IN ?", areaIDs) + } + if len(locationIDs) > 0 { + query = query.Where("k.location_id IN ?", locationIDs) + } + if len(kandangIDs) > 0 { + query = query.Where("k.id IN ?", kandangIDs) + } + return query +} diff --git a/internal/modules/repports/route.go b/internal/modules/repports/route.go index 45dc32b7..707ef878 100644 --- a/internal/modules/repports/route.go +++ b/internal/modules/repports/route.go @@ -18,4 +18,6 @@ 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) + } diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index f9642bd2..06765998 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -2,6 +2,12 @@ package service import ( "context" + "fmt" + "math" + "sort" + "strconv" + "strings" + "time" "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto" repportRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/repositories" @@ -28,6 +34,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) + GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error) } type repportService struct { @@ -40,6 +47,16 @@ type repportService struct { RecordingRepo recordingRepo.RecordingRepository ApprovalSvc approvalService.ApprovalService PurchaseSupplierRepo repportRepo.PurchaseSupplierRepository + HppPerKandangRepo repportRepo.HppPerKandangRepository +} + +type HppCostAggregate struct { + FeedCost float64 + OvkCost float64 + DocCost float64 + DocQty float64 + BudgetCost float64 + ExpenseCost float64 } func NewRepportService( @@ -51,6 +68,7 @@ func NewRepportService( recordingRepo recordingRepo.RecordingRepository, approvalSvc approvalService.ApprovalService, purchaseSupplierRepo repportRepo.PurchaseSupplierRepository, + hppPerKandangRepo repportRepo.HppPerKandangRepository, ) RepportService { return &repportService{ Log: utils.Log, @@ -62,6 +80,7 @@ func NewRepportService( RecordingRepo: recordingRepo, ApprovalSvc: approvalSvc, PurchaseSupplierRepo: purchaseSupplierRepo, + HppPerKandangRepo: hppPerKandangRepo, } } @@ -264,3 +283,438 @@ func (s *repportService) GetPurchaseSupplier(c *fiber.Ctx, params *validation.Pu return result, totalSuppliers, nil } + +func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error) { + params, filters, err := s.parseHppPerKandangQuery(ctx) + if err != nil { + return nil, nil, err + } + + if err := s.Validate.Struct(params); err != nil { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + location, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + return nil, nil, fiber.NewError(fiber.StatusInternalServerError, "failed to load timezone configuration") + } + + periodDate, err := time.ParseInLocation("2006-01-02", params.Period, location) + if err != nil { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "period must follow format YYYY-MM-DD") + } + + startOfDay := time.Date(periodDate.Year(), periodDate.Month(), periodDate.Day(), 0, 0, 0, 0, location) + endOfDay := startOfDay.Add(24 * time.Hour) + + repoRows, err := s.HppPerKandangRepo.GetRowsByPeriod(ctx.Context(), startOfDay, endOfDay, params.AreaIDs, params.LocationIDs, params.KandangIDs) + if err != nil { + return nil, nil, err + } + costRows, supplierRows, err := s.HppPerKandangRepo.GetFeedOvkDocCostByPeriod(ctx.Context(), startOfDay, endOfDay, params.AreaIDs, params.LocationIDs, params.KandangIDs) + if err != nil { + return nil, nil, err + } + costMap := make(map[uint]HppCostAggregate, len(costRows)) + for _, row := range costRows { + costMap[row.KandangID] = HppCostAggregate{ + FeedCost: row.FeedCost, + OvkCost: row.OvkCost, + DocCost: row.DocCost, + DocQty: row.DocQty, + BudgetCost: row.BudgetCost, + ExpenseCost: row.ExpenseCost, + } + } + + docSupplierMap := make(map[uint][]dto.HppPerKandangSupplierDTO) + feedSupplierMap := make(map[uint][]dto.HppPerKandangSupplierDTO) + docSeen := make(map[uint]map[uint]bool) + feedSeen := make(map[uint]map[uint]bool) + + for _, sup := range supplierRows { + if sup.SupplierID == 0 { + continue + } + + targetMap := feedSupplierMap + seen := feedSeen + category := "FEED" + if strings.EqualFold(sup.Category, "DOC") { + targetMap = docSupplierMap + seen = docSeen + category = "DOC" + } + + if seen[sup.KandangID] == nil { + seen[sup.KandangID] = make(map[uint]bool) + } + if seen[sup.KandangID][sup.SupplierID] { + continue + } + seen[sup.KandangID][sup.SupplierID] = true + + targetMap[sup.KandangID] = append(targetMap[sup.KandangID], dto.HppPerKandangSupplierDTO{ + ID: int64(sup.SupplierID), + Name: sup.SupplierName, + Alias: sup.SupplierAlias, + Category: category, + }) + } + + type weightRangeKey struct { + Min float64 + Max float64 + } + type weightRangeAggregate struct { + Summary *dto.HppPerKandangSummaryWeightRangeDTO + EggHppSum float64 + EggHppCount int + } + + dataRows := make([]dto.HppPerKandangRowDTO, 0, len(repoRows)) + perRangeMap := make(map[weightRangeKey]*weightRangeAggregate) + var totalBirds int64 + var totalWeight float64 + var totalEggPieces int64 + var totalEggKg float64 + var totalRemainingValueRp int64 + var totalEggValueRp int64 + var totalHppSum float64 + var totalHppCount int + var totalDocPriceSum float64 + var totalDocPriceCount int + var totalEggHppSum float64 + var totalEggHppCount int + + for _, row := range repoRows { + birdsFloat := row.RemainingChickenBirds + if math.IsNaN(birdsFloat) || math.IsInf(birdsFloat, 0) { + birdsFloat = 0 + } + weightFloat := row.RemainingChickenWeight + if math.IsNaN(weightFloat) || math.IsInf(weightFloat, 0) { + weightFloat = 0 + } + eggPiecesFloat := row.EggProductionPieces + if math.IsNaN(eggPiecesFloat) || math.IsInf(eggPiecesFloat, 0) { + eggPiecesFloat = 0 + } + eggWeightFloat := row.EggProductionWeightKg + if math.IsNaN(eggWeightFloat) || math.IsInf(eggWeightFloat, 0) { + eggWeightFloat = 0 + } + + avgWeight := 0.0 + if birdsFloat > 0 { + avgWeight = weightFloat / birdsFloat + } + weightMin := math.Floor(avgWeight*10) / 10 + if weightMin < 0 { + weightMin = 0 + } + weightMax := weightMin + 0.09 + rangeKey := weightRangeKey{Min: weightMin, Max: weightMax} + + rowBirds := int64(math.Round(birdsFloat)) + costEntry := costMap[row.KandangID] + totalCost := costEntry.FeedCost + costEntry.OvkCost + costEntry.DocCost + costEntry.BudgetCost + costEntry.ExpenseCost + hppRp := 0.0 + if weightFloat > 0 { + hppRp = totalCost / weightFloat + } + eggHpp := 0.0 + if eggWeightFloat > 0 { + eggHpp = totalCost / eggWeightFloat + } + + rowEggPieces := int64(math.Round(eggPiecesFloat)) + rowEggValue := int64(eggHpp * eggWeightFloat) + rowRemainingValue := int64(hppRp * weightFloat) + avgDocPrice := int64(0) + if costEntry.DocQty > 0 { + avgDocPrice = int64(math.Round(costEntry.DocCost / costEntry.DocQty)) + } + + dataRows = append(dataRows, dto.HppPerKandangRowDTO{ + ID: int(row.KandangID), + Kandang: dto.HppPerKandangRowKandangDTO{ + ID: int64(row.KandangID), + Name: row.KandangName, + Status: row.KandangStatus, + Location: dto.HppPerKandangLocationDTO{ + ID: int64(row.LocationID), + Name: row.LocationName, + }, + Pic: dto.HppPerKandangPICDTO{ + ID: int64(row.PicID), + Name: row.PicName, + }, + }, + WeightRange: dto.HppPerKandangWeightRangeDTO{ + WeightMin: weightMin, + WeightMax: weightMax, + }, + RemainingChickenBirds: rowBirds, + RemainingChickenWeightKg: weightFloat, + AvgWeightKg: avgWeight, + // FeedCostRp: costEntry.FeedCost, + // OvkCostRp: costEntry.OvkCost, + DocSuppliers: docSupplierMap[row.KandangID], + FeedSuppliers: feedSupplierMap[row.KandangID], + EggProductionPieces: rowEggPieces, + EggProductionKg: eggWeightFloat, + AverageDocPriceRp: avgDocPrice, + HppRp: hppRp, + EggHppRpPerKg: eggHpp, + RemainingValueRp: rowRemainingValue, + EggValueRp: rowEggValue, + }) + + totalBirds += rowBirds + totalWeight += weightFloat + totalEggPieces += rowEggPieces + totalEggKg += eggWeightFloat + totalRemainingValueRp += rowRemainingValue + totalEggValueRp += rowEggValue + if weightFloat > 0 { + totalHppSum += hppRp + totalHppCount++ + } + if avgDocPrice > 0 { + totalDocPriceSum += float64(avgDocPrice) + totalDocPriceCount++ + } + if eggWeightFloat > 0 { + totalEggHppSum += eggHpp + totalEggHppCount++ + } + + rangeAgg, exists := perRangeMap[rangeKey] + if !exists { + rangeAgg = &weightRangeAggregate{ + Summary: &dto.HppPerKandangSummaryWeightRangeDTO{ + WeightRange: dto.HppPerKandangWeightRangeDTO{ + WeightMin: weightMin, + WeightMax: weightMax, + }, + Label: fmt.Sprintf("%.2f - %.2f", weightMin, weightMax), + }, + } + perRangeMap[rangeKey] = rangeAgg + } + + rangeSummary := rangeAgg.Summary + rangeSummary.RemainingChickenBirds += rowBirds + rangeSummary.RemainingChickenWeightKg += row.RemainingChickenWeight + rangeSummary.EggProductionPieces += rowEggPieces + rangeSummary.EggProductionKg += eggWeightFloat + rangeSummary.RemainingValueRp += rowRemainingValue + rangeSummary.EggValueRp += rowEggValue + if eggWeightFloat > 0 { + rangeAgg.EggHppSum += eggHpp + rangeAgg.EggHppCount++ + } + } + + rangeKeys := make([]weightRangeKey, 0, len(perRangeMap)) + for key := range perRangeMap { + rangeKeys = append(rangeKeys, key) + } + sort.Slice(rangeKeys, func(i, j int) bool { + if rangeKeys[i].Min == rangeKeys[j].Min { + return rangeKeys[i].Max < rangeKeys[j].Max + } + return rangeKeys[i].Min < rangeKeys[j].Min + }) + + perRangeSummary := make([]dto.HppPerKandangSummaryWeightRangeDTO, 0, len(rangeKeys)) + for idx, key := range rangeKeys { + agg := perRangeMap[key] + entry := agg.Summary + entry.ID = idx + 1 + if entry.RemainingChickenBirds > 0 { + entry.AvgWeightKg = entry.RemainingChickenWeightKg / float64(entry.RemainingChickenBirds) + } + if agg.EggHppCount > 0 { + entry.EggHppRpPerKg = agg.EggHppSum / float64(agg.EggHppCount) + } + perRangeSummary = append(perRangeSummary, *entry) + } + + totalSummary := dto.HppPerKandangSummaryTotalDTO{ + TotalRemainingChickenBirds: totalBirds, + TotalRemainingChickenWeightKg: totalWeight, + TotalEggProductionPieces: totalEggPieces, + TotalEggProductionKg: totalEggKg, + TotalRemainingValueRp: totalRemainingValueRp, + TotalEggValueRp: totalEggValueRp, + } + if totalBirds > 0 { + totalSummary.AverageWeightKg = totalWeight / float64(totalBirds) + } + if totalEggHppCount > 0 { + totalSummary.AverageEggHppRpPerKg = totalEggHppSum / float64(totalEggHppCount) + } + if totalHppCount > 0 { + totalSummary.TotalHppRp = totalHppSum / float64(totalHppCount) + } + if totalDocPriceCount > 0 { + totalSummary.TotalAverageDocPriceRp = totalDocPriceSum / float64(totalDocPriceCount) + } + + limit := params.Limit + if limit <= 0 { + limit = 10 + } + totalCount := len(dataRows) + offset := (params.Page - 1) * limit + if offset < 0 { + offset = 0 + } + if offset > totalCount { + offset = totalCount + } + end := offset + limit + if end > totalCount { + end = totalCount + } + pagedRows := dataRows[offset:end] + + data := dto.HppPerKandangResponseData{ + Period: params.Period, + Rows: pagedRows, + Summary: dto.HppPerKandangSummaryDTO{ + PerWeightRange: perRangeSummary, + Total: totalSummary, + }, + } + + totalResults := int64(totalCount) + + totalPages := int64(0) + if totalResults > 0 { + totalPages = int64(math.Ceil(float64(totalResults) / float64(limit))) + } + if totalPages == 0 { + totalPages = 1 + } + + meta := &dto.HppPerKandangMetaDTO{ + Page: params.Page, + Limit: limit, + TotalPages: totalPages, + TotalResults: totalResults, + Filters: filters, + } + + return &data, meta, nil +} + +func (s *repportService) parseHppPerKandangQuery(ctx *fiber.Ctx) (*validation.HppPerKandangQuery, dto.HppPerKandangFiltersDTO, error) { + page := ctx.QueryInt("page", 1) + if page < 1 { + page = 1 + } + limit := ctx.QueryInt("limit", 10) + if limit < 1 { + limit = 10 + } + + rawArea := ctx.Query("area_id", "") + rawLocation := ctx.Query("location_id", "") + rawKandang := ctx.Query("kandang_id", "") + rawWeightMin := ctx.Query("weight_min", "") + rawWeightMax := ctx.Query("weight_max", "") + period := ctx.Query("period", "") + showUnrecorded := ctx.QueryBool("show_unrecorded", false) + + areaIDs, err := parseCommaSeparatedInt64s(rawArea) + if err != nil { + return nil, dto.HppPerKandangFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + locationIDs, err := parseCommaSeparatedInt64s(rawLocation) + if err != nil { + return nil, dto.HppPerKandangFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + kandangIDs, err := parseCommaSeparatedInt64s(rawKandang) + if err != nil { + return nil, dto.HppPerKandangFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + weightMin, err := parseOptionalFloat64(rawWeightMin) + if err != nil { + return nil, dto.HppPerKandangFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + weightMax, err := parseOptionalFloat64(rawWeightMax) + if err != nil { + return nil, dto.HppPerKandangFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + params := &validation.HppPerKandangQuery{ + Page: page, + Limit: limit, + Period: period, + ShowUnrecorded: showUnrecorded, + AreaIDs: areaIDs, + LocationIDs: locationIDs, + KandangIDs: kandangIDs, + WeightMin: weightMin, + WeightMax: weightMax, + } + + showUnrecordedFilter := "" + if showUnrecorded { + showUnrecordedFilter = "true" + } + + filters := dto.NewHppPerKandangFiltersDTO( + rawArea, + rawLocation, + rawKandang, + rawWeightMin, + rawWeightMax, + period, + showUnrecordedFilter, + ) + + return params, filters, nil +} + +func parseCommaSeparatedInt64s(raw string) ([]int64, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, 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, fmt.Errorf("invalid integer value '%s'", part) + } + result = append(result, id) + } + + return result, nil +} + +func parseOptionalFloat64(raw string) (*float64, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + + value, err := strconv.ParseFloat(raw, 64) + if err != nil { + return nil, fmt.Errorf("invalid float value '%s'", raw) + } + + return &value, nil +} diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index f1f46c6d..47a711cc 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -42,3 +42,15 @@ type PurchaseSupplierQuery struct { SortBy string `query:"sort_by" validate:"omitempty"` FilterBy string `query:"filter_by" validate:"omitempty"` } + +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"` + Period string `query:"period" validate:"required"` + ShowUnrecorded bool `query:"show_unrecorded"` + AreaIDs []int64 `query:"-"` + LocationIDs []int64 `query:"-"` + KandangIDs []int64 `query:"-"` + WeightMin *float64 `query:"-"` + WeightMax *float64 `query:"-"` +} diff --git a/test/integration/inventory/transfers/transfer_fifo_integration_test.go b/test/integration/inventory/transfers/transfer_fifo_integration_test.go new file mode 100644 index 00000000..d9f127a1 --- /dev/null +++ b/test/integration/inventory/transfers/transfer_fifo_integration_test.go @@ -0,0 +1,304 @@ +package test + +import ( + "context" + "math" + "strings" + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" +) + +// Test Transfer FIFO with Purchase as initial stockable +func TestTransferFIFO_PurchaseToTransfer(t *testing.T) { + db, fifoSvc := setupTransferFIFOTest(t) + ctx := context.Background() + + // Setup warehouses + sourcePW := createProductWarehouseRow(t, db, 100) // 100 qty from purchase + destPW := createProductWarehouseRow(t, db, 0) // 0 qty initially + + // Step 1: Simulate Purchase - Replenish stock to source warehouse + purchaseStockableKey := fifo.StockableKey("PURCHASE_ITEMS") + if _, err := fifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ + StockableKey: purchaseStockableKey, + StockableID: 1, // PurchaseItem ID + ProductWarehouseID: sourcePW.Id, + Quantity: 100, + }); err != nil { + t.Fatalf("Failed to replenish from purchase: %v", err) + } + + // Verify source warehouse has stock + assertWarehouseQuantity(t, db, sourcePW.Id, 100) + assertAllocationCount(t, db, 1) // 1 allocation from purchase + + // Step 2: Create Transfer - will consume from source (usable) and replenish to dest (stockable) + + // Register Transfer as Usable (source warehouse - STOCK_TRANSFER_OUT) + transferUsableKey := fifo.UsableKey("STOCK_TRANSFER_OUT") + if err := fifoSvc.RegisterUsable(fifo.UsableConfig{ + Key: transferUsableKey, + Table: "stock_transfer_details", + Columns: fifo.UsableColumns{ + ID: "id", + ProductWarehouseID: "source_product_warehouse_id", + UsageQuantity: "usage_qty", + PendingQuantity: "pending_qty", + CreatedAt: "created_at", + }, + }); err != nil && !strings.Contains(strings.ToLower(err.Error()), "already registered") { + t.Fatalf("Failed to register STOCK_TRANSFER_OUT as Usable: %v", err) + } + + // Register Transfer as Stockable (destination warehouse - STOCK_TRANSFER_IN) + transferStockableKey := fifo.StockableKey("STOCK_TRANSFER_IN") + if err := fifoSvc.RegisterStockable(fifo.StockableConfig{ + Key: transferStockableKey, + Table: "stock_transfer_details", + Columns: fifo.StockableColumns{ + ID: "id", + ProductWarehouseID: "dest_product_warehouse_id", + TotalQuantity: "total_qty", + TotalUsedQuantity: "total_used", + CreatedAt: "created_at", + }, + }); err != nil && !strings.Contains(strings.ToLower(err.Error()), "already registered") { + t.Fatalf("Failed to register STOCK_TRANSFER_IN as Stockable: %v", err) + } + + // Create transfer detail record + transferDetail := entity.StockTransferDetail{ + Id: 1, + StockTransferId: 1, + ProductId: 1, + SourceProductWarehouseID: uint64Ptr(uint64(sourcePW.Id)), + DestProductWarehouseID: uint64Ptr(uint64(destPW.Id)), + UsageQty: 0, + PendingQty: 0, + TotalQty: 0, + TotalUsed: 0, + } + transferDetailID := uint(transferDetail.Id) + if err := db.Create(&transferDetail).Error; err != nil { + t.Fatalf("Failed to create transfer detail: %v", err) + } + + transferQty := 50.0 + + // Consume from source warehouse (STOCK_TRANSFER_OUT) + consumeResult, err := fifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ + UsableKey: "STOCK_TRANSFER_OUT", + UsableID: transferDetailID, + ProductWarehouseID: sourcePW.Id, + Quantity: transferQty, + AllowPending: false, // Don't allow pending + }) + if err != nil { + t.Fatalf("Failed to consume from source warehouse: %v", err) + } + + // Verify consumption + if mathAbs(consumeResult.UsageQuantity-transferQty) > 1e-6 { + t.Fatalf("Expected usage quantity %.2f, got %.2f", transferQty, consumeResult.UsageQuantity) + } + if mathAbs(consumeResult.PendingQuantity) > 1e-6 { + t.Fatalf("Expected pending quantity 0, got %.2f", consumeResult.PendingQuantity) + } + + // Update transfer detail usable fields + if err := db.Model(&entity.StockTransferDetail{}). + Where("id = ?", transferDetail.Id). + Updates(map[string]interface{}{ + "usage_qty": consumeResult.UsageQuantity, + "pending_qty": consumeResult.PendingQuantity, + }).Error; err != nil { + t.Fatalf("Failed to update transfer detail usable fields: %v", err) + } + + // Verify source warehouse decreased + assertWarehouseQuantity(t, db, sourcePW.Id, 50) // 100 - 50 = 50 + + // Verify allocation updated - should have 50 allocated to transfer + allocations := fetchAllocationsByUsable(t, db, "STOCK_TRANSFER_OUT", transferDetailID) + if len(allocations) != 1 { + t.Fatalf("Expected 1 allocation, got %d", len(allocations)) + } + if mathAbs(allocations[0].Qty-transferQty) > 1e-6 { + t.Fatalf("Expected allocation qty %.2f, got %.2f", transferQty, allocations[0].Qty) + } + + // Replenish to destination warehouse (STOCK_TRANSFER_IN) + note := "Transfer #1" + replenishResult, err := fifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ + StockableKey: "STOCK_TRANSFER_IN", + StockableID: transferDetailID, + ProductWarehouseID: destPW.Id, + Quantity: transferQty, + Note: ¬e, + }) + if err != nil { + t.Fatalf("Failed to replenish to destination warehouse: %v", err) + } + + // Verify replenishment + if mathAbs(replenishResult.AddedQuantity-transferQty) > 1e-6 { + t.Fatalf("Expected added quantity %.2f, got %.2f", transferQty, replenishResult.AddedQuantity) + } + + // Update transfer detail stockable fields + if err := db.Model(&entity.StockTransferDetail{}). + Where("id = ?", transferDetail.Id). + Updates(map[string]interface{}{ + "total_qty": replenishResult.AddedQuantity, + }).Error; err != nil { + t.Fatalf("Failed to update transfer detail stockable fields: %v", err) + } + + // Verify destination warehouse increased + assertWarehouseQuantity(t, db, destPW.Id, transferQty) + + // Verify new stockable allocation created + stockableAllocations := fetchAllocationsByStockable(t, db, "STOCK_TRANSFER_IN", transferDetailID) + if len(stockableAllocations) != 1 { + t.Fatalf("Expected 1 stockable allocation, got %d", len(stockableAllocations)) + } + if mathAbs(stockableAllocations[0].Qty-transferQty) > 1e-6 { + t.Fatalf("Expected stockable allocation qty %.2f, got %.2f", transferQty, stockableAllocations[0].Qty) + } + + t.Logf("✅ Transfer FIFO test passed:") + t.Logf(" - Source warehouse: 100 → 50 (consumed %d)", int(transferQty)) + t.Logf(" - Destination warehouse: 0 → %d (replenished)", int(transferQty)) + t.Logf(" - Usable allocation: %.2f allocated to transfer", allocations[0].Qty) + t.Logf(" - Stockable allocation: %.2f available at destination", stockableAllocations[0].Qty) +} + +// Setup function for transfer FIFO test +func setupTransferFIFOTest(t *testing.T) (*gorm.DB, commonSvc.FifoService) { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatalf("open db: %v", err) + } + + if err := db.AutoMigrate( + &entity.ProductWarehouse{}, + &entity.StockAllocation{}, + &entity.StockTransferDetail{}, + ); err != nil { + t.Fatalf("auto migrate entities: %v", err) + } + + stockAllocRepo := commonRepo.NewStockAllocationRepository(db) + productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + fifoSvc := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log) + + // Register Purchase as Stockable + purchaseStockableKey := fifo.StockableKey("PURCHASE_ITEMS") + if err := fifoSvc.RegisterStockable(fifo.StockableConfig{ + Key: purchaseStockableKey, + Table: "purchase_items", + Columns: fifo.StockableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + TotalQuantity: "total_qty", + TotalUsedQuantity: "total_used", + CreatedAt: "created_at", + }, + }); err != nil && !strings.Contains(strings.ToLower(err.Error()), "already registered") { + t.Fatalf("register purchase stockable: %v", err) + } + + return db, fifoSvc +} + +// Helper functions + +func createProductWarehouseRow(t *testing.T, db *gorm.DB, qty float64) entity.ProductWarehouse { + t.Helper() + pw := entity.ProductWarehouse{ + ProductId: 1, + WarehouseId: 1, + Quantity: qty, + } + if err := db.Create(&pw).Error; err != nil { + t.Fatalf("create product warehouse: %v", err) + } + return pw +} + +func assertWarehouseQuantity(t *testing.T, db *gorm.DB, pwID uint, expected float64) { + t.Helper() + var pw entity.ProductWarehouse + if err := db.First(&pw, pwID).Error; err != nil { + t.Fatalf("fetch product warehouse %d: %v", pwID, err) + } + if mathAbs(pw.Quantity-expected) > 1e-6 { + t.Fatalf("expected warehouse quantity %.2f, got %.2f", expected, pw.Quantity) + } +} + +func assertAllocationCount(t *testing.T, db *gorm.DB, expected int) { + t.Helper() + var count int64 + if err := db.Model(&entity.StockAllocation{}).Count(&count).Error; err != nil { + t.Fatalf("count allocations: %v", err) + } + if int(count) != expected { + t.Fatalf("expected %d allocations, got %d", expected, count) + } +} + +func fetchAllocationsByUsable(t *testing.T, db *gorm.DB, usableType string, usableID uint) []entity.StockAllocation { + t.Helper() + var allocations []entity.StockAllocation + if err := db.Where("usable_type = ? AND usable_id = ?", usableType, usableID). + Find(&allocations).Error; err != nil { + t.Fatalf("fetch allocations by usable: %v", err) + } + return allocations +} + +func fetchAllocationsByStockable(t *testing.T, db *gorm.DB, stockableType string, stockableID uint) []entity.StockAllocation { + t.Helper() + var allocations []entity.StockAllocation + if err := db.Where("stockable_type = ? AND stockable_id = ?", stockableType, stockableID). + Find(&allocations).Error; err != nil { + t.Fatalf("fetch allocations by stockable: %v", err) + } + return allocations +} + +func floatPtr(f float64) *float64 { + return &f +} + +func uint64Ptr(u uint64) *uint64 { + return &u +} + +func mathAbs(f float64) float64 { + return math.Abs(f) +} + +func sanitizeKey(name string) string { + return strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { + return r + } + return '_' + }, name) +}