diff --git a/internal/common/repository/common.hpp.repository.go b/internal/common/repository/common.hpp.repository.go index d41387af..b053b0db 100644 --- a/internal/common/repository/common.hpp.repository.go +++ b/internal/common/repository/common.hpp.repository.go @@ -151,7 +151,7 @@ func (r *HppRepositoryImpl) GetFeedUsageCost(ctx context.Context, projectFlockKa ). Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase). Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment). - Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). + Where("rs.project_flock_kandang_id IN (?)", projectFlockKandangIDs). Where("r.record_datetime <= ?", *date). Where("f.name = ?", utils.FlagPakan). Scan(&total).Error @@ -202,7 +202,7 @@ func (r *HppRepositoryImpl) GetOvkUsageCost(ctx context.Context, projectFlockKan ). Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase). Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment). - Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). + Where("rs.project_flock_kandang_id IN (?)", projectFlockKandangIDs). Where("r.record_datetime <= ?", *date). Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flags). Scan(&total).Error diff --git a/internal/common/repository/common.hppv2.repository.go b/internal/common/repository/common.hppv2.repository.go index 81b59829..81750bdd 100644 --- a/internal/common/repository/common.hppv2.repository.go +++ b/internal/common/repository/common.hppv2.repository.go @@ -103,6 +103,7 @@ type HppV2CostRepository interface { GetProjectFlockKandangIDs(ctx context.Context, projectFlockId uint) ([]uint, error) GetLatestTransferInputByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint, period time.Time) (*HppV2LatestTransferInputRow, error) GetManualDepreciationInputByProjectFlockID(ctx context.Context, projectFlockID uint) (*HppV2ManualDepreciationInputRow, error) + GetRecordingStockRoutingAdjustmentCostByProjectFlockID(ctx context.Context, projectFlockID uint, periodDate time.Time) (float64, error) GetFarmDepreciationSnapshotByProjectFlockIDAndPeriod(ctx context.Context, projectFlockID uint, periodDate time.Time) (*HppV2FarmDepreciationSnapshotRow, error) GetEarliestChickInDateByProjectFlockID(ctx context.Context, projectFlockID uint) (*time.Time, error) GetDepreciationPercents(ctx context.Context, houseTypes []string, maxDay int) (map[string]map[int]float64, error) @@ -249,6 +250,50 @@ func (r *HppV2RepositoryImpl) GetManualDepreciationInputByProjectFlockID( return &row, nil } +func (r *HppV2RepositoryImpl) GetRecordingStockRoutingAdjustmentCostByProjectFlockID( + ctx context.Context, + projectFlockID uint, + periodDate time.Time, +) (float64, error) { + if projectFlockID == 0 || periodDate.IsZero() { + return 0, nil + } + + flags := []utils.FlagType{ + utils.FlagPakan, + utils.FlagOVK, + utils.FlagObat, + utils.FlagVitamin, + utils.FlagKimia, + } + + var total float64 + err := r.db.WithContext(ctx). + Table("recording_stocks AS rs"). + Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)"). + Joins("JOIN recordings AS r ON r.id = rs.recording_id AND r.deleted_at IS NULL"). + Joins("JOIN project_flock_kandangs AS pfk_rec ON pfk_rec.id = r.project_flock_kandangs_id"). + Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id"). + Joins( + "JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", + fifo.UsableKeyRecordingStock.String(), + fifo.StockableKeyPurchaseItems.String(), + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + ). + Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). + Where("pfk_rec.project_flock_id = ?", projectFlockID). + Where("DATE(r.record_datetime) <= DATE(?)", periodDate). + Where("(rs.project_flock_kandang_id IS NULL OR rs.project_flock_kandang_id <> r.project_flock_kandangs_id)"). + Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flags). + Scan(&total).Error + if err != nil { + return 0, err + } + + return total, nil +} + func (r *HppV2RepositoryImpl) GetFarmDepreciationSnapshotByProjectFlockIDAndPeriod( ctx context.Context, projectFlockID uint, @@ -393,7 +438,7 @@ func (r *HppV2RepositoryImpl) ListUsageCostRowsByProductFlags( Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment). Joins("LEFT JOIN product_warehouses AS ast_pw ON ast_pw.id = ast.product_warehouse_id"). Joins("LEFT JOIN products AS ast_prod ON ast_prod.id = ast_pw.product_id"). - Where("r.project_flock_kandangs_id IN ?", projectFlockKandangIDs). + Where("rs.project_flock_kandang_id IN ?", projectFlockKandangIDs). Where("r.record_datetime <= ?", *date). Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flagNames). Group(` @@ -755,7 +800,7 @@ func (r *HppV2RepositoryImpl) GetFeedUsageCost(ctx context.Context, projectFlock ). Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase). Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment). - Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). + Where("rs.project_flock_kandang_id IN (?)", projectFlockKandangIDs). Where("r.record_datetime <= ?", *date). Where("f.name = ?", utils.FlagPakan). Scan(&total).Error diff --git a/internal/common/repository/common.hppv2.repository_test.go b/internal/common/repository/common.hppv2.repository_test.go index 7aefad44..5e60f8c2 100644 --- a/internal/common/repository/common.hppv2.repository_test.go +++ b/internal/common/repository/common.hppv2.repository_test.go @@ -96,6 +96,54 @@ func TestHppV2RepositoryGetEggTerjualProratesHistoricalFarmSalesFromAdjustments( assertFloatEquals(t, totalWeightKg, 1.4) } +func TestHppV2RepositoryGetRecordingStockRoutingAdjustmentCostByProjectFlockID(t *testing.T) { + db := setupHppV2RepositoryTestDB(t) + + mustExecHppV2(t, db, + `INSERT INTO project_flock_kandangs (id, kandang_id, project_flock_id) VALUES (101, 1, 1), (201, 2, 2)`, + `INSERT INTO recordings (id, project_flock_kandangs_id, record_datetime, deleted_at) VALUES + (1, 101, '2026-04-10 08:00:00', NULL), + (2, 101, '2026-04-11 08:00:00', NULL), + (3, 101, '2026-04-12 08:00:00', NULL)`, + `INSERT INTO product_warehouses (id, warehouse_id, product_id, project_flock_kandang_id) VALUES + (501, 201, 10, NULL), + (502, 201, 11, NULL), + (503, 201, 12, NULL)`, + `INSERT INTO flags (id, flagable_type, flagable_id, name) VALUES + (10, 'products', 10, 'PAKAN'), + (11, 'products', 11, 'OVK'), + (12, 'products', 12, 'PAKAN')`, + `INSERT INTO recording_stocks (id, recording_id, product_warehouse_id, project_flock_kandang_id) VALUES + (101, 1, 501, NULL), + (102, 2, 502, 201), + (103, 3, 503, 101)`, + `INSERT INTO purchase_items (id, product_id, price) VALUES + (601, 10, 100), + (602, 11, 200), + (603, 12, 300)`, + `INSERT INTO stock_allocations (id, usable_type, usable_id, stockable_type, stockable_id, status, allocation_purpose, qty) VALUES + (9001, 'RECORDING_STOCK', 101, 'PURCHASE_ITEMS', 601, 'ACTIVE', 'CONSUME', 2), + (9002, 'RECORDING_STOCK', 102, 'PURCHASE_ITEMS', 602, 'ACTIVE', 'CONSUME', 1.5), + (9003, 'RECORDING_STOCK', 103, 'PURCHASE_ITEMS', 603, 'ACTIVE', 'CONSUME', 1)`, + ) + + repo := &HppV2RepositoryImpl{db: db} + + periodDate := mustJakartaTime(t, "2026-04-30 00:00:00") + total, err := repo.GetRecordingStockRoutingAdjustmentCostByProjectFlockID(context.Background(), 1, periodDate) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + assertFloatEquals(t, total, 500) + + earlyPeriod := mustJakartaTime(t, "2026-04-10 23:59:59") + earlyTotal, err := repo.GetRecordingStockRoutingAdjustmentCostByProjectFlockID(context.Background(), 1, earlyPeriod) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + assertFloatEquals(t, earlyTotal, 200) +} + func setupHppV2RepositoryTestDB(t *testing.T) *gorm.DB { t.Helper() @@ -111,6 +159,12 @@ func setupHppV2RepositoryTestDB(t *testing.T) *gorm.DB { record_datetime DATETIME NULL, deleted_at DATETIME NULL )`, + `CREATE TABLE recording_stocks ( + id INTEGER PRIMARY KEY, + recording_id INTEGER NULL, + product_warehouse_id INTEGER NULL, + project_flock_kandang_id INTEGER NULL + )`, `CREATE TABLE recording_eggs ( id INTEGER PRIMARY KEY, recording_id INTEGER NULL, @@ -174,6 +228,11 @@ func setupHppV2RepositoryTestDB(t *testing.T) *gorm.DB { id INTEGER PRIMARY KEY, product_warehouse_id INTEGER NULL )`, + `CREATE TABLE purchase_items ( + id INTEGER PRIMARY KEY, + product_id INTEGER NULL, + price NUMERIC(15,3) NULL + )`, `CREATE TABLE marketing_delivery_products ( id INTEGER PRIMARY KEY, marketing_product_id INTEGER NULL, diff --git a/internal/common/service/common.hppv2.service.go b/internal/common/service/common.hppv2.service.go index c392cf8b..661d23c2 100644 --- a/internal/common/service/common.hppv2.service.go +++ b/internal/common/service/common.hppv2.service.go @@ -16,6 +16,7 @@ const ( hppV2ComponentBopRegular = "BOP_REGULAR" hppV2ComponentBopEksp = "BOP_EKSPEDISI" hppV2ComponentManualPulletCost = "MANUAL_PULLET_COST" + hppV2ComponentRecordingStockRoute = "RECORDING_STOCK_ROUTE" hppV2ComponentDepreciation = "DEPRECIATION" hppV2PartGrowingNormal = "growing_normal" hppV2PartGrowingCutover = "growing_cutover" @@ -26,6 +27,7 @@ const ( hppV2PartLayingDirect = "laying_direct" hppV2PartLayingFarm = "laying_farm" hppV2PartManualCutover = "manual_cutover" + hppV2PartRecordingStockRoute = "recording_stock_route" hppV2PartDepreciationNormal = "normal_transfer" hppV2PartDepreciationCutover = "manual_cutover" hppV2PartDepreciationFarmSnapshot = "farm_snapshot" @@ -190,6 +192,12 @@ func (s *hppV2Service) CalculateHppBreakdown(projectFlockKandangId uint, date *t } appendComponent(hppV2ComponentManualPulletCost, manualPulletComponent) + recordingStockRouteComponent, err := s.getRecordingStockRouteComponent(projectFlockKandangId, contextRow, startOfDay) + if err != nil { + return nil, err + } + appendComponent(hppV2ComponentRecordingStockRoute, recordingStockRouteComponent) + depreciationComponent, err := s.getDepreciationComponent(projectFlockKandangId, contextRow, startOfDay, endOfDay, totalPulletCost) if err != nil { return nil, err @@ -1064,6 +1072,100 @@ func (s *hppV2Service) getManualPulletCostComponent( }, nil } +func (s *hppV2Service) getRecordingStockRouteComponent( + projectFlockKandangId uint, + contextRow *commonRepo.HppV2ProjectFlockKandangContext, + periodDate time.Time, +) (*HppV2Component, error) { + if s.hppRepo == nil || contextRow == nil || periodDate.IsZero() { + return nil, nil + } + + farmTotalCost, err := s.hppRepo.GetRecordingStockRoutingAdjustmentCostByProjectFlockID( + context.Background(), + contextRow.ProjectFlockID, + periodDate, + ) + if err != nil { + return nil, err + } + if farmTotalCost <= 0 { + return nil, nil + } + + farmPFKIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), contextRow.ProjectFlockID) + if err != nil { + return nil, err + } + if len(farmPFKIDs) == 0 { + return nil, nil + } + + totalPopulation, err := s.hppRepo.GetTotalPopulation(context.Background(), farmPFKIDs) + if err != nil { + return nil, err + } + if totalPopulation <= 0 { + return nil, nil + } + + targetPopulation, err := s.hppRepo.GetTotalPopulation(context.Background(), []uint{projectFlockKandangId}) + if err != nil { + return nil, err + } + if targetPopulation <= 0 { + return nil, nil + } + + ratio := targetPopulation / totalPopulation + if ratio <= 0 { + return nil, nil + } + + appliedTotal := farmTotalCost * ratio + if appliedTotal <= 0 { + return nil, nil + } + + part := HppV2ComponentPart{ + Code: hppV2PartRecordingStockRoute, + Title: "Recording Stock Route", + Scopes: []string{hppV2ScopePulletCost}, + Total: appliedTotal, + Proration: &HppV2Proration{ + Basis: hppV2ProrationPopulation, + Numerator: targetPopulation, + Denominator: totalPopulation, + Ratio: ratio, + }, + Details: map[string]any{ + "period_date": formatDateOnly(periodDate), + "farm_total_cost": farmTotalCost, + "target_population": targetPopulation, + "farm_population": totalPopulation, + "project_flock_id": contextRow.ProjectFlockID, + "project_flock_kandang_id": projectFlockKandangId, + }, + References: []HppV2Reference{ + { + Type: "recording_stock_route", + Date: formatDateOnly(periodDate), + Qty: 1, + Total: farmTotalCost, + AppliedTotal: appliedTotal, + }, + }, + } + + return &HppV2Component{ + Code: hppV2ComponentRecordingStockRoute, + Title: "Recording Stock Route", + Scopes: []string{hppV2ScopePulletCost}, + Total: appliedTotal, + Parts: []HppV2ComponentPart{part}, + }, nil +} + func (s *hppV2Service) getDepreciationComponent( projectFlockKandangId uint, contextRow *commonRepo.HppV2ProjectFlockKandangContext, diff --git a/internal/common/service/common.hppv2.service_test.go b/internal/common/service/common.hppv2.service_test.go index a2a8d27e..8ab515c6 100644 --- a/internal/common/service/common.hppv2.service_test.go +++ b/internal/common/service/common.hppv2.service_test.go @@ -25,6 +25,7 @@ type hppV2RepoStub struct { chickinRowsByKey map[string][]commonRepo.HppV2ChickinCostRow expenseRowsByPFKKey map[string][]commonRepo.HppV2ExpenseCostRow expenseRowsByFarmKey map[string][]commonRepo.HppV2ExpenseCostRow + routeCostByProject map[uint]float64 totalPopulationByKey map[string]float64 transferSummaryByPFK map[uint]struct { projectFlockID uint @@ -60,6 +61,10 @@ func (s *hppV2RepoStub) GetManualDepreciationInputByProjectFlockID(_ context.Con return s.manualInputByProject[projectFlockID], nil } +func (s *hppV2RepoStub) GetRecordingStockRoutingAdjustmentCostByProjectFlockID(_ context.Context, projectFlockID uint, _ time.Time) (float64, error) { + return s.routeCostByProject[projectFlockID], nil +} + func (s *hppV2RepoStub) GetFarmDepreciationSnapshotByProjectFlockIDAndPeriod(_ context.Context, projectFlockID uint, periodDate time.Time) (*commonRepo.HppV2FarmDepreciationSnapshotRow, error) { if s.snapshotByProjectKey == nil { return nil, nil diff --git a/internal/database/migrations/20260421101500_add_recording_stock_owner_and_standard_week.down.sql b/internal/database/migrations/20260421101500_add_recording_stock_owner_and_standard_week.down.sql new file mode 100644 index 00000000..473a75da --- /dev/null +++ b/internal/database/migrations/20260421101500_add_recording_stock_owner_and_standard_week.down.sql @@ -0,0 +1,17 @@ +BEGIN; + +DROP INDEX IF EXISTS idx_recording_stocks_project_flock_kandang_id; + +ALTER TABLE recording_stocks + DROP CONSTRAINT IF EXISTS fk_recording_stocks_project_flock_kandang_id; + +ALTER TABLE recording_stocks + DROP COLUMN IF EXISTS project_flock_kandang_id; + +ALTER TABLE house_depreciation_standards + DROP CONSTRAINT IF EXISTS chk_house_depreciation_standards_standard_week_positive; + +ALTER TABLE house_depreciation_standards + DROP COLUMN IF EXISTS standard_week; + +COMMIT; diff --git a/internal/database/migrations/20260421101500_add_recording_stock_owner_and_standard_week.up.sql b/internal/database/migrations/20260421101500_add_recording_stock_owner_and_standard_week.up.sql new file mode 100644 index 00000000..851b853d --- /dev/null +++ b/internal/database/migrations/20260421101500_add_recording_stock_owner_and_standard_week.up.sql @@ -0,0 +1,52 @@ +BEGIN; + +ALTER TABLE recording_stocks + ADD COLUMN IF NOT EXISTS project_flock_kandang_id BIGINT NULL; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_recording_stocks_project_flock_kandang_id' + ) THEN + ALTER TABLE recording_stocks + ADD CONSTRAINT fk_recording_stocks_project_flock_kandang_id + FOREIGN KEY (project_flock_kandang_id) + REFERENCES project_flock_kandangs(id) + ON DELETE SET NULL + ON UPDATE CASCADE; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_recording_stocks_project_flock_kandang_id + ON recording_stocks(project_flock_kandang_id); + +ALTER TABLE house_depreciation_standards + ADD COLUMN IF NOT EXISTS standard_week INT; + +UPDATE house_depreciation_standards +SET standard_week = CASE house_type::text + WHEN 'close_house' THEN 22 + WHEN 'open_house' THEN 25 + ELSE standard_week +END +WHERE standard_week IS NULL OR standard_week <= 0; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'chk_house_depreciation_standards_standard_week_positive' + ) THEN + ALTER TABLE house_depreciation_standards + ADD CONSTRAINT chk_house_depreciation_standards_standard_week_positive + CHECK (standard_week > 0); + END IF; +END $$; + +ALTER TABLE house_depreciation_standards + ALTER COLUMN standard_week SET NOT NULL; + +COMMIT; diff --git a/internal/entities/house_depreciation_standard.go b/internal/entities/house_depreciation_standard.go index 9300c94b..a125da29 100644 --- a/internal/entities/house_depreciation_standard.go +++ b/internal/entities/house_depreciation_standard.go @@ -6,6 +6,7 @@ type HouseDepreciationStandard struct { Id uint `gorm:"primaryKey"` HouseType string `gorm:"type:house_type_enum;not null;uniqueIndex:house_depreciation_standards_house_type_day_unique,priority:1"` DayNumber int `gorm:"column:day;not null;uniqueIndex:house_depreciation_standards_house_type_day_unique,priority:2"` + StandardWeek int `gorm:"column:standard_week;not null"` DepreciationPercent float64 `gorm:"type:numeric(15,6);not null"` CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` diff --git a/internal/entities/recording_stock.go b/internal/entities/recording_stock.go index 982bba37..29d47163 100644 --- a/internal/entities/recording_stock.go +++ b/internal/entities/recording_stock.go @@ -1,11 +1,12 @@ package entities type RecordingStock struct { - Id uint `gorm:"primaryKey"` - RecordingId uint `gorm:"column:recording_id;not null;index"` - ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` - UsageQty *float64 `gorm:"column:usage_qty"` - PendingQty *float64 `gorm:"column:pending_qty"` + Id uint `gorm:"primaryKey"` + RecordingId uint `gorm:"column:recording_id;not null;index"` + ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` + ProjectFlockKandangId *uint `gorm:"column:project_flock_kandang_id;index"` + UsageQty *float64 `gorm:"column:usage_qty"` + PendingQty *float64 `gorm:"column:pending_qty"` Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index b6d90a63..3e8071ce 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -98,6 +98,9 @@ func sapronakIncomingPurchaseQueryParts(params SapronakQueryParams) (string, []a fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume, + fifo.UsableKeyRecordingStock.String(), + params.ProjectFlockKandangIDs, + fifo.UsableKeyProjectChickin.String(), params.ProjectFlockKandangIDs, params.ProjectFlockKandangIDs, params.WarehouseIDs, @@ -323,7 +326,7 @@ func (r *ClosingRepositoryImpl) SumFeedPurchaseAndUsedByProjectFlockKandangIDs(c Joins("JOIN product_warehouses pw ON pw.id = rs.product_warehouse_id"). Joins("JOIN products prod ON prod.id = pw.product_id"). Joins("JOIN flags f ON f.flagable_id = prod.id AND f.flagable_type = ?", "products"). - Where("rec.project_flock_kandangs_id IN ?", projectFlockKandangIDs). + Where("rs.project_flock_kandang_id IN ?", projectFlockKandangIDs). Where("f.name = ?", "PAKAN"). Select("COALESCE(SUM(COALESCE(rs.usage_qty, 0) + COALESCE(rs.pending_qty, 0)), 0) AS total_used"). Scan(&usageAgg).Error @@ -905,7 +908,11 @@ WITH scoped_farm_allocations AS ( WHERE sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ? - AND COALESCE(rec.project_flock_kandangs_id, pc.project_flock_kandang_id) IN ? + AND ( + (sa.usable_type = ? AND rs.project_flock_kandang_id IN ?) + OR + (sa.usable_type = ? AND pc.project_flock_kandang_id IN ?) + ) GROUP BY sa.stockable_id ) SELECT @@ -1167,7 +1174,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsage(ctx context.Context, pfkID ui "recording_stocks rs", "pw.id = rs.product_warehouse_id", []string{"JOIN recordings r ON r.id = rs.recording_id AND r.deleted_at IS NULL"}, - "r.project_flock_kandangs_id = ? AND f.name IN ?", + "rs.project_flock_kandang_id = ? AND f.name IN ?", pfkID, sapronakFlagsUsage, ) @@ -1208,7 +1215,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageDetails(ctx context.Context, p COALESCE(rs.usage_qty,0) AS qty_out, COALESCE(p.product_price,0) AS price `, - "r.project_flock_kandangs_id = ? AND f.name IN ?", + "rs.project_flock_kandang_id = ? AND f.name IN ?", pfkID, sapronakFlagsUsage, ) @@ -1294,7 +1301,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()). Where("f.name IN ?", sapronakFlagsAll). Where(` - (sa.usable_type = ? AND r.project_flock_kandangs_id = ? AND f.name IN ?) + (sa.usable_type = ? AND rs.project_flock_kandang_id = ? AND f.name IN ?) OR (sa.usable_type = ? AND pc_used.project_flock_kandang_id = ? AND f.name IN ?) `, @@ -1347,7 +1354,12 @@ func (r *ClosingRepositoryImpl) incomingFarmPurchaseAllocationBase(ctx context.C Where("sa.status = ?", entity.StockAllocationStatusActive). Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume). Where("w.kandang_id IS NULL"). - Where("COALESCE(rec.project_flock_kandangs_id, pc.project_flock_kandang_id) = ?", projectFlockKandangID). + Where("(sa.usable_type = ? AND rs.project_flock_kandang_id = ?) OR (sa.usable_type = ? AND pc.project_flock_kandang_id = ?)", + fifo.UsableKeyRecordingStock.String(), + projectFlockKandangID, + fifo.UsableKeyProjectChickin.String(), + projectFlockKandangID, + ). Where("f.name IN ?", sapronakFlagsAll). Where("pi.received_date IS NOT NULL") db = applyDateRange(db, "pi.received_date", start, end) diff --git a/internal/modules/closings/repositories/closing.repository_test.go b/internal/modules/closings/repositories/closing.repository_test.go index 362dbef2..75dad419 100644 --- a/internal/modules/closings/repositories/closing.repository_test.go +++ b/internal/modules/closings/repositories/closing.repository_test.go @@ -20,8 +20,8 @@ func TestSapronakIncomingPurchaseQueryPartsUsesAttributedPurchasesWhenProjectFlo if sql != sapronakIncomingPurchasesScopedSQL() { t.Fatalf("expected scoped purchase SQL, got %q", sql) } - if len(args) != 8 { - t.Fatalf("expected 8 argument groups, got %d", len(args)) + if len(args) != 11 { + t.Fatalf("expected 11 argument groups, got %d", len(args)) } } @@ -42,7 +42,7 @@ func TestFetchSapronakIncomingIncludesAttributedFarmPurchasesAndHistoricalWareho (2, 10, 'products', 'OBAT')`, `INSERT INTO purchases (id, po_number, deleted_at) VALUES (1, 'PO-LTI-0005', NULL)`, `INSERT INTO recordings (id, project_flock_kandangs_id, deleted_at) VALUES (11, 101, NULL), (12, 999, NULL)`, - `INSERT INTO recording_stocks (id, recording_id, product_warehouse_id, usage_qty) VALUES (21, 11, 501, 150), (22, 12, 502, 10)`, + `INSERT INTO recording_stocks (id, recording_id, product_warehouse_id, usage_qty, project_flock_kandang_id) VALUES (21, 11, 501, 150, 101), (22, 12, 502, 10, 999)`, `INSERT INTO purchase_items (id, purchase_id, product_id, warehouse_id, project_flock_kandang_id, total_qty, price, received_date) VALUES (1, 1, 10, 1, NULL, 100, 261700, '` + receivedAt.Format(time.RFC3339) + `'), (2, 1, 20, 1, NULL, 50, 15000, '` + receivedAt.Format(time.RFC3339) + `'), @@ -145,11 +145,12 @@ func setupClosingRepositoryTestDB(t *testing.T) *gorm.DB { deleted_at TIMESTAMP NULL )`, `CREATE TABLE recording_stocks ( - id INTEGER PRIMARY KEY, - recording_id INTEGER NOT NULL, - product_warehouse_id INTEGER NOT NULL, - usage_qty NUMERIC(15,3) NOT NULL DEFAULT 0 - )`, + id INTEGER PRIMARY KEY, + recording_id INTEGER NOT NULL, + product_warehouse_id INTEGER NOT NULL, + usage_qty NUMERIC(15,3) NOT NULL DEFAULT 0, + project_flock_kandang_id INTEGER NULL + )`, `CREATE TABLE project_chickins ( id INTEGER PRIMARY KEY, project_flock_kandang_id INTEGER NOT NULL diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 8d9f5e1b..b888bec3 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -33,6 +33,7 @@ import ( "github.com/gofiber/fiber/v2" "github.com/sirupsen/logrus" "gorm.io/gorm" + "gorm.io/gorm/clause" ) type RecordingService interface { @@ -365,6 +366,14 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent } } + var stockOwnerProjectFlockKandangID *uint + if len(req.Stocks) > 0 { + stockOwnerProjectFlockKandangID, err = s.resolveRecordingStockOwnerProjectFlockKandangID(ctx, pfk, recordTime) + if err != nil { + return nil, err + } + } + day, err := s.computeRecordingDay(ctx, pfk.Id, recordTime) if err != nil { return nil, err @@ -441,12 +450,12 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent return err } - mappedStocks := recordingutil.MapStocks(createdRecording.Id, req.Stocks) - stockDesired := resetStockQuantitiesForFIFO(mappedStocks) - if err := s.Repository.CreateStocks(tx, mappedStocks); err != nil { - s.Log.Errorf("Failed to persist stocks: %+v", err) - return err - } + mappedStocks := recordingutil.MapStocks(createdRecording.Id, stockOwnerProjectFlockKandangID, req.Stocks) + stockDesired := resetStockQuantitiesForFIFO(mappedStocks) + if err := s.Repository.CreateStocks(tx, mappedStocks); err != nil { + s.Log.Errorf("Failed to persist stocks: %+v", err) + return err + } for i := range mappedStocks { if i >= len(stockDesired) { @@ -510,10 +519,14 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent s.Log.Errorf("Failed to compute recording metrics: %+v", err) return err } - if err := s.recalculateFrom(ctx, tx, createdRecording.ProjectFlockKandangId, createdRecording.RecordDatetime); err != nil { - s.Log.Errorf("Failed to recalculate recordings after create: %+v", err) - return err - } + if err := s.recalculateFrom(ctx, tx, createdRecording.ProjectFlockKandangId, createdRecording.RecordDatetime); err != nil { + s.Log.Errorf("Failed to recalculate recordings after create: %+v", err) + return err + } + if err := s.syncFarmDepreciationManualInputFromRecordingStocks(ctx, tx, createdRecording.ProjectFlockKandangId, createdRecording.RecordDatetime); err != nil { + s.Log.Errorf("Failed to sync farm depreciation manual input after create: %+v", err) + return err + } action := entity.ApprovalActionCreated if err := s.createRecordingApproval(ctx, tx, createdRecording.Id, utils.RecordingStepPengajuan, action, createdRecording.CreatedBy, nil); err != nil { @@ -574,8 +587,8 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin return err } - recordingEntity = recording - pfkForRoute := recordingEntity.ProjectFlockKandang + recordingEntity = recording + pfkForRoute := recordingEntity.ProjectFlockKandang if pfkForRoute == nil || pfkForRoute.Id == 0 { fetchedPfk, fetchErr := s.ProjectFlockKandangRepo.GetByIDLight(ctx, recordingEntity.ProjectFlockKandangId) if fetchErr != nil { @@ -586,35 +599,43 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin return fetchErr } pfkForRoute = fetchedPfk - } - routePayload := buildRecordingRoutePayloadFromUpdate(req) - if err := s.enforceTransferRecordingRoute(ctx, pfkForRoute, recordingEntity.RecordDatetime, routePayload); err != nil { - return err - } + } + if err := s.tryAutoExecuteTransferForRecordingCreate(c, pfkForRoute, recordingEntity.RecordDatetime); err != nil { + return err + } + routePayload := buildRecordingRoutePayloadFromUpdate(req) + if err := s.enforceTransferRecordingRoute(ctx, pfkForRoute, recordingEntity.RecordDatetime, routePayload); err != nil { + return err + } hasStockChanges := req.Stocks != nil hasDepletionChanges := req.Depletions != nil hasEggChanges := req.Eggs != nil - var existingStocks []entity.RecordingStock - var existingDepletions []entity.RecordingDepletion - var existingEggs []entity.RecordingEgg - var mappedDepletions []entity.RecordingDepletion + var existingStocks []entity.RecordingStock + var existingDepletions []entity.RecordingDepletion + var existingEggs []entity.RecordingEgg + var mappedDepletions []entity.RecordingDepletion + var stockOwnerProjectFlockKandangID *uint note := recordingutil.RecordingNote("Edit", recordingEntity.Id) - if hasStockChanges { - existingStocks, err = s.Repository.ListStocks(tx, recordingEntity.Id) - if err != nil { - s.Log.Errorf("Failed to list existing stocks: %+v", err) - return err - } + if hasStockChanges { + stockOwnerProjectFlockKandangID, err = s.resolveRecordingStockOwnerProjectFlockKandangID(ctx, pfkForRoute, recordingEntity.RecordDatetime) + if err != nil { + return err + } + existingStocks, err = s.Repository.ListStocks(tx, recordingEntity.Id) + if err != nil { + s.Log.Errorf("Failed to list existing stocks: %+v", err) + return err + } existingUsage := recordingutil.StockUsageByWarehouse(existingStocks) incomingUsage := recordingutil.StockUsageByWarehouseReq(req.Stocks) - match := recordingutil.FloatMapsEqual(existingUsage, incomingUsage) - if match { - hasStockChanges = false - } else { + match := recordingutil.FloatMapsEqual(existingUsage, incomingUsage) && recordingStocksAllOwnedBy(existingStocks, stockOwnerProjectFlockKandangID) + if match { + hasStockChanges = false + } else { if err := s.ensureProductWarehousesExist(c, req.Stocks, nil, nil); err != nil { return err } @@ -622,11 +643,11 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin if err := s.ensureProductWarehousesByFlags(ctx, feedIDs, []string{"PAKAN", "OVK"}, "feed"); err != nil { return err } - if err := s.reflowSyncRecordingStocks(ctx, tx, recordingEntity.Id, existingStocks, req.Stocks, note, actorID); err != nil { - return err + if err := s.reflowSyncRecordingStocks(ctx, tx, recordingEntity.Id, existingStocks, req.Stocks, stockOwnerProjectFlockKandangID, note, actorID); err != nil { + return err + } } } - } if hasDepletionChanges { existingDepletions, err = s.Repository.ListDepletions(tx, recordingEntity.Id) @@ -788,16 +809,22 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin return err } - if hasStockChanges || hasDepletionChanges || hasEggChanges { + if hasStockChanges || hasDepletionChanges || hasEggChanges { if err := s.computeAndUpdateMetrics(ctx, tx, recordingEntity); err != nil { s.Log.Errorf("Failed to recompute recording metrics: %+v", err) return err } - if err := s.recalculateFrom(ctx, tx, recordingEntity.ProjectFlockKandangId, recordingEntity.RecordDatetime); err != nil { - s.Log.Errorf("Failed to recalculate recordings after update: %+v", err) - return err + if err := s.recalculateFrom(ctx, tx, recordingEntity.ProjectFlockKandangId, recordingEntity.RecordDatetime); err != nil { + s.Log.Errorf("Failed to recalculate recordings after update: %+v", err) + return err + } + } + if hasStockChanges { + if err := s.syncFarmDepreciationManualInputFromRecordingStocks(ctx, tx, recordingEntity.ProjectFlockKandangId, recordingEntity.RecordDatetime); err != nil { + s.Log.Errorf("Failed to sync farm depreciation manual input after update: %+v", err) + return err + } } - } action := entity.ApprovalActionUpdated actorID := recordingEntity.CreatedBy @@ -1055,11 +1082,15 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { return err } - if err := s.recalculateFrom(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime); err != nil { - s.Log.Errorf("Failed to recalculate recordings after delete: %+v", err) - return err - } - s.invalidateDepreciationSnapshots(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime) + if err := s.recalculateFrom(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime); err != nil { + s.Log.Errorf("Failed to recalculate recordings after delete: %+v", err) + return err + } + if err := s.syncFarmDepreciationManualInputFromRecordingStocks(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime); err != nil { + s.Log.Errorf("Failed to sync farm depreciation manual input after delete: %+v", err) + return err + } + s.invalidateDepreciationSnapshots(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime) return nil }) @@ -1435,12 +1466,13 @@ func (s *recordingService) tryAutoExecuteTransferForRecordingCreate(c *fiber.Ctx return nil } + businessDate := recordDate physicalMoveDate := transferPhysicalMoveDate(transfer) - if physicalMoveDate.IsZero() || recordDate.Before(physicalMoveDate) { - return nil + if !physicalMoveDate.IsZero() && businessDate.Before(physicalMoveDate) { + businessDate = physicalMoveDate } - if _, err := s.TransferLayingSvc.ExecuteWithBusinessDate(c, transfer.Id, recordDate); err != nil { + if _, err := s.TransferLayingSvc.ExecuteWithBusinessDate(c, transfer.Id, businessDate); err != nil { return err } @@ -1453,102 +1485,10 @@ func (s *recordingService) enforceTransferRecordingRoute( recordTime time.Time, payload recordingRoutePayload, ) error { - if pfk == nil || pfk.Id == 0 || s.TransferLayingRepo == nil { - return nil - } - - recordDate := normalizeDateOnlyUTC(recordTime) - category := strings.ToUpper(strings.TrimSpace(pfk.ProjectFlock.Category)) - - switch category { - case strings.ToUpper(string(utils.ProjectFlockCategoryLaying)): - transfer, err := s.TransferLayingRepo.GetLatestApprovedByTargetKandang(ctx, pfk.Id) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil - } - s.Log.Errorf("Failed to resolve approved transfer by target kandang %d: %+v", pfk.Id, err) - return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transfer laying") - } - - physicalMoveDate, economicCutoffDate := transferRecordingWindow(transfer) - if physicalMoveDate.IsZero() { - return nil - } - - if recordDate.Before(physicalMoveDate) { - return fiber.NewError( - fiber.StatusBadRequest, - fmt.Sprintf("Recording kandang laying hanya bisa dimulai pada %s (tanggal pindah fisik). Sebelumnya gunakan kandang growing", physicalMoveDate.Format("2006-01-02")), - ) - } - - if transfer.ExecutedAt == nil || transfer.ExecutedAt.IsZero() { - return fiber.NewError( - fiber.StatusBadRequest, - fmt.Sprintf("Transfer laying %s dengan tanggal pindah fisik %s belum dieksekusi. Eksekusi transfer terlebih dahulu", transfer.TransferNumber, physicalMoveDate.Format("2006-01-02")), - ) - } - - if recordDate.Before(economicCutoffDate) && payload.StockCount > 0 { - return fiber.NewError( - fiber.StatusBadRequest, - fmt.Sprintf( - "Periode transisi transfer laying %s (%s s.d. %s): input PAKAN/OVK harus dicatat di kandang growing hingga %s. Recording kandang laying pada periode ini hanya untuk deplesi (dan telur bila ada).", - transfer.TransferNumber, - physicalMoveDate.Format("2006-01-02"), - economicCutoffDate.AddDate(0, 0, -1).Format("2006-01-02"), - economicCutoffDate.AddDate(0, 0, -1).Format("2006-01-02"), - ), - ) - } - - case strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)): - transfer, err := s.TransferLayingRepo.GetLatestApprovedBySourceKandang(ctx, pfk.Id) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil - } - s.Log.Errorf("Failed to resolve approved transfer by source kandang %d: %+v", pfk.Id, err) - return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transfer laying") - } - - physicalMoveDate, economicCutoffDate := transferRecordingWindow(transfer) - if physicalMoveDate.IsZero() { - return nil - } - - if recordDate.Before(physicalMoveDate) { - return nil - } - - if transfer.ExecutedAt == nil || transfer.ExecutedAt.IsZero() { - return fiber.NewError( - fiber.StatusBadRequest, - fmt.Sprintf("Transfer laying %s sudah memasuki tanggal pindah fisik %s namun belum dieksekusi. Eksekusi transfer lalu lakukan recording transisi sesuai aturan", transfer.TransferNumber, physicalMoveDate.Format("2006-01-02")), - ) - } - - if !recordDate.Before(economicCutoffDate) { - return fiber.NewError( - fiber.StatusBadRequest, - fmt.Sprintf("Recording kandang growing hanya diperbolehkan sampai %s. Gunakan kandang laying mulai %s", economicCutoffDate.AddDate(0, 0, -1).Format("2006-01-02"), economicCutoffDate.Format("2006-01-02")), - ) - } - - if payload.DepletionCount > 0 { - return fiber.NewError( - fiber.StatusBadRequest, - fmt.Sprintf( - "Periode transisi transfer laying %s (%s s.d. %s): deplesi harus dicatat di kandang laying tujuan agar mapping tidak ambigu. Kandang growing pada periode ini hanya untuk PAKAN/OVK.", - transfer.TransferNumber, - physicalMoveDate.Format("2006-01-02"), - economicCutoffDate.AddDate(0, 0, -1).Format("2006-01-02"), - ), - ) - } - } - + _ = ctx + _ = pfk + _ = recordTime + _ = payload return nil } @@ -1658,6 +1598,356 @@ func boolPtr(value bool) *bool { return &v } +func recordingStocksAllOwnedBy(stocks []entity.RecordingStock, owner *uint) bool { + for _, stock := range stocks { + if !uintPtrEqual(stock.ProjectFlockKandangId, owner) { + return false + } + } + return true +} + +func uintPtrEqual(a *uint, b *uint) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + return *a == *b +} + +func (s *recordingService) resolveRecordingStockOwnerProjectFlockKandangID( + ctx context.Context, + pfk *entity.ProjectFlockKandang, + recordTime time.Time, +) (*uint, error) { + if pfk == nil || pfk.Id == 0 { + return nil, nil + } + + category := strings.ToUpper(strings.TrimSpace(pfk.ProjectFlock.Category)) + if category == "" { + loaded, err := s.ProjectFlockKandangRepo.GetByIDLight(ctx, pfk.Id) + if err == nil && loaded != nil { + pfk = loaded + category = strings.ToUpper(strings.TrimSpace(loaded.ProjectFlock.Category)) + } + } + + if category != strings.ToUpper(string(utils.ProjectFlockCategoryLaying)) { + owner := pfk.Id + return &owner, nil + } + + if s.TransferLayingRepo == nil { + return nil, nil + } + + transfer, err := s.TransferLayingRepo.GetLatestApprovedByTargetKandang(ctx, pfk.Id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + s.Log.Errorf("Failed to resolve transfer laying for recording stock owner (target_pfk=%d): %+v", pfk.Id, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal menentukan atribusi recording stock") + } + if transfer == nil { + return nil, nil + } + + sourceProjectFlockKandangID, err := s.resolveTransferSourceProjectFlockKandangID(ctx, transfer) + if err != nil { + s.Log.Errorf("Failed to resolve transfer source kandang for transfer %d: %+v", transfer.Id, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal menentukan sumber kandang growing") + } + if sourceProjectFlockKandangID == 0 { + return nil, nil + } + + sourceChickinDate, err := s.getEarliestChickInDateByProjectFlockKandangID(ctx, sourceProjectFlockKandangID) + if err != nil { + s.Log.Errorf("Failed to resolve earliest chick-in date for source kandang %d: %+v", sourceProjectFlockKandangID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal menentukan umur ayam dari growing") + } + if sourceChickinDate == nil || sourceChickinDate.IsZero() { + owner := sourceProjectFlockKandangID + return &owner, nil + } + + thresholdDay, err := s.resolveLayingDepreciationThresholdDay(ctx, pfk) + if err != nil { + s.Log.Errorf("Failed to resolve laying threshold day for kandang %d: %+v", pfk.Id, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal menentukan standar umur laying") + } + if thresholdDay <= 0 { + thresholdDay = commonSvc.DepreciationStartAgeDay(resolveHouseType(pfk)) + } + if thresholdDay <= 0 { + thresholdDay = commonSvc.DepreciationStartAgeDay("close_house") + } + + recordDate := normalizeDateOnlyUTC(recordTime) + chickinDate := normalizeDateOnlyUTC(*sourceChickinDate) + ageDay := commonSvc.FlockAgeDay(chickinDate, recordDate) + if ageDay < thresholdDay { + owner := sourceProjectFlockKandangID + return &owner, nil + } + + owner := pfk.Id + return &owner, nil +} + +func resolveHouseType(pfk *entity.ProjectFlockKandang) string { + if pfk == nil || pfk.Kandang.HouseType == nil { + return "" + } + return strings.TrimSpace(*pfk.Kandang.HouseType) +} + +func (s *recordingService) resolveLayingDepreciationThresholdDay(ctx context.Context, pfk *entity.ProjectFlockKandang) (int, error) { + houseType := commonSvc.NormalizeDepreciationHouseType(resolveHouseType(pfk)) + if houseType == "" { + return 0, nil + } + + var row struct { + StandardWeek int `gorm:"column:standard_week"` + } + err := s.Repository.DB().WithContext(ctx). + Table("house_depreciation_standards"). + Select("standard_week"). + Where("house_type::text = ?", houseType). + Where("standard_week > 0"). + Order("effective_date DESC NULLS LAST"). + Order("id DESC"). + Limit(1). + Scan(&row).Error + if err != nil { + return 0, err + } + if row.StandardWeek <= 0 { + return 0, nil + } + return (row.StandardWeek * 7) + 1, nil +} + +func (s *recordingService) resolveTransferSourceProjectFlockKandangID(ctx context.Context, transfer *entity.LayingTransfer) (uint, error) { + if transfer == nil || transfer.Id == 0 { + return 0, nil + } + if transfer.SourceProjectFlockKandangId != nil && *transfer.SourceProjectFlockKandangId != 0 { + return *transfer.SourceProjectFlockKandangId, nil + } + + var row struct { + SourceProjectFlockKandangID uint `gorm:"column:source_project_flock_kandang_id"` + } + err := s.Repository.DB().WithContext(ctx). + Table("laying_transfer_sources"). + Select("source_project_flock_kandang_id"). + Where("laying_transfer_id = ?", transfer.Id). + Where("deleted_at IS NULL"). + Where("source_project_flock_kandang_id > 0"). + Order("id ASC"). + Limit(1). + Take(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, nil + } + if err != nil { + return 0, err + } + return row.SourceProjectFlockKandangID, nil +} + +func (s *recordingService) getEarliestChickInDateByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (*time.Time, error) { + if projectFlockKandangID == 0 { + return nil, nil + } + + var row struct { + ChickInDate *time.Time `gorm:"column:chick_in_date"` + } + err := s.Repository.DB().WithContext(ctx). + Table("project_chickins"). + Select("MIN(chick_in_date) AS chick_in_date"). + Where("project_flock_kandang_id = ?", projectFlockKandangID). + Where("deleted_at IS NULL"). + Scan(&row).Error + if err != nil { + return nil, err + } + return row.ChickInDate, nil +} + +func (s *recordingService) syncFarmDepreciationManualInputFromRecordingStocks( + ctx context.Context, + tx *gorm.DB, + projectFlockKandangID uint, + fallbackCutoverDate time.Time, +) error { + if projectFlockKandangID == 0 { + return nil + } + + targetDB := s.Repository.DB() + if tx != nil { + targetDB = tx + } + + projectFlockID, err := s.resolveProjectFlockIDByProjectFlockKandangID(ctx, targetDB, projectFlockKandangID) + if err != nil { + return err + } + if projectFlockID == 0 { + return nil + } + + totalCost, err := s.sumNoTransferRecordingStockCostByProjectFlockID(ctx, targetDB, projectFlockID) + if err != nil { + return err + } + + existing, err := s.getFarmDepreciationManualInputByProjectFlockID(ctx, targetDB, projectFlockID) + if err != nil { + return err + } + + cutoverDate := normalizeDateOnlyUTC(fallbackCutoverDate) + if existing != nil && !existing.CutoverDate.IsZero() { + cutoverDate = normalizeDateOnlyUTC(existing.CutoverDate) + } + if cutoverDate.IsZero() { + earliestDate, dateErr := s.getEarliestNoTransferRecordingDateByProjectFlockID(ctx, targetDB, projectFlockID) + if dateErr != nil { + return dateErr + } + if earliestDate != nil && !earliestDate.IsZero() { + cutoverDate = normalizeDateOnlyUTC(*earliestDate) + } + } + if cutoverDate.IsZero() { + cutoverDate = normalizeDateOnlyUTC(time.Now().UTC()) + } + + now := time.Now().UTC() + row := entity.FarmDepreciationManualInput{ + ProjectFlockId: projectFlockID, + TotalCost: totalCost, + CutoverDate: cutoverDate, + } + if existing != nil { + row.Note = existing.Note + } + + return targetDB.WithContext(ctx). + Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "project_flock_id"}}, + DoUpdates: clause.Assignments(map[string]any{ + "total_cost": row.TotalCost, + "cutover_date": row.CutoverDate, + "updated_at": now, + }), + }). + Create(&row).Error +} + +func (s *recordingService) resolveProjectFlockIDByProjectFlockKandangID(ctx context.Context, db *gorm.DB, projectFlockKandangID uint) (uint, error) { + var row struct { + ProjectFlockID uint `gorm:"column:project_flock_id"` + } + err := db.WithContext(ctx). + Table("project_flock_kandangs"). + Select("project_flock_id"). + Where("id = ?", projectFlockKandangID). + Take(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, nil + } + if err != nil { + return 0, err + } + return row.ProjectFlockID, nil +} + +func (s *recordingService) sumNoTransferRecordingStockCostByProjectFlockID(ctx context.Context, db *gorm.DB, projectFlockID uint) (float64, error) { + if projectFlockID == 0 { + return 0, nil + } + + var total float64 + err := db.WithContext(ctx). + Table("recording_stocks AS rs"). + Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)"). + Joins("JOIN recordings AS r ON r.id = rs.recording_id AND r.deleted_at IS NULL"). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). + Joins( + "JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", + fifo.UsableKeyRecordingStock.String(), + fifo.StockableKeyPurchaseItems.String(), + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + ). + Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). + Where("pfk.project_flock_id = ?", projectFlockID). + Where("rs.project_flock_kandang_id IS NULL"). + Scan(&total).Error + if err != nil { + return 0, err + } + return total, nil +} + +func (s *recordingService) getFarmDepreciationManualInputByProjectFlockID( + ctx context.Context, + db *gorm.DB, + projectFlockID uint, +) (*entity.FarmDepreciationManualInput, error) { + if projectFlockID == 0 { + return nil, nil + } + + var row entity.FarmDepreciationManualInput + err := db.WithContext(ctx). + Where("project_flock_id = ?", projectFlockID). + Take(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &row, nil +} + +func (s *recordingService) getEarliestNoTransferRecordingDateByProjectFlockID( + ctx context.Context, + db *gorm.DB, + projectFlockID uint, +) (*time.Time, error) { + if projectFlockID == 0 { + return nil, nil + } + + var row struct { + RecordDate *time.Time `gorm:"column:record_date"` + } + err := db.WithContext(ctx). + Table("recording_stocks AS rs"). + Select("MIN(r.record_datetime) AS record_date"). + Joins("JOIN recordings AS r ON r.id = rs.recording_id AND r.deleted_at IS NULL"). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). + Where("pfk.project_flock_id = ?", projectFlockID). + Where("rs.project_flock_kandang_id IS NULL"). + Scan(&row).Error + if err != nil { + return nil, err + } + return row.RecordDate, nil +} + func (s *recordingService) resolveEggRequestsToFarmWarehouses( ctx context.Context, pfk *entity.ProjectFlockKandang, @@ -2588,6 +2878,7 @@ func (s *recordingService) reflowSyncRecordingStocks( recordingID uint, existing []entity.RecordingStock, incoming []validation.Stock, + ownerProjectFlockKandangID *uint, note string, actorID uint, ) error { @@ -2608,21 +2899,32 @@ func (s *recordingService) reflowSyncRecordingStocks( if len(list) > 0 { stock = list[0] existingByWarehouse[item.ProductWarehouseId] = list[1:] - } else { - zero := 0.0 - stock = entity.RecordingStock{ - RecordingId: recordingID, - ProductWarehouseId: item.ProductWarehouseId, - UsageQty: &zero, - PendingQty: &zero, + } else { + zero := 0.0 + stock = entity.RecordingStock{ + RecordingId: recordingID, + ProductWarehouseId: item.ProductWarehouseId, + ProjectFlockKandangId: ownerProjectFlockKandangID, + UsageQty: &zero, + PendingQty: &zero, + } + if err := s.Repository.CreateStock(tx, &stock); err != nil { + return err + } } - if err := s.Repository.CreateStock(tx, &stock); err != nil { - return err + stock.ProjectFlockKandangId = ownerProjectFlockKandangID + if stock.Id != 0 { + if err := tx.Model(&entity.RecordingStock{}). + Where("id = ?", stock.Id). + Updates(map[string]any{ + "project_flock_kandang_id": ownerProjectFlockKandangID, + }).Error; err != nil { + return err + } } - } - desired := item.Qty - stock.UsageQty = &desired + desired := item.Qty + stock.UsageQty = &desired zero := 0.0 stock.PendingQty = &zero stocksToApply = append(stocksToApply, stock) diff --git a/internal/modules/production/transfer_layings/services/transfer_laying.service.go b/internal/modules/production/transfer_layings/services/transfer_laying.service.go index c1748cc8..cff2b067 100644 --- a/internal/modules/production/transfer_layings/services/transfer_laying.service.go +++ b/internal/modules/production/transfer_layings/services/transfer_laying.service.go @@ -231,6 +231,9 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) ); err != nil { return nil, err } + if err := s.validateTargetSourceLineage(c.Context(), sourceDetail.ProjectFlockKandangId, targetKandangIDs, 0); err != nil { + return nil, err + } transferDate, err := utils.ParseDateString(req.TransferDate) if err != nil { @@ -451,6 +454,9 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, ); err != nil { return nil, err } + if err := s.validateTargetSourceLineage(c.Context(), sourceDetail.ProjectFlockKandangId, targetKandangIDs, id); err != nil { + return nil, err + } transferDate, err := time.Parse("2006-01-02", req.TransferDate) if err != nil { @@ -1611,6 +1617,80 @@ func (s *transferLayingService) validateKandangOwnership( return nil } +func (s *transferLayingService) validateTargetSourceLineage( + ctx context.Context, + sourceProjectFlockKandangID uint, + targetKandangIDs []uint, + excludeTransferID uint, +) error { + if sourceProjectFlockKandangID == 0 || len(targetKandangIDs) == 0 { + return nil + } + + seen := make(map[uint]struct{}, len(targetKandangIDs)) + for _, targetKandangID := range targetKandangIDs { + if targetKandangID == 0 { + continue + } + if _, exists := seen[targetKandangID]; exists { + continue + } + seen[targetKandangID] = struct{}{} + + existingTransfer, err := s.Repository.GetLatestApprovedByTargetKandang(ctx, targetKandangID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + continue + } + s.Log.Errorf("Failed to validate transfer lineage for target kandang %d: %+v", targetKandangID, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi relasi sumber transfer ke laying") + } + if existingTransfer == nil { + continue + } + if excludeTransferID != 0 && existingTransfer.Id == excludeTransferID { + continue + } + + existingSourceID := uint(0) + if existingTransfer.SourceProjectFlockKandangId != nil && *existingTransfer.SourceProjectFlockKandangId != 0 { + existingSourceID = *existingTransfer.SourceProjectFlockKandangId + } + if existingSourceID == 0 && s.LayingTransferSourceRepo != nil { + sources, sourceErr := s.LayingTransferSourceRepo.GetByLayingTransferId(ctx, existingTransfer.Id) + if sourceErr != nil { + s.Log.Errorf("Failed to resolve transfer sources for lineage validation transfer=%d: %+v", existingTransfer.Id, sourceErr) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi relasi sumber transfer ke laying") + } + for _, source := range sources { + if source.SourceProjectFlockKandangId != 0 { + existingSourceID = source.SourceProjectFlockKandangId + break + } + } + } + if existingSourceID == 0 { + continue + } + if existingSourceID == sourceProjectFlockKandangID { + continue + } + + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf( + "Kandang tujuan %d sudah memiliki lineage sumber kandang %d dari transfer %s. Tidak boleh ganti ke sumber kandang %d.", + targetKandangID, + existingSourceID, + existingTransfer.TransferNumber, + sourceProjectFlockKandangID, + ), + ) + } + + return nil +} + func (s transferLayingService) GetMaxTargetQtyPerKandang(c *fiber.Ctx, projectFlockID uint) (map[uint]float64, error) { if err := commonSvc.EnsureRelations(c.Context(), diff --git a/internal/modules/repports/repositories/hpp_per_kandang.repository.go b/internal/modules/repports/repositories/hpp_per_kandang.repository.go index 7655fcdb..058b006e 100644 --- a/internal/modules/repports/repositories/hpp_per_kandang.repository.go +++ b/internal/modules/repports/repositories/hpp_per_kandang.repository.go @@ -221,7 +221,7 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, feedQuery := r.db.WithContext(ctx). Table("recordings AS r"). Select(` - r.project_flock_kandangs_id AS project_flock_kandang_id, + rs.project_flock_kandang_id AS project_flock_kandang_id, COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0) AS feed_cost, s.id AS supplier_id, s.name AS supplier_name, @@ -233,10 +233,10 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_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("r.project_flock_kandangs_id IN ?", projectFlockKandangIDs). + Where("rs.project_flock_kandang_id IN ?", projectFlockKandangIDs). Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). Where("f.name = ?", utils.FlagPakan). - Group("r.project_flock_kandangs_id, s.id, s.name, s.alias") + Group("rs.project_flock_kandang_id, s.id, s.name, s.alias") if err := feedQuery.Scan(&feedRows).Error; err != nil { return nil, nil, err diff --git a/internal/utils/recording/util.recording.go b/internal/utils/recording/util.recording.go index 49335c90..b6ff693b 100644 --- a/internal/utils/recording/util.recording.go +++ b/internal/utils/recording/util.recording.go @@ -8,7 +8,7 @@ import ( validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" ) -func MapStocks(recordingID uint, items []validation.Stock) []entity.RecordingStock { +func MapStocks(recordingID uint, ownerProjectFlockKandangID *uint, items []validation.Stock) []entity.RecordingStock { if len(items) == 0 { return nil } @@ -18,9 +18,10 @@ func MapStocks(recordingID uint, items []validation.Stock) []entity.RecordingSto usagePtr := new(float64) *usagePtr = item.Qty result = append(result, entity.RecordingStock{ - RecordingId: recordingID, - ProductWarehouseId: item.ProductWarehouseId, - UsageQty: usagePtr, + RecordingId: recordingID, + ProductWarehouseId: item.ProductWarehouseId, + ProjectFlockKandangId: ownerProjectFlockKandangID, + UsageQty: usagePtr, }) } return result