From 091f706276809c49d76e03ba6632fea97b49e078 Mon Sep 17 00:00:00 2001 From: giovanni Date: Tue, 21 Apr 2026 22:43:18 +0700 Subject: [PATCH 1/4] init adjustment recording --- .../repository/common.hpp.repository.go | 4 +- .../repository/common.hppv2.repository.go | 49 +- .../common.hppv2.repository_test.go | 59 ++ .../common/service/common.hppv2.service.go | 102 +++ .../service/common.hppv2.service_test.go | 5 + ...ing_stock_owner_and_standard_week.down.sql | 17 + ...rding_stock_owner_and_standard_week.up.sql | 52 ++ .../entities/house_depreciation_standard.go | 1 + internal/entities/recording_stock.go | 11 +- .../repositories/closing.repository.go | 24 +- .../repositories/closing.repository_test.go | 17 +- .../recordings/services/recording.service.go | 612 +++++++++++++----- .../services/transfer_laying.service.go | 80 +++ .../hpp_per_kandang.repository.go | 6 +- internal/utils/recording/util.recording.go | 9 +- 15 files changed, 863 insertions(+), 185 deletions(-) create mode 100644 internal/database/migrations/20260421101500_add_recording_stock_owner_and_standard_week.down.sql create mode 100644 internal/database/migrations/20260421101500_add_recording_stock_owner_and_standard_week.up.sql 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 From fec7bb5825cabec347e18d77d6999b14137521a5 Mon Sep 17 00:00:00 2001 From: giovanni Date: Wed, 22 Apr 2026 01:44:37 +0700 Subject: [PATCH 2/4] adjust --- .../controllers/projectflock.controller.go | 4 +- .../recordings/services/recording.service.go | 14 ++-- .../services/recording.service_test.go | 67 +++++++++++++++++++ 3 files changed, 79 insertions(+), 6 deletions(-) diff --git a/internal/modules/production/project_flocks/controllers/projectflock.controller.go b/internal/modules/production/project_flocks/controllers/projectflock.controller.go index 6a7b4cb0..8c33e053 100644 --- a/internal/modules/production/project_flocks/controllers/projectflock.controller.go +++ b/internal/modules/production/project_flocks/controllers/projectflock.controller.go @@ -312,10 +312,10 @@ func (u *ProjectflockController) LookupProjectFlockKandang(c *fiber.Ctx) error { mapped := warehouseDTO.ToWarehouseRelationDTO(*warehouse) dtoResult.Warehouse = &mapped } - if isTransition, isLaying, serr := u.ProjectflockService.GetProjectFlockKandangTransferStateAtDate(c, result.Id, recordDate); serr != nil { + if _, isLaying, serr := u.ProjectflockService.GetProjectFlockKandangTransferStateAtDate(c, result.Id, recordDate); serr != nil { return serr } else { - dtoResult.IsTransition = isTransition + dtoResult.IsTransition = false dtoResult.IsLaying = isLaying } applyCutOverLayingLookupOverride(&dtoResult) diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index b888bec3..18bf2a01 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -1737,9 +1737,6 @@ func (s *recordingService) resolveTransferSourceProjectFlockKandangID(ctx contex 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"` @@ -1754,12 +1751,21 @@ func (s *recordingService) resolveTransferSourceProjectFlockKandangID(ctx contex Limit(1). Take(&row).Error if errors.Is(err, gorm.ErrRecordNotFound) { + if transfer.SourceProjectFlockKandangId != nil && *transfer.SourceProjectFlockKandangId != 0 { + return *transfer.SourceProjectFlockKandangId, nil + } return 0, nil } if err != nil { return 0, err } - return row.SourceProjectFlockKandangID, nil + if row.SourceProjectFlockKandangID != 0 { + return row.SourceProjectFlockKandangID, nil + } + if transfer.SourceProjectFlockKandangId != nil && *transfer.SourceProjectFlockKandangId != 0 { + return *transfer.SourceProjectFlockKandangId, nil + } + return 0, nil } func (s *recordingService) getEarliestChickInDateByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (*time.Time, error) { diff --git a/internal/modules/production/recordings/services/recording.service_test.go b/internal/modules/production/recordings/services/recording.service_test.go index f1bc6abf..7acfcf28 100644 --- a/internal/modules/production/recordings/services/recording.service_test.go +++ b/internal/modules/production/recordings/services/recording.service_test.go @@ -148,6 +148,53 @@ func TestShouldNormalizeEggRequestsOnUpdateNormalizesFarmLevelEggs(t *testing.T) } } +func TestResolveTransferSourceProjectFlockKandangIDPrefersTransferResourceSource(t *testing.T) { + db := setupTransferSourceResolutionTestDB(t) + repo := repository.NewRecordingRepository(db) + svc := &recordingService{Repository: repo} + + transfer := &entity.LayingTransfer{ + Id: 77, + SourceProjectFlockKandangId: uintPtrForTest(999), + } + + if err := db.Exec(`INSERT INTO laying_transfer_sources (id, laying_transfer_id, source_project_flock_kandang_id, deleted_at) VALUES (1, 77, 123, NULL)`).Error; err != nil { + t.Fatalf("failed seeding laying_transfer_sources: %v", err) + } + + got, err := svc.resolveTransferSourceProjectFlockKandangID(context.Background(), transfer) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if got != 123 { + t.Fatalf("expected resource source project_flock_kandang_id=123, got %d", got) + } +} + +func TestResolveTransferSourceProjectFlockKandangIDFallsBackToTransferHeader(t *testing.T) { + db := setupTransferSourceResolutionTestDB(t) + repo := repository.NewRecordingRepository(db) + svc := &recordingService{Repository: repo} + + transfer := &entity.LayingTransfer{ + Id: 78, + SourceProjectFlockKandangId: uintPtrForTest(456), + } + + got, err := svc.resolveTransferSourceProjectFlockKandangID(context.Background(), transfer) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if got != 456 { + t.Fatalf("expected fallback source project_flock_kandang_id=456, got %d", got) + } +} + +func uintPtrForTest(value uint) *uint { + v := value + return &v +} + func setupRecordingServiceTestDB(t *testing.T) *gorm.DB { t.Helper() @@ -193,3 +240,23 @@ func setupRecordingServiceTestDB(t *testing.T) *gorm.DB { return db } + +func setupTransferSourceResolutionTestDB(t *testing.T) *gorm.DB { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{}) + if err != nil { + t.Fatalf("failed opening sqlite db: %v", err) + } + + if err := db.Exec(`CREATE TABLE laying_transfer_sources ( + id INTEGER PRIMARY KEY, + laying_transfer_id INTEGER NOT NULL, + source_project_flock_kandang_id INTEGER NOT NULL, + deleted_at TIMESTAMP NULL + )`).Error; err != nil { + t.Fatalf("failed creating laying_transfer_sources schema: %v", err) + } + + return db +} From ca6e9ef0d2465519b3d0c9a067c8b36a399a41ae Mon Sep 17 00:00:00 2001 From: giovanni Date: Wed, 22 Apr 2026 10:11:10 +0700 Subject: [PATCH 3/4] adjust name migration --- ...22030847_add_recording_stock_owner_and_standard_week.down.sql} | 0 ...0422030847_add_recording_stock_owner_and_standard_week.up.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename internal/database/migrations/{20260421101500_add_recording_stock_owner_and_standard_week.down.sql => 20260422030847_add_recording_stock_owner_and_standard_week.down.sql} (100%) rename internal/database/migrations/{20260421101500_add_recording_stock_owner_and_standard_week.up.sql => 20260422030847_add_recording_stock_owner_and_standard_week.up.sql} (100%) diff --git a/internal/database/migrations/20260421101500_add_recording_stock_owner_and_standard_week.down.sql b/internal/database/migrations/20260422030847_add_recording_stock_owner_and_standard_week.down.sql similarity index 100% rename from internal/database/migrations/20260421101500_add_recording_stock_owner_and_standard_week.down.sql rename to internal/database/migrations/20260422030847_add_recording_stock_owner_and_standard_week.down.sql diff --git a/internal/database/migrations/20260421101500_add_recording_stock_owner_and_standard_week.up.sql b/internal/database/migrations/20260422030847_add_recording_stock_owner_and_standard_week.up.sql similarity index 100% rename from internal/database/migrations/20260421101500_add_recording_stock_owner_and_standard_week.up.sql rename to internal/database/migrations/20260422030847_add_recording_stock_owner_and_standard_week.up.sql From f51fa0a16c39fcea989c1b471d9cee228b9ca18b Mon Sep 17 00:00:00 2001 From: giovanni Date: Wed, 22 Apr 2026 12:57:41 +0700 Subject: [PATCH 4/4] adjust repo hpp v2 --- .../repository/common.hppv2.repository.go | 34 +- .../common.hppv2.repository_test.go | 113 ++++++- .../repport.expense_depreciation_test.go | 293 +++++++++++++++++- .../repports/services/repport.service.go | 57 ++-- .../validations/repport.validation.go | 1 + 5 files changed, 458 insertions(+), 40 deletions(-) diff --git a/internal/common/repository/common.hppv2.repository.go b/internal/common/repository/common.hppv2.repository.go index 81750bdd..0968ad0e 100644 --- a/internal/common/repository/common.hppv2.repository.go +++ b/internal/common/repository/common.hppv2.repository.go @@ -266,6 +266,26 @@ func (r *HppV2RepositoryImpl) GetRecordingStockRoutingAdjustmentCostByProjectFlo utils.FlagVitamin, utils.FlagKimia, } + transferExistsCondition := ` + EXISTS ( + SELECT 1 + FROM laying_transfer_targets AS ltt + JOIN laying_transfers AS lt ON lt.id = ltt.laying_transfer_id + WHERE ltt.deleted_at IS NULL + AND lt.deleted_at IS NULL + AND lt.executed_at IS NOT NULL + AND ltt.target_project_flock_kandang_id = r.project_flock_kandangs_id + AND COALESCE(DATE(lt.effective_move_date), DATE(lt.economic_cutoff_date), DATE(lt.transfer_date)) <= DATE(?) + AND ( + SELECT a.action + FROM approvals a + WHERE a.approvable_type = ? + AND a.approvable_id = lt.id + ORDER BY a.id DESC + LIMIT 1 + ) = ? + ) + ` var total float64 err := r.db.WithContext(ctx). @@ -284,7 +304,19 @@ func (r *HppV2RepositoryImpl) GetRecordingStockRoutingAdjustmentCostByProjectFlo 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( + fmt.Sprintf( + "((%s) AND rs.project_flock_kandang_id IS NOT NULL AND rs.project_flock_kandang_id <> r.project_flock_kandangs_id) OR (NOT (%s) AND rs.project_flock_kandang_id IS NULL)", + transferExistsCondition, + transferExistsCondition, + ), + periodDate, + string(utils.ApprovalWorkflowTransferToLaying), + entity.ApprovalActionApproved, + periodDate, + string(utils.ApprovalWorkflowTransferToLaying), + entity.ApprovalActionApproved, + ). 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 { diff --git a/internal/common/repository/common.hppv2.repository_test.go b/internal/common/repository/common.hppv2.repository_test.go index 5e60f8c2..1e76b2d5 100644 --- a/internal/common/repository/common.hppv2.repository_test.go +++ b/internal/common/repository/common.hppv2.repository_test.go @@ -2,6 +2,7 @@ package repository import ( "context" + "fmt" "math" "testing" "time" @@ -98,33 +99,95 @@ func TestHppV2RepositoryGetEggTerjualProratesHistoricalFarmSalesFromAdjustments( func TestHppV2RepositoryGetRecordingStockRoutingAdjustmentCostByProjectFlockID(t *testing.T) { db := setupHppV2RepositoryTestDB(t) + approvalType := utils.ApprovalWorkflowTransferToLaying.String() mustExecHppV2(t, db, - `INSERT INTO project_flock_kandangs (id, kandang_id, project_flock_id) VALUES (101, 1, 1), (201, 2, 2)`, + `INSERT INTO project_flock_kandangs (id, kandang_id, project_flock_id) VALUES + (101, 1, 1), + (102, 2, 1), + (103, 3, 1), + (104, 4, 1), + (105, 5, 1), + (201, 6, 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)`, + (2, 101, '2026-04-10 08:05:00', NULL), + (3, 101, '2026-04-10 08:10:00', NULL), + (4, 102, '2026-04-10 08:15:00', NULL), + (5, 102, '2026-04-10 08:20:00', NULL), + (6, 103, '2026-04-12 08:00:00', NULL), + (7, 103, '2026-04-12 08:05:00', NULL), + (8, 104, '2026-04-12 08:10:00', NULL), + (9, 104, '2026-04-12 08:15:00', NULL), + (10, 105, '2026-04-12 08:20:00', NULL), + (11, 105, '2026-04-12 08:25: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)`, + (502, 201, 10, NULL), + (503, 201, 10, NULL), + (504, 201, 10, NULL), + (505, 201, 10, NULL), + (506, 201, 10, NULL), + (507, 201, 10, NULL), + (508, 201, 10, NULL), + (509, 201, 10, NULL), + (510, 201, 10, NULL), + (511, 201, 10, NULL)`, `INSERT INTO flags (id, flagable_type, flagable_id, name) VALUES - (10, 'products', 10, 'PAKAN'), - (11, 'products', 11, 'OVK'), - (12, 'products', 12, 'PAKAN')`, + (10, 'products', 10, '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)`, + (103, 3, 503, 101), + (104, 4, 504, NULL), + (105, 5, 505, 201), + (106, 6, 506, NULL), + (107, 7, 507, 201), + (108, 8, 508, NULL), + (109, 9, 509, 201), + (110, 10, 510, NULL), + (111, 11, 511, 201)`, `INSERT INTO purchase_items (id, product_id, price) VALUES (601, 10, 100), - (602, 11, 200), - (603, 12, 300)`, + (602, 10, 110), + (603, 10, 120), + (604, 10, 130), + (605, 10, 140), + (606, 10, 150), + (607, 10, 160), + (608, 10, 170), + (609, 10, 180), + (610, 10, 190), + (611, 10, 200)`, `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)`, + (9002, 'RECORDING_STOCK', 102, 'PURCHASE_ITEMS', 602, 'ACTIVE', 'CONSUME', 1), + (9003, 'RECORDING_STOCK', 103, 'PURCHASE_ITEMS', 603, 'ACTIVE', 'CONSUME', 1), + (9004, 'RECORDING_STOCK', 104, 'PURCHASE_ITEMS', 604, 'ACTIVE', 'CONSUME', 1), + (9005, 'RECORDING_STOCK', 105, 'PURCHASE_ITEMS', 605, 'ACTIVE', 'CONSUME', 1), + (9006, 'RECORDING_STOCK', 106, 'PURCHASE_ITEMS', 606, 'ACTIVE', 'CONSUME', 1), + (9007, 'RECORDING_STOCK', 107, 'PURCHASE_ITEMS', 607, 'ACTIVE', 'CONSUME', 1), + (9008, 'RECORDING_STOCK', 108, 'PURCHASE_ITEMS', 608, 'ACTIVE', 'CONSUME', 1), + (9009, 'RECORDING_STOCK', 109, 'PURCHASE_ITEMS', 609, 'ACTIVE', 'CONSUME', 1), + (9010, 'RECORDING_STOCK', 110, 'PURCHASE_ITEMS', 610, 'ACTIVE', 'CONSUME', 1), + (9011, 'RECORDING_STOCK', 111, 'PURCHASE_ITEMS', 611, 'ACTIVE', 'CONSUME', 1)`, + `INSERT INTO laying_transfers (id, transfer_date, effective_move_date, economic_cutoff_date, executed_at, deleted_at) VALUES + (1001, '2026-04-04', '2026-04-05', NULL, '2026-04-05 00:00:00', NULL), + (1002, '2026-05-01', '2026-05-01', NULL, '2026-05-01 00:00:00', NULL), + (1003, '2026-04-03', '2026-04-05', NULL, '2026-04-05 00:00:00', NULL), + (1004, '2026-04-03', '2026-04-05', NULL, NULL, NULL)`, + `INSERT INTO laying_transfer_targets (id, laying_transfer_id, target_project_flock_kandang_id, deleted_at) VALUES + (2001, 1001, 101, NULL), + (2002, 1002, 103, NULL), + (2003, 1003, 104, NULL), + (2004, 1004, 105, NULL)`, + fmt.Sprintf(`INSERT INTO approvals (id, approvable_type, approvable_id, action) VALUES + (3001, '%s', 1001, 'APPROVED'), + (3002, '%s', 1002, 'APPROVED'), + (3003, '%s', 1003, 'APPROVED'), + (3004, '%s', 1003, 'REJECTED'), + (3005, '%s', 1004, 'APPROVED')`, + approvalType, approvalType, approvalType, approvalType, approvalType), ) repo := &HppV2RepositoryImpl{db: db} @@ -134,14 +197,14 @@ func TestHppV2RepositoryGetRecordingStockRoutingAdjustmentCostByProjectFlockID(t if err != nil { t.Fatalf("expected no error, got %v", err) } - assertFloatEquals(t, total, 500) + assertFloatEquals(t, total, 750) 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) + assertFloatEquals(t, earlyTotal, 240) } func setupHppV2RepositoryTestDB(t *testing.T) *gorm.DB { @@ -246,6 +309,26 @@ func setupHppV2RepositoryTestDB(t *testing.T) *gorm.DB { flagable_id INTEGER NULL, name TEXT NULL )`, + `CREATE TABLE laying_transfers ( + id INTEGER PRIMARY KEY, + transfer_date DATETIME NULL, + effective_move_date DATETIME NULL, + economic_cutoff_date DATETIME NULL, + executed_at DATETIME NULL, + deleted_at DATETIME NULL + )`, + `CREATE TABLE laying_transfer_targets ( + id INTEGER PRIMARY KEY, + laying_transfer_id INTEGER NULL, + target_project_flock_kandang_id INTEGER NULL, + deleted_at DATETIME NULL + )`, + `CREATE TABLE approvals ( + id INTEGER PRIMARY KEY, + approvable_type TEXT NULL, + approvable_id INTEGER NULL, + action TEXT NULL + )`, ) return db diff --git a/internal/modules/repports/services/repport.expense_depreciation_test.go b/internal/modules/repports/services/repport.expense_depreciation_test.go index 820fbaa6..3f10e428 100644 --- a/internal/modules/repports/services/repport.expense_depreciation_test.go +++ b/internal/modules/repports/services/repport.expense_depreciation_test.go @@ -13,6 +13,7 @@ import ( commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" approvalService "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" dto "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto" repportRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations" @@ -21,12 +22,17 @@ import ( type expenseDepreciationRepoMock struct { repportRepo.ExpenseDepreciationRepository - manualInputs []repportRepo.FarmDepreciationManualInputRow + manualInputs []repportRepo.FarmDepreciationManualInputRow + candidateRows []repportRepo.FarmDepreciationCandidateRow + snapshots []entity.FarmDepreciationSnapshot - upsertedRow *entity.FarmDepreciationManualInput - deleteCalled bool - deleteDate time.Time - deleteFarmIDs []uint + upsertedRow *entity.FarmDepreciationManualInput + deleteCalled bool + deleteDate time.Time + deleteFarmIDs []uint + upsertSnapshotCalls int + upsertedSnapshots []entity.FarmDepreciationSnapshot + getSnapshotsCalls int } func (m *expenseDepreciationRepoMock) DB() *gorm.DB { @@ -46,6 +52,37 @@ func (m *expenseDepreciationRepoMock) UpsertManualInput(_ context.Context, row * return nil } +func (m *expenseDepreciationRepoMock) GetCandidateFarms(_ context.Context, _ time.Time, _ []int64, _ []int64, _ []int64) ([]repportRepo.FarmDepreciationCandidateRow, error) { + return append([]repportRepo.FarmDepreciationCandidateRow{}, m.candidateRows...), nil +} + +func (m *expenseDepreciationRepoMock) GetSnapshotsByPeriodAndFarmIDs(_ context.Context, period time.Time, farmIDs []uint) ([]entity.FarmDepreciationSnapshot, error) { + m.getSnapshotsCalls++ + if len(farmIDs) == 0 { + return []entity.FarmDepreciationSnapshot{}, nil + } + allowed := make(map[uint]struct{}, len(farmIDs)) + for _, farmID := range farmIDs { + allowed[farmID] = struct{}{} + } + result := make([]entity.FarmDepreciationSnapshot, 0, len(m.snapshots)) + for _, row := range m.snapshots { + if _, ok := allowed[row.ProjectFlockId]; !ok { + continue + } + if row.PeriodDate.IsZero() || row.PeriodDate.Format("2006-01-02") == period.Format("2006-01-02") { + result = append(result, row) + } + } + return result, nil +} + +func (m *expenseDepreciationRepoMock) UpsertSnapshots(_ context.Context, rows []entity.FarmDepreciationSnapshot) error { + m.upsertSnapshotCalls++ + m.upsertedSnapshots = append([]entity.FarmDepreciationSnapshot{}, rows...) + return nil +} + func (m *expenseDepreciationRepoMock) DeleteSnapshotsFromDate(_ context.Context, fromDate time.Time, farmIDs []uint) error { m.deleteCalled = true m.deleteDate = fromDate @@ -57,6 +94,15 @@ func (m *expenseDepreciationRepoMock) GetLatestManualInputsByFarms(_ context.Con return append([]repportRepo.FarmDepreciationManualInputRow{}, m.manualInputs...), nil } +type expenseRealizationRepoMock struct { + expenseRepo.ExpenseRealizationRepository + db *gorm.DB +} + +func (m *expenseRealizationRepoMock) DB() *gorm.DB { + return m.db +} + type hppCostRepoMock struct { commonRepo.HppCostRepository kandangIDsByFarm map[uint][]uint @@ -352,6 +398,167 @@ func TestComputeExpenseDepreciationSnapshots_ZeroWhenDepreciationMissing(t *test } } +func TestGetExpenseDepreciation_UsesExistingSnapshotWhenForceRecomputeFalse(t *testing.T) { + periodDate := mustJakartaDate(t, "2026-06-05") + repo := &expenseDepreciationRepoMock{ + candidateRows: []repportRepo.FarmDepreciationCandidateRow{ + {ProjectFlockID: 1, FarmName: "Farm A"}, + }, + snapshots: []entity.FarmDepreciationSnapshot{ + { + ProjectFlockId: 1, + PeriodDate: periodDate, + DepreciationPercentEffective: 11.1, + DepreciationValue: 111, + PulletCostDayNTotal: 1001, + Components: json.RawMessage(`{"kandang_count":0,"kandang":[]}`), + }, + }, + } + + svc := &repportService{ + Validate: validator.New(), + ExpenseDepreciationRepo: repo, + ExpenseRealizationRepo: &expenseRealizationRepoMock{}, + HppCostRepo: &hppCostRepoMock{}, + HppV2Svc: &hppV2ServiceMock{}, + } + + rows, meta, err := getExpenseDepreciationByQuery(t, svc, "page=1&limit=10&period=2026-06-05") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + if rows[0].DepreciationValue != 111 { + t.Fatalf("expected depreciation value 111, got %v", rows[0].DepreciationValue) + } + if meta == nil || meta.TotalResults != 1 { + t.Fatalf("expected meta total_results 1, got %+v", meta) + } + if repo.upsertSnapshotCalls != 0 { + t.Fatalf("expected no snapshot upsert, got %d", repo.upsertSnapshotCalls) + } + if repo.getSnapshotsCalls != 1 { + t.Fatalf("expected snapshot fetch called once, got %d", repo.getSnapshotsCalls) + } +} + +func TestGetExpenseDepreciation_ForceRecomputeRebuildsAllSnapshots(t *testing.T) { + periodDate := mustJakartaDate(t, "2026-06-05") + repo := &expenseDepreciationRepoMock{ + candidateRows: []repportRepo.FarmDepreciationCandidateRow{ + {ProjectFlockID: 1, FarmName: "Farm A"}, + }, + snapshots: []entity.FarmDepreciationSnapshot{ + { + ProjectFlockId: 1, + PeriodDate: periodDate, + DepreciationValue: 999, + PulletCostDayNTotal: 999, + }, + }, + } + + svc := &repportService{ + Validate: validator.New(), + ExpenseDepreciationRepo: repo, + ExpenseRealizationRepo: &expenseRealizationRepoMock{}, + HppCostRepo: &hppCostRepoMock{ + kandangIDsByFarm: map[uint][]uint{ + 1: {10}, + }, + }, + HppV2Svc: &hppV2ServiceMock{ + breakdownByPFK: map[uint]*approvalService.HppV2Breakdown{ + 10: depreciationBreakdown(10, 100, "Kandang A", 100, 1000, 10), + }, + }, + } + + rows, _, err := getExpenseDepreciationByQuery(t, svc, "page=1&limit=10&period=2026-06-05&force_recompute=true") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + if rows[0].DepreciationValue != 100 { + t.Fatalf("expected recomputed depreciation value 100, got %v", rows[0].DepreciationValue) + } + if repo.upsertSnapshotCalls != 1 { + t.Fatalf("expected snapshot upsert called once, got %d", repo.upsertSnapshotCalls) + } + if len(repo.upsertedSnapshots) != 1 || repo.upsertedSnapshots[0].ProjectFlockId != 1 { + t.Fatalf("expected upserted snapshot for farm 1, got %+v", repo.upsertedSnapshots) + } + if repo.getSnapshotsCalls != 0 { + t.Fatalf("expected no snapshot fetch in force recompute mode, got %d", repo.getSnapshotsCalls) + } +} + +func TestGetExpenseDepreciation_ForceRecomputeFalseComputesOnlyMissingFarms(t *testing.T) { + periodDate := mustJakartaDate(t, "2026-06-05") + repo := &expenseDepreciationRepoMock{ + candidateRows: []repportRepo.FarmDepreciationCandidateRow{ + {ProjectFlockID: 1, FarmName: "Farm A"}, + {ProjectFlockID: 2, FarmName: "Farm B"}, + }, + snapshots: []entity.FarmDepreciationSnapshot{ + { + ProjectFlockId: 1, + PeriodDate: periodDate, + DepreciationPercentEffective: 11.1, + DepreciationValue: 111, + PulletCostDayNTotal: 1001, + Components: json.RawMessage(`{"kandang_count":0,"kandang":[]}`), + }, + }, + } + + svc := &repportService{ + Validate: validator.New(), + ExpenseDepreciationRepo: repo, + ExpenseRealizationRepo: &expenseRealizationRepoMock{}, + HppCostRepo: &hppCostRepoMock{ + kandangIDsByFarm: map[uint][]uint{ + 1: {10}, + 2: {20}, + }, + }, + HppV2Svc: &hppV2ServiceMock{ + breakdownByPFK: map[uint]*approvalService.HppV2Breakdown{ + 10: depreciationBreakdown(10, 100, "Kandang A", 999, 9999, 10), + 20: depreciationBreakdown(20, 200, "Kandang B", 200, 2000, 10), + }, + }, + } + + rows, _, err := getExpenseDepreciationByQuery(t, svc, "page=1&limit=10&period=2026-06-05") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(rows) != 2 { + t.Fatalf("expected 2 rows, got %d", len(rows)) + } + if rows[0].ProjectFlockID != 1 || rows[0].DepreciationValue != 111 { + t.Fatalf("expected farm 1 use existing snapshot value 111, got %+v", rows[0]) + } + if rows[1].ProjectFlockID != 2 || rows[1].DepreciationValue != 200 { + t.Fatalf("expected farm 2 recomputed value 200, got %+v", rows[1]) + } + if repo.upsertSnapshotCalls != 1 { + t.Fatalf("expected one upsert call for missing farms, got %d", repo.upsertSnapshotCalls) + } + if len(repo.upsertedSnapshots) != 1 || repo.upsertedSnapshots[0].ProjectFlockId != 2 { + t.Fatalf("expected upsert only farm 2 snapshot, got %+v", repo.upsertedSnapshots) + } + if repo.getSnapshotsCalls != 1 { + t.Fatalf("expected snapshot fetch called once, got %d", repo.getSnapshotsCalls) + } +} + func TestUpsertExpenseDepreciationManualInput_InvalidatesSnapshotsFromCutoverDate(t *testing.T) { repo := &expenseDepreciationRepoMock{ manualInputs: []repportRepo.FarmDepreciationManualInputRow{ @@ -411,6 +618,82 @@ func TestUpsertExpenseDepreciationManualInput_InvalidatesSnapshotsFromCutoverDat } } +func getExpenseDepreciationByQuery(t *testing.T, svc *repportService, query string) ([]dto.ExpenseDepreciationRowDTO, *dto.ExpenseDepreciationMetaDTO, error) { + t.Helper() + + app := fiber.New() + var ( + rows []dto.ExpenseDepreciationRowDTO + meta *dto.ExpenseDepreciationMetaDTO + ) + app.Get("/", func(c *fiber.Ctx) error { + resultRows, resultMeta, err := svc.GetExpenseDepreciation(c) + if err != nil { + return err + } + rows = resultRows + meta = resultMeta + return c.SendStatus(fiber.StatusOK) + }) + + target := "/" + if query != "" { + target += "?" + query + } + resp, err := app.Test(httptest.NewRequest(http.MethodGet, target, nil)) + if err != nil { + return nil, nil, err + } + if resp.StatusCode != fiber.StatusOK { + return nil, nil, fiber.NewError(resp.StatusCode, "request failed") + } + return rows, meta, nil +} + +func depreciationBreakdown( + projectFlockKandangID uint, + kandangID uint, + kandangName string, + depreciationValue float64, + pulletCostDayN float64, + depreciationPercent float64, +) *approvalService.HppV2Breakdown { + return &approvalService.HppV2Breakdown{ + ProjectFlockKandangID: projectFlockKandangID, + KandangID: kandangID, + KandangName: kandangName, + HouseType: "close_house", + Components: []approvalService.HppV2Component{ + { + Code: "DEPRECIATION", + Title: "Depreciation", + Total: depreciationValue, + Parts: []approvalService.HppV2ComponentPart{ + { + Code: "normal_transfer", + Total: depreciationValue, + Details: map[string]any{ + "schedule_day": 1, + "depreciation_percent": depreciationPercent, + "pullet_cost_day_n": pulletCostDayN, + "source_project_flock_id": 77, + "origin_date": "2026-01-01", + }, + References: []approvalService.HppV2Reference{ + { + Type: "laying_transfer", + ID: 701, + Date: "2026-05-20", + Qty: 100, + }, + }, + }, + }, + }, + }, + } +} + func decodeDepreciationComponents(t *testing.T, raw []byte) depreciationFarmComponents { t.Helper() var out depreciationFarmComponents diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index c5e89e3c..02bfdf5a 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -231,25 +231,9 @@ func (s *repportService) GetExpenseDepreciation(ctx *fiber.Ctx) ([]dto.ExpenseDe farmNameByID[row.ProjectFlockID] = row.FarmName } - snapshots, err := s.ExpenseDepreciationRepo.GetSnapshotsByPeriodAndFarmIDs(ctx.Context(), periodDate, farmIDs) - if err != nil { - return nil, nil, err - } - snapshotByFarmID := make(map[uint]entity.FarmDepreciationSnapshot, len(snapshots)) - for _, row := range snapshots { - snapshotByFarmID[row.ProjectFlockId] = row - } - - missingFarmIDs := make([]uint, 0) - for _, farmID := range farmIDs { - if _, exists := snapshotByFarmID[farmID]; exists { - continue - } - missingFarmIDs = append(missingFarmIDs, farmID) - } - - if len(missingFarmIDs) > 0 { - computedSnapshots, computeErr := s.computeExpenseDepreciationSnapshots(ctx.Context(), periodDate, missingFarmIDs, farmNameByID) + snapshotByFarmID := make(map[uint]entity.FarmDepreciationSnapshot) + if params.ForceRecompute { + computedSnapshots, computeErr := s.computeExpenseDepreciationSnapshots(ctx.Context(), periodDate, farmIDs, farmNameByID) if computeErr != nil { return nil, nil, computeErr } @@ -257,10 +241,43 @@ func (s *repportService) GetExpenseDepreciation(ctx *fiber.Ctx) ([]dto.ExpenseDe if err := s.ExpenseDepreciationRepo.UpsertSnapshots(ctx.Context(), computedSnapshots); err != nil { return nil, nil, err } + snapshotByFarmID = make(map[uint]entity.FarmDepreciationSnapshot, len(computedSnapshots)) for _, row := range computedSnapshots { snapshotByFarmID[row.ProjectFlockId] = row } } + } else { + snapshots, err := s.ExpenseDepreciationRepo.GetSnapshotsByPeriodAndFarmIDs(ctx.Context(), periodDate, farmIDs) + if err != nil { + return nil, nil, err + } + snapshotByFarmID = make(map[uint]entity.FarmDepreciationSnapshot, len(snapshots)) + for _, row := range snapshots { + snapshotByFarmID[row.ProjectFlockId] = row + } + + missingFarmIDs := make([]uint, 0) + for _, farmID := range farmIDs { + if _, exists := snapshotByFarmID[farmID]; exists { + continue + } + missingFarmIDs = append(missingFarmIDs, farmID) + } + + if len(missingFarmIDs) > 0 { + computedSnapshots, computeErr := s.computeExpenseDepreciationSnapshots(ctx.Context(), periodDate, missingFarmIDs, farmNameByID) + if computeErr != nil { + return nil, nil, computeErr + } + if len(computedSnapshots) > 0 { + if err := s.ExpenseDepreciationRepo.UpsertSnapshots(ctx.Context(), computedSnapshots); err != nil { + return nil, nil, err + } + for _, row := range computedSnapshots { + snapshotByFarmID[row.ProjectFlockId] = row + } + } + } } rows := make([]dto.ExpenseDepreciationRowDTO, 0, len(candidateRows)) @@ -2717,6 +2734,7 @@ func (s *repportService) parseExpenseDepreciationQuery(ctx *fiber.Ctx) (*validat rawLocation := ctx.Query("location_id", "") rawProjectFlock := ctx.Query("project_flock_id", "") period := strings.TrimSpace(ctx.Query("period", "")) + forceRecompute := ctx.QueryBool("force_recompute", false) areaIDs, err := parseCommaSeparatedInt64s(rawArea) if err != nil { @@ -2766,6 +2784,7 @@ func (s *repportService) parseExpenseDepreciationQuery(ctx *fiber.Ctx) (*validat Page: page, Limit: limit, Period: period, + ForceRecompute: forceRecompute, ProjectFlockIDs: projectFlockIDs, AreaIDs: areaIDs, LocationIDs: locationIDs, diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index f34e2702..fac647f4 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -84,6 +84,7 @@ type ExpenseDepreciationQuery struct { Page int `query:"page" validate:"omitempty,min=1,gt=0"` Limit int `query:"limit" validate:"omitempty,min=1,max=1000,gt=0"` Period string `query:"period" validate:"required,datetime=2006-01-02"` + ForceRecompute bool `query:"force_recompute"` ProjectFlockIDs []int64 `query:"-"` AreaIDs []int64 `query:"-"` LocationIDs []int64 `query:"-"`