Merge branch 'codex/recording' into 'development'

Codex/recording

See merge request mbugroup/lti-api!435
This commit is contained in:
Giovanni Gabriel Septriadi
2026-04-22 05:58:42 +00:00
20 changed files with 1380 additions and 211 deletions
@@ -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
@@ -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,82 @@ 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,
}
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).
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(
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 {
return 0, err
}
return total, nil
}
func (r *HppV2RepositoryImpl) GetFarmDepreciationSnapshotByProjectFlockIDAndPeriod(
ctx context.Context,
projectFlockID uint,
@@ -393,7 +470,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 +832,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
@@ -2,6 +2,7 @@ package repository
import (
"context"
"fmt"
"math"
"testing"
"time"
@@ -96,6 +97,116 @@ func TestHppV2RepositoryGetEggTerjualProratesHistoricalFarmSalesFromAdjustments(
assertFloatEquals(t, totalWeightKg, 1.4)
}
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),
(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-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, 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')`,
`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),
(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, 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),
(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}
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, 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, 240)
}
func setupHppV2RepositoryTestDB(t *testing.T) *gorm.DB {
t.Helper()
@@ -111,6 +222,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 +291,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,
@@ -187,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
@@ -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,
@@ -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
@@ -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;
@@ -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;
@@ -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"`
+6 -5
View File
@@ -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"`
@@ -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)
@@ -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
@@ -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)
@@ -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,362 @@ 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
}
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) {
if transfer.SourceProjectFlockKandangId != nil && *transfer.SourceProjectFlockKandangId != 0 {
return *transfer.SourceProjectFlockKandangId, nil
}
return 0, nil
}
if err != nil {
return 0, err
}
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) {
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 +2884,7 @@ func (s *recordingService) reflowSyncRecordingStocks(
recordingID uint,
existing []entity.RecordingStock,
incoming []validation.Stock,
ownerProjectFlockKandangID *uint,
note string,
actorID uint,
) error {
@@ -2608,21 +2905,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)
@@ -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
}
@@ -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(),
@@ -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
@@ -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
@@ -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,
@@ -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:"-"`
+5 -4
View File
@@ -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