fix recording standar prod laying

This commit is contained in:
giovanni
2026-06-07 18:55:24 +07:00
parent edfd6ac95c
commit 2216f572c2
6 changed files with 445 additions and 11 deletions
@@ -0,0 +1,14 @@
-- Reverse UPSERT: hapus baris PFK 47 & 48 yang kemungkinan baru diinsert oleh up migration ini.
-- Jika sebelumnya sudah ada (ON CONFLICT DO UPDATE), baris ini akan terhapus —
-- restore manual dari backup jika diperlukan.
DELETE FROM farm_depreciation_manual_inputs
WHERE project_flock_id IN (47, 48);
-- UPDATE rows untuk PFK 427 tidak bisa di-reverse secara presisi:
-- nilai total_cost sebelum migration ini tidak tersimpan di migration history
-- (data awal di-load via cmd/import-farm-depreciation-manual-inputs dari Excel).
-- PFK 10 dan 11 tidak berubah (nilai sama dengan state dari migration 20260529144559).
-- Jika perlu rollback penuh: restore dari database backup atau re-import Excel lama.
-- Recompute snapshots setelah rollback
TRUNCATE TABLE farm_depreciation_snapshots;
@@ -0,0 +1,105 @@
UPDATE farm_depreciation_manual_inputs
SET total_cost = 1900157533.55,
cutover_date = DATE '2026-02-28',
updated_at = NOW()
WHERE project_flock_id = 10;
UPDATE farm_depreciation_manual_inputs
SET total_cost = 146658321.066,
cutover_date = DATE '2026-02-28',
updated_at = NOW()
WHERE project_flock_id = 13;
UPDATE farm_depreciation_manual_inputs
SET total_cost = 51824694.138,
cutover_date = DATE '2026-02-28',
updated_at = NOW()
WHERE project_flock_id = 17;
UPDATE farm_depreciation_manual_inputs
SET total_cost = 15491774.796,
cutover_date = DATE '2026-02-28',
updated_at = NOW()
WHERE project_flock_id = 8;
-- Cutover 2026-02-28 (lanjutan)
UPDATE farm_depreciation_manual_inputs
SET total_cost = 575074391.36, cutover_date = DATE '2026-02-28', updated_at = NOW()
WHERE project_flock_id = 4;
UPDATE farm_depreciation_manual_inputs
SET total_cost = 578360642.51, cutover_date = DATE '2026-02-28', updated_at = NOW()
WHERE project_flock_id = 5;
UPDATE farm_depreciation_manual_inputs
SET total_cost = 880983605.92, cutover_date = DATE '2026-02-28', updated_at = NOW()
WHERE project_flock_id = 6;
UPDATE farm_depreciation_manual_inputs
SET total_cost = 391669576.153, cutover_date = DATE '2026-02-28', updated_at = NOW()
WHERE project_flock_id = 9;
UPDATE farm_depreciation_manual_inputs
SET total_cost = 2521797832.14, cutover_date = DATE '2026-02-28', updated_at = NOW()
WHERE project_flock_id = 11;
UPDATE farm_depreciation_manual_inputs
SET total_cost = 139227054.164, cutover_date = DATE '2026-02-28', updated_at = NOW()
WHERE project_flock_id = 12;
UPDATE farm_depreciation_manual_inputs
SET total_cost = 380083106.836, cutover_date = DATE '2026-02-28', updated_at = NOW()
WHERE project_flock_id = 14;
UPDATE farm_depreciation_manual_inputs
SET total_cost = 705136853.847, cutover_date = DATE '2026-02-28', updated_at = NOW()
WHERE project_flock_id = 15;
UPDATE farm_depreciation_manual_inputs
SET total_cost = 209816474.000, cutover_date = DATE '2026-02-28', updated_at = NOW()
WHERE project_flock_id = 18;
UPDATE farm_depreciation_manual_inputs
SET total_cost = 557606867.000, cutover_date = DATE '2026-02-28', updated_at = NOW()
WHERE project_flock_id = 19;
UPDATE farm_depreciation_manual_inputs
SET total_cost = 239330456.11, cutover_date = DATE '2026-02-28', updated_at = NOW()
WHERE project_flock_id = 20;
UPDATE farm_depreciation_manual_inputs
SET total_cost = 4724203916.72, cutover_date = DATE '2026-02-28', updated_at = NOW()
WHERE project_flock_id = 26;
-- Cutover 2026-05-15
UPDATE farm_depreciation_manual_inputs
SET total_cost = 5449963647.43, cutover_date = DATE '2026-05-15', updated_at = NOW()
WHERE project_flock_id = 27;
-- Cutover 2026-06-08 (upsert — row mungkin belum ada)
INSERT INTO farm_depreciation_manual_inputs (project_flock_id, total_cost, cutover_date, created_at, updated_at)
VALUES (47, 5395429899.42, DATE '2026-06-08', NOW(), NOW())
ON CONFLICT (project_flock_id) DO UPDATE
SET total_cost = EXCLUDED.total_cost,
cutover_date = EXCLUDED.cutover_date,
updated_at = NOW();
-- Cutover 2026-06-16 (upsert — row mungkin belum ada)
INSERT INTO farm_depreciation_manual_inputs (project_flock_id, total_cost, cutover_date, created_at, updated_at)
VALUES (48, 5514616442.08, DATE '2026-06-16', NOW(), NOW())
ON CONFLICT (project_flock_id) DO UPDATE
SET total_cost = EXCLUDED.total_cost,
cutover_date = EXCLUDED.cutover_date,
updated_at = NOW();
-- Pengaman: pastikan snapshot di-recompute dengan total_cost baru
-- saat user request /api/reports/expense/depreciation
TRUNCATE TABLE farm_depreciation_snapshots;
@@ -387,35 +387,87 @@ func (s productionStandardService) EnsureWeekAvailable(ctx context.Context, stan
return nil return nil
} }
week := ((day - 1) / 7) + 1 requestedWeek := ((day - 1) / 7) + 1
if week <= 0 { if requestedWeek <= 0 {
return nil return nil
} }
upperCategory := strings.ToUpper(category) upperCategory := strings.ToUpper(category)
if upperCategory == string(utils.ProjectFlockCategoryLaying) { if upperCategory == string(utils.ProjectFlockCategoryLaying) {
detail, err := s.ProductionStandardDetailRepo.GetByStandardIDAndWeek(ctx, standardID, week) effectiveWeek := requestedWeek
firstCommonWeek, ok, err := s.layingFirstCommonStandardWeek(ctx, standardID)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Standart production tidak tersedia untuk week %d", week))
}
return err return err
} }
if detail == nil { if ok && requestedWeek < firstCommonWeek {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Standart production tidak tersedia untuk week %d", week)) effectiveWeek = firstCommonWeek
} }
detail, err := s.ProductionStandardDetailRepo.GetByStandardIDAndWeek(ctx, standardID, effectiveWeek)
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
}
growthDetail, err := s.StandardGrowthDetailRepo.GetByStandardIDAndWeek(ctx, standardID, effectiveWeek)
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
}
if detail != nil && growthDetail != nil {
return nil
}
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Standart production tidak tersedia untuk week %d", requestedWeek))
} }
growthDetail, err := s.StandardGrowthDetailRepo.GetByStandardIDAndWeek(ctx, standardID, week) growthDetail, err := s.StandardGrowthDetailRepo.GetByStandardIDAndWeek(ctx, standardID, requestedWeek)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Standart production tidak tersedia untuk week %d", week)) return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Standart production tidak tersedia untuk week %d", requestedWeek))
} }
return err return err
} }
if growthDetail == nil { if growthDetail == nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Standart production tidak tersedia untuk week %d", week)) return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Standart production tidak tersedia untuk week %d", requestedWeek))
} }
return nil return nil
} }
func (s productionStandardService) layingFirstCommonStandardWeek(ctx context.Context, standardID uint) (int, bool, error) {
details, err := s.ProductionStandardDetailRepo.GetByProductionStandardID(ctx, standardID)
if err != nil {
return 0, false, err
}
detailWeeks := make(map[int]struct{}, len(details))
for _, detail := range details {
if detail.Week <= 0 {
continue
}
detailWeeks[detail.Week] = struct{}{}
}
growthDetails, err := s.StandardGrowthDetailRepo.GetByProductionStandardID(ctx, standardID)
if err != nil {
return 0, false, err
}
firstCommonWeek := 0
for _, detail := range growthDetails {
if detail.Week <= 0 {
continue
}
if _, ok := detailWeeks[detail.Week]; !ok {
continue
}
if firstCommonWeek == 0 || detail.Week < firstCommonWeek {
firstCommonWeek = detail.Week
}
}
return firstCommonWeek, firstCommonWeek > 0, nil
}
@@ -0,0 +1,95 @@
package service
import (
"context"
"strings"
"testing"
"github.com/glebarez/sqlite"
repositories "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gorm.io/gorm"
)
func TestEnsureWeekAvailableAllowsLayingBeforeFirstCommonStandardWeek(t *testing.T) {
svc := setupProductionStandardServiceTest(t)
if err := svc.EnsureWeekAvailable(context.Background(), 1, string(utils.ProjectFlockCategoryLaying), 85); err != nil {
t.Fatalf("expected pre-standard laying week to be allowed, got %v", err)
}
}
func TestEnsureWeekAvailableRejectsLayingMissingWeekAfterStandardStarts(t *testing.T) {
svc := setupProductionStandardServiceTest(t)
err := svc.EnsureWeekAvailable(context.Background(), 1, string(utils.ProjectFlockCategoryLaying), 127)
if err == nil {
t.Fatal("expected missing laying standard week to be rejected")
}
if !strings.Contains(err.Error(), "week 19") {
t.Fatalf("expected error to mention requested week 19, got %v", err)
}
}
func TestEnsureWeekAvailableKeepsGrowingWeekStrict(t *testing.T) {
svc := setupProductionStandardServiceTest(t)
err := svc.EnsureWeekAvailable(context.Background(), 2, string(utils.ProjectFlockCategoryGrowing), 8)
if err == nil {
t.Fatal("expected missing growing standard week to be rejected")
}
if !strings.Contains(err.Error(), "week 2") {
t.Fatalf("expected error to mention requested week 2, got %v", err)
}
}
func setupProductionStandardServiceTest(t *testing.T) productionStandardService {
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)
}
statements := []string{
`CREATE TABLE production_standard_details (
id INTEGER PRIMARY KEY,
production_standard_id INTEGER NOT NULL,
week INTEGER NOT NULL,
target_hen_day_production NUMERIC NULL,
target_hen_house_production NUMERIC NULL,
target_egg_weight NUMERIC NULL,
target_egg_mass NUMERIC NULL,
standard_fcr NUMERIC NULL,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL
)`,
`CREATE TABLE standard_growth_details (
id INTEGER PRIMARY KEY,
production_standard_id INTEGER NOT NULL,
target_mean_bw NUMERIC NULL,
max_depletion NUMERIC NULL,
min_uniformity NUMERIC NOT NULL,
week INTEGER NOT NULL,
feed_intake NUMERIC NULL,
created_at TIMESTAMP NULL,
created_by INTEGER NOT NULL
)`,
`INSERT INTO production_standard_details (id, production_standard_id, week, standard_fcr) VALUES
(1, 1, 18, 2.1)`,
`INSERT INTO standard_growth_details (id, production_standard_id, week, min_uniformity, created_by) VALUES
(1, 1, 18, 80, 1),
(2, 2, 1, 80, 1)`,
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed preparing schema: %v", err)
}
}
return productionStandardService{
ProductionStandardDetailRepo: repositories.NewProductionStandardDetailRepository(db),
StandardGrowthDetailRepo: repositories.NewStandardGrowthDetailRepository(db),
}
}
@@ -205,6 +205,7 @@ func AttachProductionStandards(ctx context.Context, db *gorm.DB, warnOnly bool,
standardDetailByStd := make(map[uint]map[int]*entity.ProductionStandardDetail, len(standardIDs)) standardDetailByStd := make(map[uint]map[int]*entity.ProductionStandardDetail, len(standardIDs))
growthDetailByStd := make(map[uint]map[int]*entity.StandardGrowthDetail, len(standardIDs)) growthDetailByStd := make(map[uint]map[int]*entity.StandardGrowthDetail, len(standardIDs))
firstCommonWeekByStd := make(map[uint]int, len(standardIDs))
for standardID := range standardIDs { for standardID := range standardIDs {
details, err := standardDetailRepo.GetByProductionStandardID(ctx, standardID) details, err := standardDetailRepo.GetByProductionStandardID(ctx, standardID)
@@ -242,6 +243,10 @@ func AttachProductionStandards(ctx context.Context, db *gorm.DB, warnOnly bool,
growthMap[growth.Week] = &growth growthMap[growth.Week] = &growth
} }
growthDetailByStd[standardID] = growthMap growthDetailByStd[standardID] = growthMap
if firstCommonWeek, ok := firstCommonStandardWeek(detailMap, growthMap); ok {
firstCommonWeekByStd[standardID] = firstCommonWeek
}
} }
// Batch-load laying transfer targets → EARLIEST source PFK chick_in_date per target. // Batch-load laying transfer targets → EARLIEST source PFK chick_in_date per target.
@@ -284,6 +289,9 @@ func AttachProductionStandards(ctx context.Context, db *gorm.DB, warnOnly bool,
continue continue
} }
week := computeTransferAwareWeek(item, sourceChickInByTarget) week := computeTransferAwareWeek(item, sourceChickInByTarget)
if firstCommonWeek, ok := firstCommonWeekByStd[standardID]; ok {
week = effectiveProductionStandardWeek(item, week, firstCommonWeek)
}
item.StandardWeek = &week item.StandardWeek = &week
cacheKey := standardKey{standardID: standardID, week: week} cacheKey := standardKey{standardID: standardID, week: week}
if cached, ok := cache[cacheKey]; ok { if cached, ok := cache[cacheKey]; ok {
@@ -324,6 +332,38 @@ func applyProductionStandardValues(item *entity.Recording, values productionStan
item.StandardFcr = fcr item.StandardFcr = fcr
} }
func firstCommonStandardWeek(
detailMap map[int]*entity.ProductionStandardDetail,
growthMap map[int]*entity.StandardGrowthDetail,
) (int, bool) {
firstWeek := 0
for week := range detailMap {
if week <= 0 {
continue
}
if _, ok := growthMap[week]; !ok {
continue
}
if firstWeek == 0 || week < firstWeek {
firstWeek = week
}
}
return firstWeek, firstWeek > 0
}
func effectiveProductionStandardWeek(item *entity.Recording, actualWeek int, firstCommonWeek int) int {
if item == nil || actualWeek <= 0 || firstCommonWeek <= 0 {
return actualWeek
}
if !IsLayingRecording(*item) {
return actualWeek
}
if actualWeek < firstCommonWeek {
return firstCommonWeek
}
return actualWeek
}
// collectLayingPFKIDs mengumpulkan semua project_flock_kandang_id dari recording laying // collectLayingPFKIDs mengumpulkan semua project_flock_kandang_id dari recording laying
func collectLayingPFKIDs(items []*entity.Recording) []uint { func collectLayingPFKIDs(items []*entity.Recording) []uint {
seen := make(map[uint]struct{}) seen := make(map[uint]struct{})
@@ -1,10 +1,15 @@
package recording package recording
import ( import (
"context"
"testing" "testing"
"time"
"github.com/glebarez/sqlite"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gorm.io/gorm"
) )
func TestMapDepletionsKeepsSourceWarehouseRoutes(t *testing.T) { func TestMapDepletionsKeepsSourceWarehouseRoutes(t *testing.T) {
@@ -45,3 +50,126 @@ func TestMapEggsSetsProjectFlockKandangID(t *testing.T) {
t.Fatalf("expected project flock kandang id 44, got %+v", got[0].ProjectFlockKandangId) t.Fatalf("expected project flock kandang id 44, got %+v", got[0].ProjectFlockKandangId)
} }
} }
func TestAttachProductionStandardsClampsLayingPreStandardWeek(t *testing.T) {
db := setupAttachProductionStandardTestDB(t)
day := 91
recordDate := time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC)
chickInDate := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
recording := &entity.Recording{
Id: 501,
ProjectFlockKandangId: 103,
RecordDatetime: recordDate,
Day: &day,
ProjectFlockKandang: &entity.ProjectFlockKandang{
Id: 103,
ProjectFlock: entity.ProjectFlock{
Id: 52,
Category: string(utils.ProjectFlockCategoryLaying),
ProductionStandardId: 1,
ProductionStandard: entity.ProductionStandard{
Id: 1,
Name: "STD Laying",
},
},
},
}
actualWeek := computeTransferAwareWeek(recording, map[uint]time.Time{103: chickInDate})
if actualWeek != 13 {
t.Fatalf("expected actual transfer-aware week 13, got %d", actualWeek)
}
if err := AttachProductionStandards(context.Background(), db, false, nil, recording); err != nil {
t.Fatalf("expected attach standard to succeed, got %v", err)
}
if recording.Day == nil || *recording.Day != 91 {
t.Fatalf("expected actual recording day to remain 91, got %+v", recording.Day)
}
if recording.StandardWeek == nil || *recording.StandardWeek != 18 {
t.Fatalf("expected effective standard week 18, got %+v", recording.StandardWeek)
}
if recording.StandardFeedIntake == nil || *recording.StandardFeedIntake != 120 {
t.Fatalf("expected feed intake std from week 18, got %+v", recording.StandardFeedIntake)
}
if recording.StandardHenDay == nil || *recording.StandardHenDay != 80 {
t.Fatalf("expected hen day std from week 18, got %+v", recording.StandardHenDay)
}
if recording.StandardFcr == nil || *recording.StandardFcr != 2.1 {
t.Fatalf("expected fcr std from week 18, got %+v", recording.StandardFcr)
}
}
func setupAttachProductionStandardTestDB(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)
}
statements := []string{
`CREATE TABLE production_standard_details (
id INTEGER PRIMARY KEY,
production_standard_id INTEGER NOT NULL,
week INTEGER NOT NULL,
target_hen_day_production NUMERIC NULL,
target_hen_house_production NUMERIC NULL,
target_egg_weight NUMERIC NULL,
target_egg_mass NUMERIC NULL,
standard_fcr NUMERIC NULL,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL
)`,
`CREATE TABLE standard_growth_details (
id INTEGER PRIMARY KEY,
production_standard_id INTEGER NOT NULL,
target_mean_bw NUMERIC NULL,
max_depletion NUMERIC NULL,
min_uniformity NUMERIC NOT NULL,
week INTEGER NOT NULL,
feed_intake NUMERIC NULL,
created_at TIMESTAMP NULL,
created_by INTEGER NOT NULL
)`,
`CREATE TABLE laying_transfer_targets (
id INTEGER PRIMARY KEY,
laying_transfer_id INTEGER NOT NULL,
target_project_flock_kandang_id INTEGER NOT NULL,
deleted_at TIMESTAMP NULL
)`,
`CREATE TABLE laying_transfers (
id INTEGER PRIMARY KEY,
source_project_flock_kandang_id INTEGER NULL,
deleted_at TIMESTAMP NULL
)`,
`CREATE TABLE project_chickins (
id INTEGER PRIMARY KEY,
project_flock_kandang_id INTEGER NOT NULL,
chick_in_date TIMESTAMP NOT NULL,
deleted_at TIMESTAMP NULL
)`,
`INSERT INTO production_standard_details
(id, production_standard_id, week, target_hen_day_production, target_hen_house_production, target_egg_weight, target_egg_mass, standard_fcr)
VALUES (1, 1, 18, 80, 70, 55, 44, 2.1)`,
`INSERT INTO standard_growth_details
(id, production_standard_id, week, feed_intake, max_depletion, min_uniformity, created_by)
VALUES (1, 1, 18, 120, 1.5, 80, 1)`,
`INSERT INTO laying_transfers (id, source_project_flock_kandang_id, deleted_at) VALUES
(77, 83, NULL)`,
`INSERT INTO laying_transfer_targets (id, laying_transfer_id, target_project_flock_kandang_id, deleted_at) VALUES
(88, 77, 103, NULL)`,
`INSERT INTO project_chickins (id, project_flock_kandang_id, chick_in_date, deleted_at) VALUES
(99, 83, '2026-01-01 00:00:00', NULL)`,
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed preparing schema: %v", err)
}
}
return db
}